Military System

PWN TAMUctf  ·  2026-03-26  ·  ARM64 UAF → status-leak → tcache poison into BSS
gigem{st4le_dr4ft_tcache_auth_bypass}

Military System is an ARM64 (aarch64) heap challenge. It runs behind qemu-aarch64-static in a container, is built with every modern mitigation turned on — full RELRO, stack canary, NX, PIE, and even a seccomp sandbox — and presents a small menu for managing “channels” and their draft messages. The flag is gated behind a clearance check, and nothing in the normal menu flow grants you clearance. The whole solve is a chain of three primitives that each hand you the next: a use-after-free from a close-without-null bug, an information oracle hiding in the status view, and a tcache poisoning write aimed at a BSS global.

The interesting part of this challenge is not any single exotic technique — it is how a sloppy lifecycle (freeing a buffer but leaving the pointer live) cascades into a full authentication bypass against a binary that, on paper, is locked down hard.

Table of Contents

  1. Reconnaissance & the Target Data
  2. The Bug: Close Without NULL (UAF)
  3. The Status View as a Leak Oracle
  4. Where the Auth Check Actually Lives
  5. Tcache Poisoning into BSS
  6. Putting the Chain Together
  7. Pitfalls
  8. Key Lessons

1 Reconnaissance & the Target Data

Triage first. file and checksec tell us what we are up against, and the qemu wrapper is a strong hint the author wanted this run under emulation rather than natively:

PropertyValue
ArchitectureARM64 / aarch64
MitigationsFull RELRO, canary, NX, PIE
Sandboxseccomp (installed at startup)
Runtimeqemu-aarch64-static inside a container

Decompilation (Ghidra headless on main, render_status, and install_seccomp, with radare2 to double-check struct offsets) reveals a channel manager. There are 2 channel slots, each holding a draft buffer of 64–128 bytes. The per-channel struct is 56 bytes laid out as:

// channel struct = 56 bytes
struct channel {
    int      open;        // +0x00  (4)  is the channel open?
    int      _pad;        // +0x04  (4)  alignment padding
    uint64   draft_size;  // +0x08  (8)  size of the draft buffer
    void    *draft_ptr;   // +0x10  (8)  heap pointer to the draft
    char     label[32];   // +0x18  (32) channel label
};

The globals that matter all sit in BSS at fixed offsets from the PIE base:

SymbolAddressRole
g_opsPIE + 0x20020operations table
g_channelsPIE + 0x20028the 2 channel slots
g_authPIE + 0x200a0clearance state (the target)
render_statusPIE + 0x17a0status printer (the leak)
Read the target before touching the heap Knowing where the clearance value lives (g_auth in BSS, not on the heap) decides the entire exploit strategy. A plain heap overflow can never reach a BSS global — which is exactly why the solve has to redirect an allocation rather than smash a neighbour.

2 The Bug: Close Without NULL (UAF)

The “close channel” action free()s the draft buffer — and then walks away. It does not NULL the draft_ptr and it does not zero draft_size. The pointer and size are left stale, pointing at a chunk that now belongs to the allocator’s tcache.

That would be harmless if every code path respected the open flag. But the “edit draft” function doesn’t. Its only guard is:

if (ch->draft_ptr != NULL && ch->draft_size != 0) {
    // write user data into *draft_ptr  -- no 'open' check!
}

Since close left both fields populated, “edit draft” happily writes into a freed chunk. That is a classic use-after-free, and specifically a write primitive into a tcache-linked chunk — which is exactly what tcache poisoning needs.

Close-without-NULL is the whole game Freeing a buffer is only half of a safe free. Leaving the pointer live turns the freelist metadata (the fd pointer that lives in the first 8 bytes of a freed chunk) into something the attacker can overwrite through a legitimate-looking “edit” path.

3 The Status View as a Leak Oracle

Before we can poison anything we need to defeat PIE and ASLR — we need a heap address and a PIE address. Both are handed to us by the status view, and it is clearly intentional. render_status has a special branch for a channel that is closed but still has a non-NULL draft pointer — i.e. precisely the stale-draft state the close bug creates. In that state it prints two pointers:

heap leak
last_draft=%p
PIE leak
diagnostic_hook=%p

The last_draft=%p value is the stale heap pointer — our heap base. The diagnostic_hook=%p value is a function pointer to render_status itself; since render_status sits at a known PIE + 0x17a0, subtracting that constant gives us the PIE base. Two leaks from one “diagnostic” path, both only reachable because the draft pointer was left stale.

