
В разборе задачи Simple Stack Smash с Virginia Tech Summit CTF 2023 на Arch Cloud Labs — типичный entry-level pwn: 32-битный ELF, fgets на 0x400 байт в буфер на 16 байт, функция win по фиксированному адресу, без канарейки и без PIE. Решение уместилось в восемь строк на pwntools. Без библиотеки тот же таск требует ручного подсчёта смещения, упаковки адреса в little-endian через struct.pack и отладки синхронизации ввода-вывода — вместо десяти минут уходит сорок. Разница не в понимании уязвимости, а в инструменте. Этот pwn CTF туториал проведёт от установки pwntools до рабочего buffer overflow эксплойта — с разбором каждой строки кода и граблей, на которых буксуют новички.
Pwntools — python pwn библиотека для написания эксплойтов, заточенная под Linux. На Windows она работает исключительно через WSL2 с Ubuntu — не каприз авторов, а зависимости на POSIX-вызовы. В документации написано «CTF framework and exploit development library», и тут не врут: библиотека покрывает весь цикл от разведки бинаря до получения шелла. Подробнее — в нашем подробном разборе бинарный анализ уязвимостей.
Установка и настройка pwntools — одна команда: python3 -m pip install --upgrade pwntools. После этого в системе появляется утилита pwn — через неё генерируются шаблоны эксплойтов (pwn template ./vuln), а checksec ./vuln показывает защиты бинаря прямо из терминала без захода в Python.
Для GDB-отладки нужен плагин. Три варианта: pwndbg, GEF или peda. Я рекомендую pwndbg — его вывод лучше всего сочетается с cyclic и cyclic_find, а команда telescope наглядно показывает содержимое стека. Установка: sudo apt install gdb, клонирование репозитория pwndbg с GitHub и запуск setup.sh. Ещё стоит поставить gdbserver через sudo apt install gdbserver — он нужен для удалённой отладки и работы gdb.attach() из Python-скрипта.
Для статического анализа пригодится Ghidra или IDA — но для entry-level pwn challenges хватит за глаза objdump -d ./vuln | grep -A5 win, чтобы найти адрес целевой функции. Декомпилятор понадобится на задачах посложнее, где нет очевидного win.
Зачем это всё за пределами CTF: по данным Mandiant M-Trends 2025, эксплойты — 38% всех первичных векторов атак, самый распространённый метод initial access. Переполнение буфера на стеке — одна из классических уязвимостей в реальных атаках. При эксплуатации клиентского ПО (уязвимость в PDF-ридере или браузере) такие переполнения соответствуют технике Exploitation for Client Execution (T1203) в MITRE ATT&CK, хотя CTF-задачи с прямым запуском бинаря моделируют более общий сценарий. Навык бинарной эксплуатации, отработанный на CTF, напрямую конвертируется в работу пентестера или исследователя уязвимостей.
Каждый pwn-таск на CTF начинается не с запуска бинаря и не с чтения исходников — с разведки через checksec. Одна команда checksec ./vuln — и стратегия эксплуатации понятна ещё до открытия декомпилятора.
Типичный вывод для учебной задачи по бинарной эксплуатации CTF: Arch — i386-32-little, RELRO — Partial RELRO, Stack — No canary found, NX — NX enabled, PIE — No PIE (0x8048000).
Что каждая строка значит для написания эксплойта:
No canary found — на стеке нет канарейки (случайного значения между локальными переменными и return address). Можно свободно перезаписывать адрес возврата, не вызывая __stack_chk_fail. Прямой путь к stack overflow эксплуатации.
NX enabled — стек помечен как неисполняемый. Классическая атака с NOP sled + shellcode на стеке не сработает. Но для ret2win это не помеха: мы не исполняем код на стеке, а перенаправляем поток управления на существующую функцию в коде бинаря.
No PIE — адреса функций фиксированы при каждом запуске. Адрес win() одинаков и локально, и на сервере CTF. С включённым PIE пришлось бы сначала утекать базовый адрес — задача на порядок сложнее.
Этот набор — нет канарейки, NX включён, нет PIE — визитная карточка entry-level pwn challenges. По разбору Arch Cloud Labs, аналогичная конфигурация была на Virginia Tech Summit CTF 2023: бинарь буквально говорит «переполни буфер и перенаправь исполнение на win».
В Python-скрипте разведка делается через класс ELF: вызов elf = ELF('./vuln') автоматически запускает checksec и выводит результат в консоль. Из объекта elf достаются адреса функций через elf.symbols['win'], GOT-записи через elf.got['puts'], PLT-записи через elf.plt['puts']. Не нужно вручную парсить objdump или readelf — всё доступно как атрибуты объекта. На соревновании, где каждая минута на счету, это решает.
Что происходит на стеке: при вызове функции в нижние адреса памяти кладутся локальные переменные (включая наш буфер), затем сохранённый EBP (base pointer вызывающей функции), и наконец — return address. Данные в буфер пишутся от младших адресов к старшим. Вводишь больше байт, чем размер буфера — перезаписываешь сначала соседние переменные, потом сохранённый EBP, потом return address. Именно перезапись return address даёт контроль над потоком исполнения.
Смещение (offset) — количество байт от начала буфера до адреса возврата на стеке. Без правильного смещения pwntools buffer overflow эксплойт не работает: пейлоад либо не дотянется до return address, либо перезапишет не те байты.
Наивный подход — отправить строку AAAABBBBCCCC... и вычислять, какие четыре байта оказались в EIP при крэше. Для буферов в 16-20 байт это терпимо, но при размере 200+ ручной подсчёт — мучение. Pwntools решает задачу через cyclic patterns: уникальные последовательности, в которых любые четыре подряд идущих байта встречаются ровно один раз.
Пошаговый процесс нахождения offset:
Шаг 1 — генерация паттерна. В терминале cyclic 200 создаёт строку aaaabaaacaaadaaaeaaa... длиной 200 символов. Каждая четвёрка байт уникальна в пределах всей строки.
Шаг 2 — подача паттерна в бинарь под GDB. Запускаем gdb ./vuln, вводим run, вставляем паттерн. Программа падает с SIGSEGV. Pwndbg покажет значение EIP — например, 0x61616167. Это четыре байта из паттерна, которые перезаписали return address.
Шаг 3 — вычисление смещения. Команда cyclic -l 0x61616167 возвращает число — допустим, 24. Это offset: 24 байта мусора до return address, следующие 4 байта перезаписывают EIP. Процесс детально описан в туториале Georgia Tech CS6265 и в разборе Arch Cloud Labs.
Шаг 4 — верификация. Отправляем 24 * 'A' + 'BBBB' в бинарь под GDB. Если EIP = 0x42424242 (ASCII-код 'B') — смещение верное, можно переходить к написанию эксплойта для CTF. Если нет — пересчитываем. Пропускать этот шаг — типичная ошибка новичка: один лишний или недостающий байт — и эксплойт молча не работает без внятной ошибки.
В Python-скрипте те же функции: cyclic(200) генерирует паттерн, cyclic_find(0x61616167) возвращает смещение. Функция принимает и числа, и байтовые строки — cyclic_find(b'gaaa') даст тот же результат.
Для 64-битных бинарей — группа по 8 байт: в командной строке cyclic -n 8 200, в Python — cyclic(200, n=8). Смещение ищется по RSP или RIP. Механизм тот же, но размер одной уникальной подстроки больше.
Ret2win — базовый тип pwn-задачи: в бинаре есть функция (win, flag, print_flag), которая выводит флаг или запускает шелл. Она никогда не вызывается в нормальном потоке — задача атакующего перенаправить на неё управление через переполнение буфера. Классический формат для pwn заданий CTF решения начального уровня.
Допустим, разведка показала: 32-битный ELF без канарейки и без PIE, функция win по адресу 0x08049216, буфер на 16 байт, fgets читает до 0x400 байт. Смещение до return address — 28 байт (найдено через cyclic).
from pwn import *
context.binary = elf = ELF('./vuln')
offset = 28
payload = flat(cyclic(offset), elf.symbols['win'])
p = process()
p.sendlineafter(b':', payload)
p.interactive()
Разбор построчно:
from pwn import * — импортирует всё из pwntools. Библиотека следует подходу «kitchen sink»: один импорт — и доступны сборка пейлоада, упаковка адресов, работа с процессами и сетью.
context.binary = elf = ELF('./vuln') — загружает бинарь и автоматически выставляет архитектуру (context.arch), порядок байт (context.endian) и разрядность (context.bits). Одна строка вместо четырёх ручных настроек.
offset = 28 — число из cyclic_find. В боевом скрипте часто пишут offset = cyclic_find(0x61616167) прямо в коде, чтобы не терять контекст.
flat(cyclic(offset), elf.symbols['win']) — ядро пейлоада. flat() принимает произвольное количество аргументов — байтовые строки и числа — автоматически упаковывает числа через p32/p64 (в зависимости от context.bits) и склеивает в единую байтовую строку. Здесь cyclic(28) генерирует 28 байт мусора до return address, а elf.symbols['win'] — целое число, которое flat упакует в little-endian. Без flat пришлось бы писать cyclic(28) + p32(elf.symbols['win']) — функционально то же самое, но flat чище при сложных пейлоадах с десятками компонентов.
p = process() — запускает бинарь локально. Когда context.binary установлен, путь подтягивается из контекста автоматически.
p.sendlineafter(b':', payload) — ждёт символ : в выводе программы и только потом отправляет пейлоад с \n на конце. Надёжнее голого sendline(), потому что гарантирует синхронизацию: скрипт не отправит данные раньше, чем программа готова их принять. Без sendlineafter эксплойт buffer overflow на python может работать через раз — классический баг, который стоит часов отладки.
p.interactive() — переключает в интерактивный режим. Если эксплойт сработал — на экране появится флаг или шелл.
Отдельно про функции упаковки. p32(0x08049216) берёт число, дополняет до 4 байт и переставляет в порядок little-endian: 0x08049216 превращается в \x16\x92\x04\x08. Для 64-битных адресов — p64(), дополняет до 8 байт. Обратные функции u32() и u64() распаковывают байтовую строку обратно в число. Без чёткого понимания порядка байт отладка пейлоада — гадание на кофейной гуще: адрес выглядит правильно в скрипте, но при упаковке байты меняются местами.
Переключение на удалённый сервер CTF — замена одной строки: p = process() на p = remote('ctf.example.com', 1337). Весь остальной код — без изменений. Модуль pwnlib.tubes реализует единый интерфейс для локальных процессов, TCP-соединений и SSH-хостов. Эксплойт stack smashing, отлаженный локально, работает на удалённом сервере без переписывания — и за это pwntools для начинающих CTF-игроков ценят больше всего.
Методы отправки: send(data) — байты без символа новой строки, sendline(data) — с \n на конце, sendafter(delim, data) — ожидание строки перед отправкой, sendlineafter(delim, data) — ожидание + отправка с \n. Методы приёма: recv(n) — до n байт, recvline() — до \n, recvuntil(delim) — до появления заданной последовательности.
Разница между send и sendline — не мелочь. sendline добавляет \n (0x0a) — лишний байт. Если программа читает ввод через read(), лишний \n станет частью данных и сдвинет структуру пейлоада. Если через gets() или fgets() — символ \n служит разделителем и не попадает в буфер. Перед отправкой обязательно смотри в декомпиляторе, какая функция чтения используется. На этом горят даже опытные CTF-игроки.
Ещё один паттерн для pwn challenges — подключение по SSH: s = ssh('user', 'ctf.example.com', password='pass'), затем p = s.process('./vuln'). Нужно, когда задание работает как локальный бинарь на сервере, а не как сетевой сервис.
Для отладки из Python — gdb.attach(p, gdb_script), где p — запущенный процесс, а gdb_script — строка с командами GDB. Для корректной работы нужен терминальный мультиплексор: tmux, xterm, gnome-terminal. Pwntools выбирает терминал через context.terminal — например, context.terminal = ['tmux', 'splitw', '-h']. Если подходящий терминал не найден, gdb.attach выбросит ошибку (и ты потратишь полчаса, пока поймёшь, что дело не в эксплойте, а в терминале). Типичный сценарий: ставишь breakpoint на ret в уязвимой функции через gdb.attach(p, 'break *vuln+42\ncontinue'), отправляешь пейлоад и в GDB смотришь, что лежит на вершине стека в момент возврата. Если там правильный адрес win-функции — смещение верное. Команда telescope $esp 10 (32-бит) или telescope $rsp 10 (64-бит) в pwndbg покажет десять значений на стеке наглядно.
Альтернатива — gdb.debug('./vuln') вместо process(). Сразу открывает GDB с загруженным бинарём, без необходимости ловить PID. Удобно для начальной разведки, когда offset ещё неизвестен.
На 64-битной архитектуре есть нюанс, который ломает рабочий эксплойт без видимой причины. system() и другие libc-функции требуют 16-байтового выравнивания RSP перед вызовом. Ситуация: смещение правильное, RIP указывает на win-функцию, программа падает с SIGSEGV — но уже внутри system(), на инструкции movaps с невыровненным стеком. Каждый второй новичок в бинарной эксплуатации CTF тратит на это часы, не понимая, почему «всё правильно, а не работает».
Решение — добавить перед адресом win-функции адрес инструкции ret. Один ret сдвигает RSP на 8 байт и восстанавливает выравнивание. В pwntools для поиска ret-гаджета — класс ROP:
from pwn import *
context.binary = elf = ELF('./vuln64')
rop = ROP(elf)
ret = rop.find_gadget(['ret'])[0]
offset = 40
payload = flat(cyclic(offset), ret, elf.symbols['win'])
p = process()
p.sendlineafter(b':', payload)
p.interactive()
Отличие от 32-битной версии — две строки: создание объекта ROP и поиск гаджета. find_gadget(['ret']) ищет в бинаре инструкцию ret и возвращает её адрес. Порядок элементов в пейлоаде: мусор → ret-гаджет → адрес win. При выполнении ret-гаджет снимает со стека следующий адрес (win) и прыгает на него, но RSP при этом сдвигается на 8 байт — выравнивание восстанавливается. Если пейлоад работает в 32-битном бинаре и ломается в 64-битном — первым делом проверяй alignment.
Кроме выравнивания, при написании эксплойта для buffer overflow есть другие грабли:
Порядок байт. x86/x86-64 используют little-endian. Адрес 0x08049216 в памяти хранится как \x16\x92\x04\x08. Функции p32() и p64() конвертируют автоматически, но при ручной конкатенации байтовых строк ошибка с эндианностью почти гарантирована. Правило простое: адреса — только через p32(), p64() или flat(), никакой ручной записи.
Плохие символы. Некоторые функции ввода фильтруют определённые байты. По туториалу Georgia Tech CS6265, scanf пропускает пробельные символы: 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20. Если адрес win-функции содержит один из этих байтов — пейлоад обрежется. Для entry-level ret2win это редкость, но на средних задачах — стандартная головоломка.
Нулевые байты в адресе. В 64-битных бинарях адреса содержат старшие нули: 0x0000000000401234. При little-endian упаковке через p64() нули окажутся в конце: \x34\x12\x40\x00\x00\x00\x00\x00. Если программа читает через gets() — нулевой байт воспринимается как конец строки, но поскольку нули идут после полезной части адреса, а return address — последний элемент пейлоада, обычно не мешает. Проблема возникает, если нулевой байт попадает в середину — например, при ROP-цепочке из нескольких гаджетов с адресами, содержащими \x00.
Выход за пределы ret2win. Когда в бинаре нет готовой win-функции, нужны ROP-цепочки — последовательности адресов гаджетов, каждый из которых выполняет короткую инструкцию и передаёт управление следующему. Pwntools покрывает и это: класс [ROP умеет автоматически строить цепочки](https://docs.pwntools.com/en/stable/rop/rop.html) для вызова system("/bin/sh") или execve. Но это следующий уровень после уверенного освоения ret2win. То же с format string уязвимостями — класс FmtStr автоматизирует эксплуатацию, но требует понимания механики строк формата.
Entry-level pwn на CTF — шаблонная задача. Один и тот же паттерн «checksec → cyclic → flat → sendlineafter → interactive» покрывает большинство тасков до 200-300 очков. На этом уровне pwntools для начинающих даёт колоссальное ускорение: вместо возни с struct.pack и ручной синхронизацией ввода-вывода — фокус на самой уязвимости.
Но шаблон перестаёт работать, когда появляются канарейки, PIE, Full RELRO и ASLR. На среднем и высоком уровне pwn challenges нужны утечки через format string, ROP-цепочки для обхода NX, ret2libc для обхода отсутствия win-функции. Pwntools покрывает все эти сценарии — модули rop, fmtstr, dynelf, shellcraft — но использовать их осмысленно можно только после того, как руками разобрался, что делает каждый байт пейлоада на стеке. Иначе библиотека превращается из инструмента в чёрный ящик.
Большинство CTF-игроков, которых я видел на соревнованиях, способны решить ret2win за десять минут, но не могут объяснить, почему p32() переставляет байты или зачем нужен ret-гаджет перед адресом win-функции на 64-битном бинаре. Шаблон из восьми строк покрывает 80% задач начального уровня — и в этом одновременно сила и слабость подхода. Сила — потому что на CTF побеждает скорость. Слабость — потому что рост останавливается ровно в тот момент, когда шаблон перестаёт работать. Реальный навык бинарной эксплуатации начинается не с from pwn import *, а с понимания, что происходит между инструкцией ret и первым байтом контролируемого адреса на стеке. Если хочешь сдать OSCP — WAPT даёт ту же глубину, но с русскоязычным разбором.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...