Главная / Блог / Buffer Overflow с нуля: находим переполнение буфера и перезаписываем EIP на учебном бинарнике в GDB

11 мин.00

Buffer Overflow с нуля: находим переполнение буфера и перезаписываем EIP на учебном бинарнике в GDB

Buffer Overflow с нуля: находим переполнение буфера и перезаписываем EIP на учебном бинарнике в GDB

Buffer Overflow с нуля: перезаписываем EIP на учебном бинарнике в GDB

На CTF в категории pwn задачи начального уровня выглядят одинаково: 32-битный ELF, gets() или strcpy() без проверки длины, защиты выключены — и цель: вычислить точное смещение до регистра EIP и перенаправить выполнение. ROP Emporium, pwnable.kr, exploit.education — этот сценарий повторяется десятки раз. И вот что важно: без уверенной перезаписи EIP двигаться дальше в бинарной эксплуатации не получится. Ни ROP, ни heap exploitation, ни format string attacks — ничего из этого не ляжет без фундамента. Разберём весь процесс: от компиляции уязвимой программы до момента, когда GDB покажет в EIP заветное 0x42424242.

Переполнение стека для начинающих: стековый кадр и регистры x86

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

Стек — область памяти для локальных переменных, аргументов вызовов и служебных данных. На x86 стек растёт от старших адресов к младшим: каждый push уменьшает указатель стека. При вызове функции на стек кладётся стековый кадр (stack frame) — блок с аргументами, адресом возврата, сохранённым базовым указателем и локальными переменными.

Три регистра, на которых всё держится:

  • ESP (Extended Stack Pointer) — текущая вершина стека, дёргается при каждом push и pop
  • EBP (Extended Base Pointer) — «дно» текущего стекового кадра, от него отсчитываются адреса локальных переменных (буфер buf лежит, например, по адресу $ebp - 0x48)
  • EIP (Extended Instruction Pointer) — адрес следующей инструкции; именно этот регистр решает, что программа делает дальше

Почему переполнение буфера даёт контроль? Внутри стекового кадра локальные переменные лежат ближе к вершине стека (младшие адреса), а сохранённый EBP и адрес возврата — дальше (старшие адреса). Буфер char buf[64] — ниже, адрес возврата — выше. Запишешь в буфер больше, чем он вмещает — лишние байты «проедут» через padding и сохранённый EBP, а потом лягут точно на адрес возврата. Когда функция выполнит ret, процессор заберёт перезаписанное значение из стека и поместит его в EIP. Программа «прыгнет» по адресу, который выбрал атакующий. Это return address overwrite — фундамент stack-based buffer overflow.

В классификации MITRE ATT&CK переполнение буфера фигурирует в нескольких техниках в зависимости от контекста: Exploitation for Privilege Escalation (T1068), Exploit Public-Facing Application (T1190), Exploitation for Client Execution (T1203). Эти техники описывают контексты применения эксплойтов в целом, а не специфично buffer overflow — детали на attack.mitre.org. Наш учебный пример с CLI-аргументом не привязан к конкретной технике ATT&CK — он демонстрирует саму механику memory corruption.

Замечание для тех, кто работает с 64-битной архитектурой: на x86_64 указатели занимают 8 байт, регистр называется RIP вместо EIP, базовый указатель — RBP вместо EBP, и calling convention другой (аргументы идут через регистры, а не через стек). Механика переполнения та же, но размеры смещений другие. Здесь работаем с 32-битным бинарником — стандарт для начальных pwn-тасков и отправная точка для изучения buffer overflow с нуля.

Пример переполнения буфера: уязвимый бинарник и настройка стенда

Применимо: учебная среда / [CTF начального уровня, legacy-бинарники без SSP/NX/ASLR/PIE, 32-bit x86 Linux]

Работает если: бинарник 32-bit x86, скомпилирован без stack canary (-fno-stack-protector), без PIE (-no-pie), NX отключён (-z execstack), ASLR отключён на уровне ОС.

