Table of Contents
If you’ve played CTFs for a while, you’ve probably heard of the exit_function overwrite technique. It’s a classic primitive for turning a single arbitrary write (and some reads) into code execution when a program shuts down. This post breaks down how the technique actually works under the hood and how to use it. Let’s start from the basics:
What is an exit function?
These are functions registered to run automatically during normal process termination (exit(), return from main, ecc). The function pointers live in a global glibc structure called __exit_funcs (or initial).
It is possible to register them in a few ways. The most common are atexit() and on_exit(). A third one, __cxa_atexit, is mostly used internally by glibc and the C++ compiler to handle object destructors.
By default, glibc also registers _dl_fini as an exit function to clean up after the dynamic loader. That becomes important later.
Let’s look at a practical example on how these functions could be used:
//example snippet#include <stdio.h>#define _DEFAULT_SOURCE#include <stdlib.h>
void cleanup1(void) { puts("cleanup1"); }void cleanup2(int status, void *value) { printf("cleanup2 status: %d value: %d\n", status, *(int *)value); }
int value = 5;
int main(void) { atexit(cleanup1); // no arguments given on_exit(cleanup2, &value); // takes the exit status as an argument
printf("Exiting...\n"); return 0; // triggers cleanup2 then cleanup1}So how do the registrars exactly work? These functions take our cleanup routine pointer and create an exit_function struct in libc memory. The definition below shows that this struct not only wraps the function call but also stores an optional argument, and includes a flavor field to handle different argument combinations.
//glibc https://elixir.bootlin.com/glibc/glibc-2.43.9000/source/stdlib/exit.h#L34struct exit_function{ long int flavor; union { void (*at) (void); struct { void (*fn) (int status, void *arg); void *arg; } on; struct { void (*fn) (void *arg, int status); void *arg; void *dso_handle; } cxa; } func;};Because this struct can be a little bit confusing, here are the function flavors that run_exit_handlers accepts as a nice table:
| Flavor Value | Macro Name | Execution Behavior |
|---|---|---|
0 | ef_free | Slot is dead/empty. ignored. |
1 | ef_us | Used internally; effectively does nothing here. |
2 | ef_on | Calls fn(status, arg). |
3 | ef_at | Calls fn() with no arguments. |
4 | ef_cxa | Calls fn(arg, status). |
Every time a function is registered, its struct gets appended to a node of a linked list called exit_function_list. Each node holds up to 32 functions. Most C programs only need one node, but the list can grow if needed.
//glibc https://elixir.bootlin.com/glibc/glibc-2.43.9000/source/stdlib/exit.h#L55struct exit_function_list{ struct exit_function_list *next; size_t idx; struct exit_function fns[32];};How are exit_functions called?
Great, we know how these functions get stored, but how are they executed? They trigger when code explicitly calls exit(), this can happen from a function call or, when main() finishes, it returns control to __libc_start_call_main, which passes that return value again straight into exit().
//glibc (https://elixir.bootlin.com/glibc/glibc-2.43.9000/source/sysdeps/generic/libc_start_call_main.h#L23)_Noreturn static __always_inline void__libc_start_call_main (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL), int argc, char **argv MAIN_AUXVEC_DECL){ exit (main (argc, argv, __environ MAIN_AUXVEC_PARAM));}exit() itself is basically just a wrapper around __run_exit_handlers().
//glibc (https://elixir.bootlin.com/glibc/glibc-2.43.9000/source/stdlib/exit.c#L146)void exit (int status){ __run_exit_handlers (status, &__exit_funcs, true, true);}Now it becomes interesting: the code below iterates through the linked list of exit_function_list structs in the outer loop, and steps through the 32 possible functions per node in the inner loop.
Notice that --cur->idx decrements the index before fetching the function. That guarantees exit functions run in Last-In, First-Out (LIFO) order.
// glibc https://elixir.bootlin.com/glibc/glibc-2.43.9000/source/stdlib/exit.c#L41void __run_exit_handlers (int status, struct exit_function_list **listp, bool run_list_atexit, bool run_dtors){ ... i removed a lot of code to make it readable, not only here ...
while (true) { struct exit_function_list *cur; cur = *listp;
if (cur == NULL) break;
//loop acting on a single function_list entry while (cur->idx > 0) { struct exit_function *const f = &cur->fns[--cur->idx]; const uint64_t new_exitfn_called = __new_exitfn_called;
switch (f->flavor) { case ef_free: case ef_us: break; case ef_on: onfct = f->func.on.fn; arg = f->func.on.arg; PTR_DEMANGLE (onfct); //<-- huh what is this? onfct (status, arg); break; case ef_at: atfct = f->func.at; PTR_DEMANGLE (atfct); //<-- demangle? atfct (); break; case ef_cxa: f->flavor = ef_free; cxafct = f->func.cxa.fn; arg = f->func.cxa.arg; PTR_DEMANGLE (cxafct); //<-- not good... cxafct (arg, status); break; } } *listp = cur->next; } _exit (status);}After this loop executed all cleanup functions that got registered, _exit(status); is getting called. Still there is a problem…
Pointer mangling
Let’s look at exit_function_list dynamically in pwndbg.
pwndbg> tele &initial00:0000│ 0x7ffff7e14fa0 (initial) ◂— 0 <-- next exit_fun list01:0008│ 0x7ffff7e14fa8 (initial+8) ◂— 1 <-- num of entries02:0010│ 0x7ffff7e14fb0 (initial+16) ◂— 4 <-- type of first entry03:0018│ 0x7ffff7e14fb8 (initial+24) ◂— 0x97ef82a4eb1caab6 <-- mangled ptr04:0020│ 0x7ffff7e14fc0 (initial+32) ◂— 0 <-- argument of first entryBecause this list has only one entry, the next pointer is NULL and idx is 1, also the entry is type 4 (ef_cxa); So far so good, but the function pointer itself looks like garbage, this happens because it is mangled!
You can see how this mangling works direclty in the disassembly:
pwndbg> disas __run_exit_handlersDump of assembler code for function __run_exit_handlers:0x00007ffff7c40f10 <+0>: endbr640x00007ffff7c40f14 <+4>: push rbp0x00007ffff7c40f15 <+5>: mov rbp,rsp
... code ...
0x00007ffff7c40fea <+218>: ror rax,0x110x00007ffff7c40fee <+222>: xor rax,QWORD PTR fs:0x30
... other code ...
0x00007ffff7c41005 <+245>: call raxThis sequence reveals that the PTR_DEMANGLE operation follows a specific mathematical formula:
To forge our own pointer, we just run it in reverse:
So, what is the key? It is called the pointer_guard, and it is an element of the Thread Control Block (TCB) stored inside the Thread Local Storage (TLS). The TLS is a per-thread memory region whose base address is saved in a special segment register (fs).
In most single-threaded CTF challenges, TLS sits at a predictable offset from the loader. However, if the target binary spawns additional threads, the TLS for those new threads is mmap’d alongside their execution stacks in a completely different memory region. At that point, unless you have a highly specific memory leak, finding the pointer_guard becomes notoriously difficult.
pwndbg> tlsThread Local Storage (TLS) base: 0x7ffff7f78740TLS is located at: 0x7ffff7f78000 0x7ffff7f7d000 rw-p 5000 0 [anon_7ffff7f78]Dumping the address:tcbhead_t @ 0x7ffff7f78740 ... stuff ... 0x00007ffff7f78768 +0x0028 stack_guard : 0x5538dfb9143ee300 0x00007ffff7f78770 +0x0030 pointer_guard : 0x555b340836ae1b2e ... other stuff ...You see at offset 0x28 our stack canary (stack_guard), ending in its usual null byte. The value stored directly after it at 0x30 is our key (pointer_guard).
Putting it all together
With the internals sorted out, pulling off the exploit usually requires this chain:
- Leak libc to find
initial. You may also need a loader leak to find the pointer guard if you can’t leak it directly. - Recover the mangling key and use it to mangle the
system()address. - Overwrite an existing
ef_cxaexit function (like_dl_fini) or forge a new one with the mangledsystempointer and a pointer to/bin/shas the argument. - Exit.
And you get a shell.
Writeups involving exit_functions
- RepusPing: MntcrlCTF 2026 (easy): https://blog.davidherm.es/posts/repusping
- Babyheap: JustCTF - Chapter 2 (after a big heap chain): https://blog.davidherm.es/post/babyheap_2