algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

PTR_MANGLE and hijacking the exit handlers

2026-06-073 min readpwn glibc internals

The stack canary protects return addresses. Its less-famous sibling, pointer guard, protects a handful of stored function pointers that an attacker with a write would otherwise love: atexit handlers, TLS destructors, the rip/rsp saved in a jmp_buf. When exit() walks those handlers, it demangles each pointer first — so overwriting one with &system produces garbage, not a call. Unless you understand the mangle.

targetglibc — PTR_MANGLE / __run_exit_handlers / __call_tls_dtors
arch
amd64-64-little
guard
__pointer_chk_guard at fs:0x30 (sibling of the canary at fs:0x28)
era
the post-2.34 world, where the malloc hooks are gone

the mangle#

A guarded pointer isn't stored raw. It's XOR'd with a per-process secret and rotated:

mangled=rol(ptrguard, 17)

and read back with the inverse, ptr = ror(mangled, 17) ⊕ guard. On x86-64 it's two instructions inlined at every store/use:

PTR_MANGLE / PTR_DEMANGLEasm
1
2
3
4
5
6
7
; PTR_MANGLE rax:
xor  rax, qword ptr fs:[0x30]    ; mix in the secret guard
rol  rax, 0x11                   ; rotate left 17

; PTR_DEMANGLE rax (the inverse, on the way to calling it):
ror  rax, 0x11
xor  rax, qword ptr fs:[0x30]

__pointer_chk_guard is seeded at startup from the kernel's AT_RANDOM — the same source as the canary — and parked in the TLS at fs:0x30, one slot past the canary's fs:0x28. Per-process, constant for the run, inherited across fork.

what it guards, and how exit reaches it#

Two structures matter, because both are walked on a normal exit():

  • __exit_funcs — the atexit/__cxa_atexit list. __run_exit_handlers demangles each exit_function's pointer and calls it.
  • tls_dtor_list — thread-local destructors. __call_tls_dtors does, per entry:
__call_tls_dtors (paraphrased)c
1
2
3
4
5
6
while (tls_dtor_list) {
    struct dtor_list *cur = tls_dtor_list;
    dtor_func func = PTR_DEMANGLE(cur->func);   // ror 17 ; xor fs:0x30
    tls_dtor_list = cur->next;
    func(cur->obj);                              // <- our target call, obj in rdi
}

So if you can write a fake dtor_list (or corrupt an existing entry), the demangled func is called with obj in rdi. Point func at system and obj at "/bin/sh" and exit() spawns a shell — if you can produce a correctly mangled func. That's the whole problem, and there are two solutions.

move 1 — leak the guard#

The guard isn't a moving target; it's one value. Leak __pointer_chk_guard from the TLS directly, or recover it from any already-mangled pointer whose plaintext you know (a default libc handler, say): since ptr = ror(mangled,17) ⊕ guard,

recover the guard from a known mangled pointerpython
guard = ror(leaked_mangled, 17) ^ known_plaintext_ptr
mangled_system = rol(libc.sym["system"] ^ guard, 17)   # forge any pointer now

With the guard in hand you can mangle any target, and the demangle on the exit path turns it straight back into your call.

move 2 — zero the guard#

If you have an arbitrary write but no leak, don't fight the secret — delete it. Overwrite the TLS slot at fs:0x30 with 0. Now ptr ⊕ 0 = ptr, so mangling collapses to a plain rotate you can compute with no secret at all:

guard = 0 -> mangling is just a rotatepython
write(tls_base + 0x30, p64(0))            # neutralise the guard
mangled_system = rol(libc.sym["system"], 17)   # no leak needed

This is the exact analogue of overwriting the canary's master copy — once the secret is zero, the protection is a fixed, public transform.

exit() ran my handler

A forged tls_dtor_list entry, a mangled system, obj = "/bin/sh", and the program's own clean-up did the call:

txt
segfault{exit_handlers_are_function_pointers_too}

where this sits in the modern playbook#

With __free_hook/__malloc_hook gone since 2.34, the targets that survive are the ones glibc still calls on the way out — exit handlers, TLS destructors — and those are guarded by PTR_MANGLE precisely to make this attack harder. It raises the bar to "leak or zero one TLS qword," the same bar the canary sets. Know where the guard lives and how the rotate works, and a mangled pointer stops being a wall: it's a fixed function of a single secret you can read or erase.