Не работает если: хотя бы одна защита включена — stack canary обнаружит перезапись и вызовет __stack_chk_fail, NX заблокирует исполнение шелл-кода на стеке, ASLR рандомизирует адреса при каждом запуске.

Требования к окружению

  • ОС: Ubuntu 22.04 или 24.04 LTS (x86_64 с поддержкой 32-bit через gcc-multilib)
  • RAM: 1 ГБ минимум (GDB + один процесс)
  • Пакеты: gcc, gcc-multilib, gdb, python3, pwntools (pip install -U pwntools — старые версии содержат SSTI-уязвимость GHSA-7xc5-ggpp-g249; минимальную безопасную версию уточняйте в GitHub Security Advisory)
  • GDB-расширение: pwndbg или GEF. Без них работать в GDB на порядок тяжелее — серьёзно. pwndbg ставится так: git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh. Расширение автоматически показывает регистры, дизассемблер и стек при каждой остановке
  • ASLR: для учебных целей отключаем: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space (действует до перезагрузки)

Берём минимальную уязвимую программу. Функция overflow() копирует аргумент командной строки в буфер фиксированного размера через strcpy() без проверки длины — классика. Сохраните код как vuln.c:

#include <string.h>
void overflow(char *input) {
    char buf[64];
    strcpy(buf, input);  // тут и живёт баг
}
int main(int argc, char **argv) {
    if (argc > 1) overflow(argv[1]);
    return 0;
}

strcpy() не проверяет, влезет ли строка input в 64-байтовый буфер. Если входная строка длиннее — данные выйдут за границу buf и перезапишут всё, что лежит выше по стеку. Такие функции (strcpy, gets, sprintf) — главные источники stack-based buffer overflow в Си-коде. Безопасные аналоги (strncpy, fgets, snprintf) ограничивают объём копируемых данных, но в учебных бинарниках намеренно используются уязвимые варианты.

Компилируем с отключёнными защитами: gcc -m32 -fno-stack-protector -z execstack -no-pie -g -o vuln vuln.c. Что делает каждый флаг: -m32 — 32-битный ELF, -g — отладочные символы для GDB, -fno-stack-protector — убирает stack canary (GCC включает его по умолчанию начиная примерно с 4.x), -z execstack — помечает стек как исполняемый, -no-pie — фиксирует адреса секций.

После компиляции проверяем защиты: pwn checksec ./vuln (CLI-утилита из pwntools). Ожидаемый результат: Stack: No canary found, NX: NX disabled, PIE: No PIE. Все три защиты выключены — бинарник готов к эксплуатации уязвимости buffer overflow.

Сразу стоит глянуть, как компилятор расположил данные в стеке: objdump -d vuln | grep -A 15 "<overflow>". Обратите внимание на инструкцию sub $0xNN, %esp в прологе функции — она показывает, сколько байт выделено под локальные переменные. Это значение может превышать 64 из-за выравнивания стека на 16-байтовую границу. И вот тут начинается самое интересное.

Поиск переполнения буфера: offset до EIP при отладке бинарника в GDB

Крэш бинарника — доказательство уязвимости

Загружаем бинарник в GDB: gdb -q ./vuln. Сначала нормальный ввод: run AAAA. Программа завершается штатно, exit code 0. Теперь шлём данные, заведомо превышающие размер буфера: run $(python3 -c "print('A' * 100)").

Результат: Program received signal SIGSEGV, Segmentation fault. Команда info registers eip показывает eip 0x41414141 — адрес возврата перезаписан символами A (ASCII-код 0x41). Процессор попытался выполнить инструкцию по адресу 0x41414141, которого не существует, и выбросил segfault. Факт доказан: переполнение буфера достигает адреса возврата, и мы контролируем содержимое EIP.

Но 100 байт — грубая оценка. Сколько именно байт нужно, чтобы заполнить пространство до EIP (не включая его)? Этот параметр — offset. Зная его, можно расположить нужный адрес так, чтобы он попал ровно в EIP — ни байтом раньше, ни байтом позже.

Паттерн де Брёйна — точный анализ стека в GDB

