Источник: https://github.com/AnaktaCTF/CTF/blob/main — PWN/UAF.md

Введение

Use-After-Free представляет собой одну из наиболее интересных и в то же время опасных уязвимостей, связанных с эксплуатацией кучи. Для её понимания и успешного использования необходимо глубокое знание механики работы аллокатора памяти, структуры чанков и особенностей поведения аллокаций при различных сценариях. Несмотря на техническую сложность, такие уязвимости открывают широкие возможности для нестандартных атак и оригинальных способов захвата управления программой.

Инструменты вроде GDB и расширения pwndbg позволяют детально анализировать поведение программы на этапе выполнения, отслеживать движения по куче, находить утечки и точно определять, где именно происходит ошибка.

Что такое UAF?

Use-After-Free (UAF) — это уязвимость, связанная с ошибками управления динамически выделенной памятью. Она возникает в тех случаях, когда программа сначала корректно выделяет блок памяти, затем освобождает его, но по каким-то причинам продолжает использовать указатель на уже освобождённую область. Такая ошибка характерна прежде всего для языков низкого уровня, таких как C и C++, где управление памятью полностью возложено на программиста и не сопровождается встроенной защитой со стороны среды выполнения.

После освобождения памяти с помощью функций вроде free() в C или оператора delete в C++, сама память не обязательно немедленно очищается или перезаписывается. Более того, указатель на освобождённый блок остаётся валидным с точки зрения синтаксиса программы, что и делает уязвимость трудноуловимой без специальных инструментов или защиты на уровне компилятора и рантайма. На практике это означает, что программа может продолжать считывать или записывать данные в область памяти, которая уже может быть возвращена аллокатором и заново использована для хранения других объектов. В такой ситуации поведение становится неопределённым: от простого сбоя исполнения до серьёзных нарушений безопасности.

В некоторых случаях продолжение работы с освобождённой памятью приводит к так называемым "dangling pointers" — висячим указателям, которые ссылаются на устаревшие, уже перераспределённые участки памяти. Эти участки могут быть в дальнейшем перезаписаны новым содержимым, и если программа продолжит использовать старый указатель, она либо столкнётся с повреждением данных, либо получит доступ к чужим структурам, что может быть использовано злоумышленником в целях атаки.

Опасность UAF заключается в том, что она даёт возможность контролировать поведение программы через вмешательство в структуру кучи. Например, если атакующий сумеет предсказать или повлиять на то, какие данные будут размещены в ранее освобождённой области, он сможет подменить содержимое памяти, а при определённых условиях — даже вызвать выполнение произвольного кода. Такие атаки нередко приводят к компрометации всей системы, особенно если UAF эксплуатируется в программах с повышенными привилегиями или в ядре операционной системы.

На практике UAF может проявляться по-разному: от тривиального обращения к уже несуществующей строке до сложных сценариев переписывания таблиц виртуальных функций (vtable), хуков и других чувствительных структур в памяти. В контексте реальной эксплуатации особенно интересны случаи, когда UAF даёт возможность обойти защитные механизмы вроде ASLR (Address Space Layout Randomization), tcache, или даже использовать его в комбинации с другими уязвимостями, такими как heap overflow или double free, создавая целые цепочки эксплуатации.

Таким образом, понимание механики UAF — это не просто знание о том, что «нельзя использовать указатель после free()». Это целый пласт знаний о том, как работает менеджер памяти в операционных системах, как устроены аллокаторы, как защита памяти может быть обойдена и каким образом программное поведение может быть детерминировано и направлено атакующим.

Пример Use-After-Free

Рассмотрим простой пример программы на C с уязвимостью типа Use-After-Free. В этом примере можно наблюдать, как повторное использование освобождённой памяти без зануления указателя приводит к неожиданному поведению и потенциальной ошибке безопасности.

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

char *notes[8];

void add_note() {
    int idx;
    for (idx = 0; idx < 8 && notes[idx]; idx++);
    if (idx == 8) {
        puts("Full");
        return;
    }

    notes[idx] = malloc(0x80);
    printf("Data: ");
    ssize_t len = read(0, notes[idx], 0x7f);
    if (len > 0) notes[idx][len - 1] = '\0';
}

void delete_note() {
    int idx;
    printf("Index: ");
    scanf("%d", &idx);
    if (idx < 0 || idx >= 8 || !notes[idx]) {
        puts("Invalid");
        return;
    }
    free(notes[idx]);
    puts("Freed!");
}

void show_note() {
    int idx;
    printf("Index: ");
    scanf("%d", &idx);
    if (idx < 0 || idx >= 8 || !notes[idx]) {
        puts("Invalid");
        return;
    }
    printf("Data: %s\n", notes[idx]);
}

int main() {
    setbuf(stdout, NULL);
    int choice;
    while (1) {
        puts("1. Add");
        puts("2. Delete");
        puts("3. Show");
        puts("4. Exit");
        scanf("%d", &choice);
        getchar();
        switch (choice) {
            case 1: add_note(); break;
            case 2: delete_note(); break;
            case 3: show_note(); break;
            case 4: exit(0);
            default: puts("Invalid"); break;
        }
    }
}

