# exit_func: a general guide

7 min read
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#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;
};

Because this struct can be a little bit confusing, here are the function flavors that run_exit_handlers accepts as a nice table:

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).

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#L55
struct 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#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);
}

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 &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

Because 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_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}

To forge our own pointer, we just run it in reverse:

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 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> 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), 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:

  1. Leak libc to find initial. You may also need a loader leak to find the pointer guard if you can’t leak it directly.
  2. Recover the mangling key and use it to mangle the system() address.
  3. Overwrite an existing ef_cxa exit function (like _dl_fini) or forge a new one with the mangled system pointer and a pointer to /bin/sh as the argument.
  4. Exit.

And you get a shell.

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