Слать однообразные AAAA... и подбирать длину вручную — работа на часы (и нервы). Для точного определения offset используется паттерн де Брёйна (De Bruijn sequence) — последовательность символов, в которой каждая подстрока заданной длины встречается ровно один раз. По 4 байтам, оказавшимся в EIP после крэша, можно однозначно вычислить их позицию в паттерне, а значит — точное расстояние от начала буфера до адреса возврата.

Инструменты для генерации паттерна:

  1. pwntools CLI (рекомендую): cyclic 100 генерирует паттерн длиной 100 байт; cyclic -l <hex_value> вычисляет offset по значению из EIP
  2. Metasploit Framework: pattern_create.rb -l 100 и pattern_offset.rb -q <value> — та же задача
  3. pwndbg: если расширение установлено, cyclic доступна прямо внутри GDB-сессии

Пошаговый процесс:

  1. Генерируем паттерн: cyclic 100. Получаем строку вида aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaam...
  2. Отправляем как аргумент в GDB: run aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaam... (полную строку из вывода cyclic)
  3. Программа падает с SIGSEGV. Смотрим EIP: info registers eip. Допустим, GDB показывает eip 0x6161616c
  4. Вычисляем offset: cyclic -l 0x6161616c. Результат: 76. Между началом буфера и адресом возврата — ровно 76 байт

Почему 76, а не 68 (64 байта буфера + 4 байта EBP)? Компилятор добавляет padding для выравнивания стека. Инструкция sub $0x48, %esp (0x48 = 72) в прологе overflow() выделяет 72 байта — на 8 больше, чем объявленный буфер. Итого: 72 байта выделенного пространства + 4 байта сохранённого EBP = 76 байт до адреса возврата. Именно поэтому offset нельзя вычислять по размеру массива в исходниках — его всегда определяют экспериментально через паттерн. Вот этот момент — когда понимаешь, откуда берутся лишние 8 байт — отделяет «скопировал writeup» от «понял, что происходит».

Перезапись EIP: контролируем поток выполнения программы

Зная точный offset (76), формируем проверочный payload: 76 байт заполнителя + 4 байта маркера, которые должны оказаться в EIP. Берём символ B (ASCII 0x42):

(gdb) run $(python3 -c "print('A'*76 + 'BBBB')")
Program received signal SIGSEGV, Segmentation fault.
0x42424242 in ?? ()
(gdb) info registers eip
eip            0x42424242          0x42424242

0x42424242 в EIP — наши четыре буквы B. Контроль подтверждён: мы записали произвольное 4-байтовое значение в регистр, определяющий следующую исполняемую инструкцию. Это control flow hijacking — ключевой момент эксплуатации buffer overflow.

Что подставить вместо BBBB? Зависит от задачи.

Ret2win — простейший CTF-сценарий. Если в бинарнике есть функция-«победитель» (обычно win(), flag() или shell()), находим её адрес: objdump -d vuln | grep win или p &win в GDB. Подставляем адрес в little-endian — младший байт первым. Для адреса 0x08049196 payload: 'A' * 76 + '\x96\x91\x04\x08'.

Шелл-код на стеке — если NX отключён и нужен полноценный шелл. Шелл-код (машинные инструкции для запуска /bin/sh) помещается в буфер, перед ним ставится NOP sled — цепочка байтов \x90 (no-operation), которая «скользит» исполнение к шелл-коду и компенсирует неточность адреса. В EIP записывается адрес куда-то в середину NOP sled. Адрес находят через дамп стека в GDB: x/100x $esp-200, ищут последовательность 0x90909090.

ROP — если NX включён (стандарт для задач средней сложности). Вместо шелл-кода формируется цепочка адресов «гаджетов» — фрагментов кода бинарника или библиотек, заканчивающихся ret. Каждый гаджет выполняет одну операцию, цепочка складывается в нужную последовательность: загрузить аргумент, вызвать system().

В полной цепочке атаки перезапись EIP — это шаг получения выполнения кода. До него — поиск уязвимости и вычисление offset; после — шелл, повышение привилегий, пост-эксплуатация. По аналогии с веб-безопасностью: перезапись EIP — как первый ' OR 1=1 -- в SQL injection. Отсюда начинается всё остальное.

