House of Kiwi

House of Kiwi - article cover image RE & Pwn
Time it takes to read this article 9 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 Kiwi is a control-flow-hijack technique that does not touch a single allocator hook. Instead of overwriting __malloc_hook or __free_hook — the classic finishes that glibc 2.34 deleted — it reaches code execution through glibc’s *error-handling* machinery: it deliberately triggers a fatal allocator assertion, and rides the abort() path into stdio’s flush routine, _IO_flush_all_lockp, where a corrupted FILE structure turns a routine vtable call into a jump to attacker-controlled code. In other words, it converts a heap write into RIP control by making the allocator kill the process *through* an I/O path the attacker has already booby-trapped.

The technique was popularised around 2021 and targets roughly glibc 2.29–2.33 — the window in which the hooks were on their way out but the __malloc_assert__libc_messageabort → stdio-flush chain still led cleanly into a hijackable I/O vtable call. It is best understood as a *finish*: it assumes you already possess an arbitrary (or at least targeted libc-relative) write and a libc leak, and it answers the question “where do I point that write now that the hooks are gone?” Because the trigger is a malloc assertion rather than a normal allocation, House of Kiwi is also a useful reminder that even glibc’s *failure* paths are attacker-reachable surface.

Attack Prerequisites

House of Kiwi is not a bug in itself; it is a way to cash out writes you already have. It expects a fairly specific but common mid-exploit state:

  • A libc leak — every target address (the top chunk, _IO_list_all, the I/O jump tables) is libc- or heap-relative, so you need the base first.
  • Two controlled writes (or one write used twice): one to corrupt the top chunk size, and one to corrupt the I/O function-pointer table / a FILE structure on the flush path.
  • The ability to force a large allocation after corrupting the top chunk — this is what drives _int_malloc into sysmalloc and detonates the assertion.
  • A glibc in the reachable window — approximately 2.29–2.33, where the assertion routes through __libc_message/abort into _IO_flush_all_lockp; the exact reachable slot is version-dependent.
  • A pivot gadget for the final call — commonly a setcontext-style gadget, because at the hijacked call site a register points at a structure the attacker controls, enabling a full register-load pivot into a ROP chain or one_gadget.

How It Works

The trigger side abuses an invariant that sysmalloc asserts. When a request cannot be served from any bin and the top chunk is too small, _int_malloc calls sysmalloc to extend the heap. Before doing so, sysmalloc asserts a consistency property on the old top chunk — in essence that its size is page-aligned and its prev_inuse bit is set relative to the previous heap state. If you have corrupted the top chunk’s size field to a value that violates that relationship, the assertion fails. A failed glibc assertion does not return: it calls the internal reporting path (__malloc_assert / malloc_printerr__libc_message), which prints a diagnostic and then calls abort(). The attacker has thus converted “allocate something large” into “glibc is now aborting.”

The abort() and message path does not exit silently — it flushes stdio so buffered output is not lost, and that flush is the pivot. abort/the message machinery reaches _IO_flush_all_lockp, which walks the global linked list of open streams anchored at _IO_list_all and, for each stream fp, performs a virtual call: effectively _IO_OVERFLOW(fp, EOF), which dereferences the stream’s vtable and calls its overflow slot. That is a function pointer read out of memory, invoked with fp as its first argument. If the attacker controls either the vtable pointer of a stream on that list or the function-pointer table entry that the flush path resolves, the flush becomes a controlled indirect call.

The reason House of Kiwi does not simply swap in a whole fake vtable is _IO_vtable_check: since glibc 2.24, indirect I/O vtable calls validate that the vtable pointer lies within the legitimate __libc_IO_vtables section, so a naive vtable pointing at the heap is rejected. Kiwi’s answer is to target the *function-pointer table region already inside that validated area* — the file/helper jumps tables (_IO_file_jumps / _IO_helper_jumps) that the abort-flush path resolves through — or to corrupt a FILE object that is already on _IO_list_all so the validated vtable is used but the data it operates on is attacker-shaped. Crucially, at the hijacked call site a register (classically RDX) points at a structure the attacker populated, so instead of needing a single magic function you can aim the call at a setcontext-style gadget that loads a full register set from that structure and pivots into a ROP chain — the standard way House of Kiwi reaches a shell without any hook.

