House of Husk

House of Husk - article cover image RE & Pwn
Time it takes to read this article 10 minutes.

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_table and __printf_arginfo_table live in libc’s writable data and their addresses are ASLR-affected. In pwntools these are libc.sym['__printf_function_table'] and libc.sym['__printf_arginfo_table'].
  • Controlled memory to host the fake arginfo table — a heap chunk, a .bss buffer, 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 steerprintf, 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 clean system("/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;
}
C

Note 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
Bash

Walkthrough / 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
Bash

The 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()
Python

The 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_table points at your fake table but __printf_function_table is still NULL, the parser never takes the custom path and your pointer is never called. Conversely, do not aim the arginfo entry at system and expect a shell: the callback is entered with parser-controlled registers, so rdi is not "/bin/sh". Use a one_gadget whose register constraints you have verified against the actual call site, or land in a setcontext/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_buffer rework in modern glibc), so validate in a debugger that YOUR libc still calls through __printf_arginfo_table on the specifier you intend to trigger. Confirm the two symbols with nm -D libc.so.6 | grep printf_.*table before 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

Copied title and URL