Основы бинарной эксплуатации: ограничения и защитные механизмы

Описанная техника работает в учебных условиях. В реальных бинарниках и CTF средней сложности активны защиты, каждая из которых ломает отдельный этап эксплуатации.

Stack Canary (SSP) — компилятор вставляет случайное значение (canary) между локальными переменными и адресом возврата. Перед ret значение проверяется: изменилось — программа вызывает __stack_chk_fail и падает. GCC включает SSP по умолчанию. checksec покажет Stack: Canary found. Обход: утечка canary через format string, side-channel, brute force в fork-серверах (canary не меняется при fork() — актуально для сетевых сервисов с fork-моделью, не для нашего CLI-бинарника).

NX / DEP — стек помечен как неисполняемый. Шелл-код в буфере не выполнится — процессор сгенерирует исключение. checksec: NX: NX enabled. Обход: ROP-цепочки, ret2libc (вызов system("/bin/sh") через библиотечную функцию). NX включён по умолчанию в современных ОС и компиляторах.

ASLR — адреса стека, кучи, библиотек рандомизируются при каждом запуске. Даже зная offset, атакующий не знает, куда указывает шелл-код или ROP-гаджет. Значение 2 в /proc/sys/kernel/randomize_va_space — полная рандомизация. Обход: утечка адресов, partial overwrite младших байтов, brute force на 32-битных системах (пространство стека ~2^12 вариантов — вполне перебираемо).

PIE — адреса самого бинарника (.text, .plt, .got) тоже рандомизируются. Без утечки base address нельзя использовать ни адреса функций, ни гаджеты. checksec: PIE: PIE enabled. Обход: утечка адреса из бинарника, partial overwrite.

На практике результат checksec определяет стратегию: Canary: No, NX: No, PIE: No — прямой stack smash с шелл-кодом или ret2win; NX: Yes — нужен ROP; Canary: Yes — сначала утечка canary; Full RELRO + Canary + NX + PIE — задача высокой сложности с цепочкой утечек. Каждая защита добавляет один шаг к эксплуатации, но принцип тот же: найти переполнение, вычислить offset, контролировать адрес возврата.

Переполнение буфера в стеке — не единственный вид. Есть heap overflow (перезапись метаданных аллокатора) и format string attacks (уязвимые функции семейства printf позволяют читать и писать по произвольным адресам). Но все эти техники базируются на одном принципе: данные, контролируемые атакующим, интерпретируются программой как адреса или инструкции. Stack buffer overflow — самый прямолинейный пример этого принципа, и начинать стоит именно с него.

Большинство pwn-туториалов останавливаются на моменте «EIP перезаписан, подставляй шелл-код». По опыту разбора нескольких десятков тасков на разных CTF-площадках — реальное понимание приходит не тогда, когда EIP показывает 0x42424242, а когда можешь объяснить, почему offset составляет 76, а не 68. cyclic и pattern_create работают одинаково — дело не в утилитах. Дело в понимании того, как компилятор раскладывает стековый кадр: какой padding вставляет, почему sub $0x48, %esp вместо sub $0x40, %esp, где заканчивается буфер и начинается служебная область.

Ещё одна системная проблема: новички зацикливаются на NOP sled и шелл-коде, хотя на современных CTF-площадках даже задачи категории easy идут с включённым NX. Шелл-код на стеке не выполнится. Время, потраченное на заучивание байтовых последовательностей \x31\xc0\x50\x68..., было бы полезнее вложить в ROP и ret2libc — техники, которые работают при включённом NX. Но без навыка точной перезаписи EIP ни ROP, ни ret2libc не имеют смысла — это фундамент, и пропускать его как учить SQL injection, не понимая структуру SQL-запроса. Если хочешь не просто writeup прочитать, а пройти всю атаку от fuzzing до RCE самому — на WAPT разбирают эту цепочку в двух модулях с лабами на каждый кейс.

🚀 Хочешь закрепить на практике? CTF-задачи и лабы ждут на HackerLab.

Поделиться

0 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...