Vulnerable Code / Setup

The enabling bug is an ordinary heap corruption that yields a write over the top chunk and over a libc target. A minimal menu allocator with an overflow on edit and a libc leak already in hand is representative; the point is that the top-chunk size and an I/O table are both writable through the same primitive:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Representative target: heap overflow lets us reach the top chunk's
   size field and, via a leaked libc, an arbitrary libc write. */
static char *chunks[32];

void edit(int i, size_t n, char *src) {
    /* BUG: length is caller-controlled and unchecked against the real
       allocation, so a write can run off the end of chunks[i] and
       overwrite the header (size) of the following chunk / the top. */
    memcpy(chunks[i], src, n);
}

void request(size_t n) {
    /* A large n after the top size is corrupted forces _int_malloc into
       sysmalloc, where the assertion on the old top chunk fires. */
    chunks[0] = malloc(n);
}
C

There is no invented offset here: the exploit expresses the top chunk, _IO_list_all, and the jump tables as symbols resolved from the exact libc. Fingerprint mitigations and the libc version first, because the reachable assertion path and the precise slot invoked on flush differ across the 2.29–2.33 range:

$ checksec --file=./heapmenu
RELRO      STACK CANARY   NX         PIE
Full RELRO Canary found   NX enabled PIE enabled

$ ldd ./heapmenu | grep libc
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
$ /lib/x86_64-linux-gnu/libc.so.6 | head -1   # confirm the version window
GNU C Library (Ubuntu GLIBC 2.31) stable release version 2.31.
Bash

Walkthrough / Exploitation

The exploit has two corruptions and one trigger. First corrupt the top chunk size so the assertion will fire; second corrupt the flush-path target so the abort’s I/O flush calls your pivot; then force a large allocation to detonate. Keep every address symbolic against the resolved libc — never hardcode offsets:

from pwn import *

libc = ELF('./libc.so.6', checksec=False)
io = process('./heapmenu')

def edit(i, data):    ...     # menu wrappers over the vuln program
def alloc(n):         ...
def leak_libc():      ...     # assume an existing info leak

libc.address = leak_libc() - libc.sym['_IO_2_1_stdout_']
log.success('libc base: %#x' % libc.address)

# Symbols we target (offsets resolved from THIS libc, not fabricated):
list_all   = libc.sym['_IO_list_all']
file_jumps = libc.sym['_IO_file_jumps']
setcontext = libc.sym['setcontext']      # +offset for the load-from-RDX form

# 1) Corrupt the TOP CHUNK size to violate the sysmalloc assertion.
#    (write reaches the top chunk header via the overflow primitive)
corrupt_top_size(0x0)          # any value breaking the page-align/prev_inuse invariant

# 2) Corrupt the flush-path target so _IO_flush_all_lockp's vtable call
#    lands on our pivot. Point RDX at a fake context we control, and aim
#    the resolved jump-table slot at the setcontext gadget.
build_fake_io_and_context(list_all, file_jumps, setcontext)
Python

Confirm the trigger in a debugger before trusting it end to end. The two checkpoints are: the assertion actually fires inside sysmalloc (not a different malloc_printerr further up), and the flush walk reaches your corrupted stream/table rather than a benign one:

gdb -q ./heapmenu
pwndbg> break sysmalloc
pwndbg> run
pwndbg> # after corrupting the top size and requesting a large chunk:
pwndbg> p main_arena.top          # inspect the (corrupted) top chunk
pwndbg> x/gx &_IO_list_all         # head of the stream list we walk on abort
pwndbg> break _IO_flush_all_lockp  # the flush routine reached via abort()
pwndbg> continue
pwndbg> # single-step the per-stream vtable call and confirm the slot
pwndbg> x/3i $pc                   # watch the _IO_OVERFLOW indirect call
pwndbg> p/x $rdx                    # RDX should point at our fake context
Bash

