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.
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:
| Property | Value |
|---|---|
| Architecture | ARM64 / aarch64 |
| Mitigations | Full RELRO, canary, NX, PIE |
| Sandbox | seccomp (installed at startup) |
| Runtime | qemu-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:
| Symbol | Address | Role |
|---|---|---|
g_ops | PIE + 0x20020 | operations table |
g_channels | PIE + 0x20028 | the 2 channel slots |
g_auth | PIE + 0x200a0 | clearance state (the target) |
render_status | PIE + 0x17a0 | status printer (the leak) |
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.
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.
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.
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:
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.
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.
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.
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:
tcache_get() performs only an
alignment check on the returned pointer — no size/next-chunk sanity. So the destination
doesn’t need a forged chunk header; it only needs to be 16-byte aligned.0xc0 is 16-byte aligned, so g_auth + 0x20 passes the alignment check and
the allocator accepts it.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.
Each primitive feeds the next, which is what makes this a satisfying chain rather than three unrelated tricks:
last_draft (heap base) and diagnostic_hook (→ PIE base via
−0x17a0).target = PIE_base + g_auth_off + 0x20 and
poisoned_fd = (chunk_addr >> 12) ^ target.poisoned_fd into the freed chunk’s
fd.g_auth + 0x20. Write
0x7_434F_4D44.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.
draft_size < (span & 0xff) is checked, but the full span is used for the read,
so a heap overflow is possible). It is a tempting rabbit hole — but the auth target is in
BSS, so overflowing a heap neighbour gets you nowhere. The UAF→poison path is the intended one.fd fails; you must XOR with chunk_addr >> 12 first. Easy to miss if
you learned tcache poisoning on an older libc.tcache_get() checks 16-byte
alignment of the returned pointer. Pick a target address that is actually aligned
(g_auth + 0x20 is); an unaligned BSS address would be rejected.0x7_434F_4D44 exactly from the
binary — a single wrong nibble silently fails the clearance check with no error.draft_ptr != NULL instead of the open flag. Audit every path that
touches a freed object’s fields, not just the free itself.
render_status printed both a heap and a PIE address precisely because the object was
closed-but-non-NULL. Treat info/status views as oracles.poisoned_fd = (chunk_addr >> 12) ^ target. tcache has no header validation beyond
alignment, so a 16-byte-aligned BSS address is a perfectly valid allocation destination.Tools: Ghidra headless (decompile main, render_status, install_seccomp), radare2 (struct-offset verification), pwntools (exploit scripting over SSL), qemu-aarch64-static (local run).