Status/info functions on freed objects are oracles A render or “view” routine that inspects an object the program believes is dead will happily print live pointers. Whenever a status path reads fields on a closed/freed object, treat it as a candidate info leak before anything else.

4 Where the Auth Check Actually Lives

The clearance gate is not a heap value and not a simple boolean. It is a magic-number check on a BSS global:

// pass the clearance check iff this 64-bit slot holds the magic
if (*(uint64_t *)(g_auth + 0x20) == 0x7434F4D44) {
    // clearance granted -> flag is readable
}

The target address is g_auth + 0x20, in BSS. That is the single fact that shapes the exploit: because the goal is a controlled write to a chosen address (a BSS global), not corruption of a heap neighbour, we need an arbitrary-write primitive. Tcache poisoning gives us exactly that — it lets us hand malloc an attacker-chosen address so the next allocation lands directly on g_auth + 0x20, where we write the magic value.

The magic value is ASCII 0x7_434F_4D44 is just packed text (“DMOC”-ish bytes with a high nibble). You don’t need to understand its meaning — you need to write those 8 bytes verbatim to g_auth + 0x20. Keep the exact constant from the binary; don’t retype it from memory.

5 Tcache Poisoning into BSS

This binary links a modern glibc (2.32+), so tcache uses safe-linking: the fd pointer of a freed chunk is stored XOR-mangled with a per-chunk key derived from the chunk’s own address. With our heap leak in hand, defeating it is a one-liner. To make the freelist hand out target, we write the poisoned fd:

# safe-linking: mangle the target with the chunk's address >> 12
poisoned_fd = (chunk_addr >> 12) ^ target_addr
# target_addr = PIE_base + g_auth_off + 0x20   (g_auth + 0x20 in BSS)

We write that value into the freed chunk via the UAF edit primitive from step 2. On the next two allocations of that size class, the second malloc returns a pointer to g_auth + 0x20, and we write the clearance magic straight into it.

Two details make this work cleanly on this target:

free draft (no NULL) → UAF edit: write poisoned fd → malloc #1 (pops chunk) → malloc #2 = g_auth+0x20 → write magic → cleared
Safe-linking is not a wall once you have a heap leak The mangling key is chunk_addr >> 12 — which the leak gives you. Safe-linking only costs the attacker one XOR; it is a speed bump, not a defense, the moment a heap address leaks.

6 Putting the Chain Together

Each primitive feeds the next, which is what makes this a satisfying chain rather than three unrelated tricks:

  1. Create a channel with a draft, then close it — the bug leaves the draft pointer and size live (UAF state).
  2. Open the status view on that closed-but-non-NULL channel to leak last_draft (heap base) and diagnostic_hook (→ PIE base via −0x17a0).
  3. Compute target = PIE_base + g_auth_off + 0x20 and poisoned_fd = (chunk_addr >> 12) ^ target.
  4. Use the UAF edit to write poisoned_fd into the freed chunk’s fd.
  5. Allocate twice; the second allocation lands on g_auth + 0x20. Write 0x7_434F_4D44.
  6. Trigger the clearance-gated action — it reads the flag.

The exploit was scripted in pwntools against the remote over SSL. Local testing first (under the same qemu wrapper) validated the offsets before touching the remote.

[+] leak: last_draft = 0x... (heap base) [+] leak: diagnostic_hook = 0x... -> PIE base (−0x17a0) [*] target = g_auth + 0x20 (BSS, 16-byte aligned) [*] poisoned fd = (chunk >> 12) ^ target [*] malloc x2 -> allocation lands on g_auth+0x20 [*] wrote clearance magic 0x7_434F_4D44 [+] clearance granted -> gigem{st4le_dr4ft_tcache_auth_bypass}
gigem{st4le_dr4ft_tcache_auth_bypass}

7 Pitfalls

8 Key Lessons

A free that doesn’t NULL the pointer is a UAF waiting for a writer. The close bug alone is inert; it becomes lethal because a different function (edit draft) trusts draft_ptr != NULL instead of the open flag. Audit every path that touches a freed object’s fields, not just the free itself.

Tools: Ghidra headless (decompile main, render_status, install_seccomp), radare2 (struct-offset verification), pwntools (exploit scripting over SSL), qemu-aarch64-static (local run).