Для компиляции без защитных механизмов используем следующие флаги:

gcc -g -o uaf uaf.c -no-pie -fno-stack-protector
  • -g — включение отладочной информации для GDB;
  • -no-pie — отключение позиционно-независимого исполнения (чтобы адреса в памяти были фиксированы);
  • -fno-stack-protector — отключение защиты стека от переполнения.

Особенности реализации и поведение во время выполнения

В представленном коде указатель, полученный при вызове функции malloc(), сохраняется в массив notes[], где каждая ячейка представляет собой независимую запись. Когда пользователь выбирает опцию удаления записи, вызывается функция free(), освобождающая соответствующую область памяти. Однако после этого сам указатель в массиве остаётся без изменений, он продолжает указывать на уже недействительный участок памяти. Такая логика становится источником потенциально опасного поведения.

После освобождения памяти программа по-прежнему позволяет обратиться к тому же индексу в массиве с помощью функции show_note(), которая выводит содержимое по указанному указателю. С технической точки зрения, обращение к освобождённой памяти не вызывает немедленного сбоя — она по-прежнему может содержать старые данные, особенно если с момента освобождения в этот участок ещё не была записана новая информация. Однако это уже нарушает корректную работу с динамической памятью, поскольку фактически программа читает из области, которой она больше не владеет.

Особенно интересным является поведение, при котором в дальнейшем происходит новое выделение памяти такого же размера, как и ранее освобождённая область. Аллокатор может принять решение выделить память из той же самой области, тем самым возвращая указатель, идентичный тому, что уже хранится в массиве. Таким образом, старый указатель начинает указывать на новые данные, что приводит к чтению непредсказуемой информации.

Такой сценарий демонстрирует характерную реализацию уязвимости типа Use-After-Free: программа обращается к освобождённой памяти, которая может быть повторно использована при последующих вызовах malloc(). Визуально поведение может оставаться корректным и даже полезным на этапе отладки, однако с точки зрения безопасности это создаёт серьёзную угрозу. Содержимое, доступное через такой dangling-указатель, может быть неожиданным, устаревшим или принадлежать другому объекту. Более того, это может привести к утечке конфиденциальной информации или нарушению логики работы, если освобождённый и переиспользованный участок будет интерпретироваться некорректно.

Подобные ошибки часто оказываются незаметными при поверхностном тестировании, поскольку не приводят к моментальному сбою программы. Однако в контексте безопасности, особенно в системах с многопоточностью, сетевым взаимодействием или при наличии доверенных данных, передаваемых через указатели, подобные недочёты становятся уязвимостями, которые могут быть использованы злоумышленниками.

Чтобы наглядно продемонстрировать данную уязвимость откроем uaf в отладчике GDB с включённым pwndbg.

Сначала в notes[0] записываем строку, затем освобождаем память. После этого в notes[1] записываем новые данные, malloc повторно использует тот же участок памяти..

Проверка через GDB показывает, что notes[0] и notes[1] указывают на один и тот же адрес, что подтверждает факт использования освобождённой памяти — классическая ситуация UAF.

Данный пример наглядно демонстрирует, как отсутствие зануления указателя после освобождения памяти может привести к нежелательному поведению — в том числе, к утечке или подмене данных. Такое поведение особенно опасно в многошаговых логиках, где один и тот же участок памяти может быть использован для хранения различных критичных структур.

Для безопасного управления памятью в подобных случаях рекомендуется всегда присваивать указателю значение NULL после free(), а также реализовывать дополнительные проверки на этапе чтения и записи.

Защиты

Современные версии glibc используют механизм под названием tcache — это локальный кэш освобождённых блоков памяти для каждого потока. Он ускоряет работу malloc и free, но при этом меняет логику поведения кучи. Классические техники эксплуатации, такие как атаки на fastbin’ы, стали менее эффективны, но появились новые — например, tcache poisoning. Благодаря tcache стало проще делать такие атаки, как double free, особенно если всё происходит в рамках одного потока.

Кроме того, на этапе компиляции включаются дополнительные защиты: PIE, NX, RELRO и stack protector. PIE делает адреса кода и данных непредсказуемыми, NX запрещает выполнение кода в области стека, RELRO защищает таблицу GOT от перезаписи, а stack protector обнаруживает попытки переполнения буфера. Все эти механизмы делают эксплуатацию сложнее, особенно для начинающих.

Поэтому, если задача создаётся для изучения уязвимости или для демонстрации, такие защиты обычно отключают. Например, убирают PIE, чтобы адреса были фиксированными, отключают stack protector и NX, чтобы не мешали работе эксплойта, а также могут оставить RELRO в частичном режиме или вовсе отключить. Это позволяет сосредоточиться именно на сути уязвимости и её поведении в памяти.

Заключение

Use-After-Free — одна из наиболее интересных и опасных уязвимостей в heap-эксплуатации. Она требует глубокого понимания работы аллокатора, но при этом даёт широкие возможности для креативной эксплуатации.

С помощью GDB, pwndbg и хорошей подготовки можно не только находить, но и использовать такие уязвимости, развивая навыки реверса и бинарного анализа.