Table of Contents
What is an exit function?
It is a routine registered to execute automatically during normal process termination. These function pointers are stored in a global structure within glibc known as __exit_funcs (or initial).
There are different ways to register an exit function. The most straightforward ones are atexit() and on_exit(). There is a third one (__cxa_atexit), which is primarily used internally by glibc and the C++ compiler to handle object destructors.
Speaking of, glibc also registers _dl_fini as an exit function to clean up after the dynamic loader, this becomes important later.
//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}These functions take the pointer to our cleanup routines and create an exit_function struct that gets saved in libc. Looking at the definition below, we can see that this struct not only wraps the function call itself but also saves the argument if needed and a flavor field. This indicates that there are multiple types of exit functions that take different combinations of arguments as we have seen above.
//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;};These are all the function flavors accepted by the run_exit_handlers function we will see later.
| 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). |
These structs get stored in a linked list called exit_function_list, where every entry can hold 32 functions. This means that in a standard C program, typically only one list node is allocated. However, because it is a linked list, it can expand to hold a virtually infinite number of exit functions.
//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?
Most obviously, they are triggered when exit() is explicitly called in the code. However, this also happens implicitly during a normal return. When main() finishes, it returns control to its caller, __libc_start_call_main, which passes the return value 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));}Looking at the exit() function itself, we notice that it is essentially 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);}Knowing how exit functions are represented in memory makes the following code much easier to understand. The external while loop iterates through the linked list of exit_function_list structs, while the internal while loop iterates through the 32 possible exit functions inside each node.
Notice the --cur->idx instruction: the index is decremented before the function is fetched. This guarantees that exit functions are always executed 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);}Now we have an exceptional grasp on how exit functions work internally! There is only one last puzzle piece to solve.
Pointer mangling
Let’s do something different and look at the exit_function_list dynamically. This can be done in pwndbg using tele &initial (or by doing p initial, though the latter is more verbose).
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 entryHere we can see exactly what we were speaking about before. Because this list has only one entry, the next pointer is NULL and the idx is 1. The entry is specifically of type 4 (ef_cxa), yet the value that should be the function pointer is completely unrecognizable. It has been mangled.
The best way to understand how this mangling occurs is to look at the disassembly directly in GDB:
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:
Consequently, to forge our own pointer, we must apply the inverse mangling operation:
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 CTF challenges, you operate on the main thread, where the TLS is mapped at a predictable offset relative to the loader and libc. 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), identifiable by the mandatory null byte at the end. The value stored directly after it at 0x30 is our key (pointer_guard).
Putting it all together
Now that we understand the internal machinery of exit functions, the path to getting a shell becomes clear. To pull off this exploit, an attacker must execute the following chain:
- We require a leak to libc, this will help us to locate
initial, additionally we could need a leak of the loader if no other exit_functions are registered. - Then we can recover the mangling key with the formulas shown above and use it to mangle our
system()address. - Finally we overwrite an
ef_cxaexit_function (or fake a new one) with our mangled system pointer, and add a pointer to/bin/shas the argument. - Exit
And boom goes the dynamite.
Writeups involving exit_functions
- Babyheap: JustCTF - Chapter 2: https://blog.davidherm.es/post/babyheap_2
- RepusPing: MntcrlCTF 2026: https://blog.davidherm.es/posts/repusping