When the large malloc runs, sysmalloc‘s assertion fails, glibc calls __libc_message and then abort(), the abort path flushes streams via _IO_flush_all_lockp, and the per-stream overflow call transfers control to the setcontext pivot with RDX pointing at the fake context you staged. setcontext then loads RSP/RIP and the general registers from that structure, dropping you into a ROP chain (or straight into a one_gadget if its constraints are met). No hook was ever touched:

# 3) Detonate: force the large allocation that fails the assertion.
alloc(0x1000)      # _int_malloc -> sysmalloc -> assert -> abort -> flush -> pivot

# The fake context loads RSP into our ROP chain (dup/execve or one_gadget).
io.interactive()
Python

Note: The most common failure is that the assertion you fire is not the one you expected. Corrupting the top chunk can trip several different malloc_printerr/__malloc_assert sites depending on how it is corrupted and the request size, and not all of them lead to the same flush behaviour. Break on sysmalloc and on __libc_message and read the exact diagnostic string glibc is about to print — it names the check that failed and tells you whether you are on the intended path before you ever reach _IO_flush_all_lockp.

Opsec: House of Kiwi is inherently noisy: it *crashes* the process by design, so on a monitored target you will emit the glibc assertion/abort diagnostic (“malloc(): …” / “Assertion … failed”) to stderr and leave a SIGABRT core-dump trail — very visible to logging and crash reporters. The reachable flush slot is also tightly version-bound (~2.29–2.33); on a libc outside that window the abort path may not route through a hijackable I/O call, so pin the version before committing to this finish rather than an FSOP alternative.

Detection and Defense

Because Kiwi is a finishing move layered on a heap write, defenses split between denying the initial corruption and hardening the I/O path it abuses:

  • Prevent the top-chunk overwrite — bound every copy against the real allocation size; the technique cannot start without a write that reaches the top chunk’s size field.
  • Keep glibc’s I/O vtable hardening_IO_vtable_check (since 2.24) is exactly what forces Kiwi to target the validated jump tables rather than a fake heap vtable; do not disable it or run pre-2.24 libc.
  • Modern glibc — releases after the 2.29–2.33 window continue to adjust the assertion and abort/flush paths; combined with the hook removal in 2.34, the clean reachable slot Kiwi relies on is not guaranteed on newer builds.
  • Monitor for SIGABRT and glibc diagnostics — a burst of malloc()/assertion abort messages and core dumps is a strong signal of heap-corruption exploitation attempts, including this technique.
  • AddressSanitizer / hardened allocators — ASan flags the overflow into chunk metadata during testing, and out-of-line-metadata allocators (hardened_malloc, scudo) deny the in-band top-chunk corruption the trigger depends on.

Real-World Impact

House of Kiwi is primarily a CTF-era and heap-research technique rather than a specific CVE, and its significance is doctrinal: it was one of the first widely-taught answers to “what do you overwrite now that the hooks are gone,” demonstrating that glibc’s *assertion and abort* paths are exploitable surface, not just its allocation fast paths. It sits alongside FSOP/_IO_list_all hijacking and the printf-table finish (House of Husk) in the modern post-2.34 toolkit, and the bug classes it monetises — heap overflow and use-after-free (CWE-122/CWE-416) — remain the dominant source of memory-corruption exploitation seen in engagements and competitions.

Conclusion

House of Kiwi weaponises glibc’s own failure handling: corrupt the top chunk so a large allocation fails sysmalloc‘s assertion, let the __libc_message/abort path flush stdio through _IO_flush_all_lockp, and arrange for that flush’s per-stream vtable call to land on a setcontext pivot with a register aimed at a fake context you control. It respects _IO_vtable_check by targeting the validated jump tables rather than a fake vtable, which is exactly why it worked across the 2.29–2.33 window without a single hook. The lesson is broader than the trick: an allocator whose *abort* path performs indirect calls through mutable I/O state has made even crashing an exploitable event.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL