# exit_func: a general guide

8 min read
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#L34
struct 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 ValueMacro NameExecution Behavior
0ef_freeSlot is dead/empty. ignored.
1ef_usUsed internally; effectively does nothing here.
2ef_onCalls fn(status, arg).
3ef_atCalls fn() with no arguments.
4ef_cxaCalls 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#L55
struct 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#L41
void __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 &initial
00:00000x7ffff7e14fa0 (initial) ◂— 0 <-- next exit_fun list
01:00080x7ffff7e14fa8 (initial+8) ◂— 1 <-- num of entries
02:00100x7ffff7e14fb0 (initial+16) ◂— 4 <-- type of first entry
03:00180x7ffff7e14fb8 (initial+24) ◂— 0x97ef82a4eb1caab6 <-- mangled ptr
04:00200x7ffff7e14fc0 (initial+32) ◂— 0 <-- argument of first entry

Here 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_handlers
Dump of assembler code for function __run_exit_handlers:
0x00007ffff7c40f10 <+0>: endbr64
0x00007ffff7c40f14 <+4>: push rbp
0x00007ffff7c40f15 <+5>: mov rbp,rsp
... code ...
0x00007ffff7c40fea <+218>: ror rax,0x11
0x00007ffff7c40fee <+222>: xor rax,QWORD PTR fs:0x30
... other code ...
0x00007ffff7c41005 <+245>: call rax

This sequence reveals that the PTR_DEMANGLE operation follows a specific mathematical formula:

address=ror(mangled_address,0x11)  key\text{address} = \texttt{ror}(\text{mangled\_address}, \text{0x11})\ \oplus\ \text{key}

Consequently, to forge our own pointer, we must apply the inverse mangling operation:

mangled_address=rol(address key,0x11)\text{mangled\_address} = \texttt{rol}(\text{address} \oplus\ \text{key} , \text{0x11})

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> tls
Thread Local Storage (TLS) base: 0x7ffff7f78740
TLS 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:

  1. 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.
  2. Then we can recover the mangling key with the formulas shown above and use it to mangle our system() address.
  3. Finally we overwrite an ef_cxa exit_function (or fake a new one) with our mangled system pointer, and add a pointer to /bin/sh as the argument.
  4. Exit

And boom goes the dynamite.

Writeups involving exit_functions

My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts

Comments