
На первом CTF я потратил три часа на pwn-таск за 100 очков — задачу, которую тиммейты закрывают за семь минут. Уязвимость увидел сразу: классический buffer overflow через gets(), без canary, без PIE. Но вместо шести строк на pwntools я полез считать смещение в hex-редакторе и конструировать payload через echo -e | nc. Когда тиммейт показал свой скрипт — шесть строк Python, shell на сервере — я понял: pwntools для начинающих это не «потом освою», а первый навык, без которого в pwn-категории ловить нечего. Разберём весь путь от нуля до работающего ret2win эксплойта — с конкретным бинарником, реальными командами и разбором каждой строчки.
Минимальный набор: Python 3 с библиотекой pwntools, GDB с плагином pwndbg (или GEF — дело вкуса, но pwndbg мне нравится больше) и утилита checksec для анализа защит бинарника. Подробнее — в нашем материале про бинарный анализ уязвимостей.
Pwntools ставится одной командой: python3 -m pip install pwntools. После установки появляется библиотека pwn для импорта в скрипты и набор CLI-утилит: checksec, cyclic, pwn template. Если pip ругается на зависимости — на Debian/Ubuntu доставьте python3-dev и libssl-dev.
Pwndbg — клонируете репозиторий с GitHub, запускаете setup.sh, скрипт сам подключает плагин к GDB. После этого вместо голой консоли GDB вы увидите цветной вывод с регистрами, стеком и дизассемблером. Как пишут сами авторы pwndbg: «плагин GDB, который делает отладку менее мучительной, с фокусом на задачах разработчиков эксплойтов». Разница между голым GDB и pwndbg — как между блокнотом и нормальной IDE.
Ещё пригодится objdump из пакета binutils — он уже стоит в большинстве дистрибутивов. Для дизассемблирования сложных бинарников можно взять Ghidra или radare2, но для базовых ret2win-тасков хватит связки pwndbg + pwntools за глаза.
Первое действие при получении бинарника — checksec. Утилита из pwntools показывает, какие защиты компилятор включил при сборке. От вывода checksec зависит вся стратегия эксплуатации. Допустим, CTF-организаторы выдали бинарник vuln — типичный ret2win таск. Запускаем checksec ./vuln:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Разберём каждую строку — без этого написание эксплойта на Python превращается в слепой перебор.
Arch: amd64-64-little — 64-битный бинарник, Little-Endian. Для упаковки адресов берём p64() из pwntools, адреса 8-байтовые.
No canary found — стековых канареек нет. Канарейка (stack canary) — случайное значение между локальными переменными и адресом возврата. При переполнении она затирается, программа это ловит и завершается с *** stack smashing detected ***. Без канарейки мы свободно перезаписываем return address. Красота.
NX enabled — стек не исполняемый. Бит NX запрещает выполнение кода в области стека. Записать shellcode в буфер и прыгнуть на него не выйдет — нужна другая стратегия: вместо инъекции шеллкода перенаправляем выполнение на существующую функцию в бинарнике. Это и есть суть ret2win.
No PIE — бинарник грузится по фиксированному адресу 0x400000. Адреса функций не рандомизируются при каждом запуске. Адрес целевой функции win() можно хардкодить в payload.
Partial RELRO — таблица GOT частично доступна для записи. Для ret2win не критично, но при GOT overwrite или ret2libc атаках этот параметр становится определяющим.
Итого: нет канарейки, нет PIE, стек не исполняемый — классический сценарий для ret2win. Находим адрес целевой функции и перезаписываем return address. Будь включён PIE — адреса менялись бы при каждом запуске. Стояла бы канарейка — пришлось бы сначала её утечь через info leak.
В типичном ret2win таске есть функция, которую программа не вызывает при нормальном выполнении, но если перенаправить на неё поток управления — получим флаг или shell. Найти её можно прямо через pwntools: класс ELF при загрузке бинарника парсит таблицу символов. Достаточно python3 -c "from pwn import *; e = ELF('./vuln'); print(hex(e.symbols['win']))" — и адрес на экране. В pwndbg — команда info functions после загрузки бинарника. Классический вариант — objdump -d ./vuln | grep win.
Допустим, адрес функции win — 0x401196. Этот адрес мы подставим в payload вместо оригинального return address.
Следующий вопрос: сколько байт мусора записать в буфер, чтобы добраться до адреса возврата? Этот размер — offset. Считать вручную по дизассемблеру можно, но медленно и чревато ошибками (спросите меня, откуда знаю). Pwntools решает задачу за секунды через циклический паттерн.
Функция cyclic() генерирует строку, в которой каждая подпоследовательность из N символов уникальна. Когда программа упадёт при переполнении, в регистре RSP окажется фрагмент этого паттерна. По фрагменту cyclic_find() определит точное смещение.
На практике: в терминале — cyclic 200 | ./vuln. Программа падает с Segmentation fault. Открываем бинарник в GDB: gdb ./vuln. Внутри — r < <(cyclic 200). Pwndbg покажет состояние регистров в момент краша. Нас интересует значение RSP — там лежит адрес, который мы перезаписали.
Pwndbg показал: RSP содержит 0x6161616661616165. Это фрагмент cyclic-паттерна. В терминале: cyclic -l 0x6161616661616165 — получаем 40. Значит, 40 байт мусора заполняют буфер и сохранённый RBP, а начиная с 41-го байта перезаписывается return address.
То же самое из Python: cyclic_find(0x6161616661616165) вернёт 40. Как показывает разбор Virginia Tech Summit CTF от Arch Cloud Labs, pwndbg умеет cyclic -l <значение> прямо в отладчике — без переключения в терминал.
Критический нюанс для x64: по умолчанию cyclic генерирует паттерн с 4-байтовыми уникальными подстроками (для 32-битных бинарников). Для 64-битных нужны 8-байтовые: context.arch = 'amd64' в скрипте или cyclic -n 8 в командной строке. Без этого cyclic_find не найдёт offset — будет искать 4-байтовую подстроку в 8-байтовом значении. Это одна из самых частых причин, по которой новичок застревает на два часа с правильным в целом подходом. Я сам так попался.
Всё необходимое собрано: адрес целевой функции (0x401196), offset до return address (40), архитектура. Собираем эксплойт buffer overflow.
from pwn import *
context.arch = 'amd64'
elf = ELF('./vuln')
p = process('./vuln')
offset = 40
win_addr = elf.symbols['win']
payload = b'A' * offset + p64(win_addr)
p.sendline(payload)
p.interactive()
Разберём каждую строку — понимание того, что делает каждый вызов, отличает осознанное решение CTF pwn задач от слепого копирования writeup.
from pwn import * — импортирует всё из pwntools: функции упаковки, классы для работы с процессами и ELF-файлами, утилиты вроде cyclic.
context.arch = 'amd64' — задаёт архитектуру. От этого зависит поведение p64(), p32(), cyclic(). Без явной установки pwntools может выбрать неправильный размер упаковки — и привет, два часа отладки.
elf = ELF('./vuln') — загружает бинарник. Класс ELF (конструктор __init__(self, path, checksec=True)) парсит таблицу символов, GOT, PLT и секции. Через elf.symbols['win'] получаем адрес без ручного objdump.
p = process('./vuln') — запускает локальный процесс и создаёт tube — канал ввода-вывода для отправки данных и получения ответа.
payload = b'A' * offset + p64(win_addr) — 40 байт мусора плюс адрес win, упакованный в 8 байт Little-Endian. Функция p64(0x401196) возвращает b'\x96\x11\x40\x00\x00\x00\x00\x00'. Именно это значение окажется в RIP после инструкции ret в уязвимой функции.
p.sendline(payload) — отправляет payload в stdin процесса, добавляя \n в конце. Для функций ввода, которые не ждут перевод строки (например, read()), используйте p.send() без \n.
p.interactive() — переключает в интерактивный режим. Если win() вызвала system("/bin/sh") — появится shell.
Функции упаковки решают главную головную боль новичка: как записать адрес 0x401196 в виде байтовой строки с правильным порядком байт?
p64(0x401196) — упаковка числа в 8 байт Little-Endian. Дополняем до 8 байт (0x0000000000401196), разворачиваем порядок побайтово. Результат: \x96\x11\x40\x00\x00\x00\x00\x00.
p32(0x401196) — то же самое, но в 4 байта. Для 32-битных бинарников.
Обратные функции — u32() и u64() — распаковывают байтовую строку обратно в число. Нужно при чтении адресов из утечки памяти: u64(b'\x96\x11\x40\x00\x00\x00\x00\x00') вернёт 0x401196.
Алгоритм прост: p32(0x0EDB) дополняет до 4 байт — 0x00000EDB — и разворачивает: \xDB\x0E\x00\x00. Понимание этого механизма снимает 90% вопросов вида «почему мой эксплойт не работает».
Эксплойт написан, но при запуске процесс падает без shell. Первый инстинкт — менять payload наугад. Правильный подход — подключить GDB и посмотреть, что лежит в регистрах в момент ret.
Pwntools умеет автоматически подключать GDB к процессу через gdb.attach(). Для этого нужен tmux: запускаете tmux, внутри — скрипт. Pwntools откроет вторую панель с GDB. В скрипте после создания процесса добавьте gdb.attach(p, 'break *main\ncontinue') — второй аргумент содержит GDB-команды, которые выполнятся при подключении. Стандартный набор: breakpoint на инструкцию ret уязвимой функции и continue.
В pwndbg команда stack 20 покажет содержимое стека, x/gx $rsp — значение по адресу RSP. Если в RSP лежит адрес win — offset правильный. Если что-то другое — пересчитывайте.
Как описано в tutorial от Georgia Tech, gdb-модуль pwntools позволяет программировать отладочные скрипты. Вместо ручного ввода точек останова каждый раз — описываете их один раз в Python-строке и запускаете через gdb.attach(). Экономия времени на CTF — колоссальная.
На 64-битных системах перед вызовом ряда функций (включая system()) стек должен быть выровнен по 16 байтам. Если при входе в win() RSP не делится на 16, инструкция movaps внутри libc выбросит SIGSEGV. И не потому что адрес неверный — адрес правильный, стек кривой.
Решение: добавить ret gadget перед адресом win. Инструкция ret просто смещает RSP на 8 байт, восстанавливая выравнивание. Найти адрес можно через ROPgadget --binary ./vuln | grep ": ret$" или через pwntools: rop = ROP(elf) и затем rop.find_gadget(['ret']). Payload становится: b'A' * offset + p64(ret_gadget) + p64(win_addr).
Ни один из русскоязычных гайдов по stack buffer overflow эксплуатации, которые я видел в топе выдачи, не объясняет эту проблему на уровне «почему». И это стоит новичкам часов на каждом втором x64-таске.
На CTF эксплойт отправляется на удалённый сервер. Pwntools делает переключение элементарным: замените process('./vuln') на remote('ctf.example.com', 1337). Интерфейс tube одинаковый — sendline(), recv(), interactive() работают идентично.
Стандартный приём — аргумент args из pwntools. В скрипте: p = remote('ctf.example.com', 1337) if args.REMOTE else process('./vuln'). Запуск: python3 exploit.py для локального теста, python3 exploit.py REMOTE для атаки на сервер.
Команда pwn template ./vuln --host ctf.example.com --port 1337 генерирует полный шаблон с обработкой аргументов, загрузкой ELF и настройкой подключения. Как отмечает Arch Cloud Labs в разборе Virginia Tech CTF, такие шаблоны стоит держать под рукой — на турнире каждая сэкономленная минута на бойлерплейте идёт в решение задач.
Для ret2win с вызовом функции из самого бинарника проблема не возникает — адрес win не зависит от libc. Но как только перейдёте к ret2libc, столкнётесь с тем, что автор The Pwner's Roadmap (izzy.sh) называет «Libc Hell»: ваш локальный glibc 2.39, на сервере — 2.31, оффсеты system() различаются и ROP-цепочка прыгает в мусор. Решение — утилита pwninit: она скачивает нужный линкер и патчит бинарник под целевую libc. Это не опциональное знание — столкнётесь с этим в первые же недели работы с удалёнными тасками.
Каждый новичок в pwn наступает на одни и те же грабли. Вот список из реальной практики — каждая ошибка обходится в потерянные часы на соревнованиях.
Неправильный размер упаковки. Используете p32() для 64-битного бинарника или наоборот. Адрес упаковывается в неправильное количество байт, весь payload сдвигается. Решение — всегда ставьте context.arch в первой строке после импорта. Checksec показал amd64 — ставите amd64. Показал i386 — ставите i386.
Лишний символ новой строки. sendline() добавляет \n. Если программа читает ввод через read() (которая не рассматривает \n как разделитель), лишний байт смещает payload. Правило: sendline() для gets(), scanf(), fgets(); send() для read().
Offset с ошибкой на 8 байт. В x64 между концом буфера и return address лежит сохранённый RBP — 8 байт. Если считаете offset вручную от начала буфера до RBP, а не до return address — payload не дотянется. Cyclic решает это автоматически: он находит смещение до значения в RSP, которое и есть return address.
Забытое выравнивание стека. Описано выше — добавьте ret gadget перед адресом целевой функции на x64. Симптом: программа падает внутри win() (не до неё, а именно внутри — смотрите backtrace в pwndbg).
Cyclic с неправильным размером подстроки. По умолчанию cyclic генерирует 4-байтовые уникальные подстроки (для 32-битных бинарников). Для x64 нужны 8-байтовые. Без context.arch = 'amd64' перед вызовом cyclic_find() функция вернёт неправильный offset или вообще не найдёт совпадения.
Ret2win — входной билет в pwn. Следующий шаг — ret2libc: вместо вызова функции из бинарника вы дёргаете system("/bin/sh") из libc, когда win-функции нет. Связка cyclic + p64() + ROP из pwntools покрывает большинство таких задач.
Затем — полноценные ROP-цепочки. Вместо одного адреса в payload помещается последовательность адресов gadget'ов, каждый из которых выполняет одну операцию. Класс ROP в pwntools автоматизирует поиск gadget'ов и построение цепочек. Согласно ROP Emporium (ресурс с восемью задачами на ROP), базовые x64-цепочки стоит тренировать до автоматизма: на турнирах скорость построения chain определяет, решите вы задачу или нет.
Дальше — format string атаки (в pwntools есть класс FmtStr с методами write() и execute_writes()), heap exploitation (use-after-free, tcache poisoning) и kernel pwn. Каждый уровень требует на порядок больше времени. По оценке из Pwner's Roadmap (izzy.sh), от первого buffer overflow до стабильного решения heap-задач — 2-4 месяца ежедневной практики.
В контексте MITRE ATT&CK stack buffer overflow эксплуатация соответствует технике Exploitation for Client Execution (T1203, Execution) — когда атакующий использует уязвимость для выполнения кода. В сценариях повышения привилегий — Exploitation for Privilege Escalation (T1068). Понимание этих техник на уровне байтов делает сильнее и в атаке, и в защите.
Замечаю устойчивый паттерн у людей, которые приходят в бинарную эксплуатацию с нуля: недели уходят на чтение теории про стек, кучу, виртуальную память — и ни одного запуска GDB. Мой опыт обратный. Всё, что знаю про pwn, узнал из отладчика, а не из учебников. Теория без GDB — как изучать плавание по видеороликам: можете описать технику гребка, но в воде захлебнётесь. Правильная стратегия — взять бинарник, сломать его, посмотреть в pwndbg что произошло, починить эксплойт, повторить. На каждой итерации понимание стека растёт сильнее, чем за главу учебника. Ret2win — не финальная точка, а первый шаг. Но без первого шага не бывает второго. Формула на бумаге понятна, а вот переполнение по-настоящему ощущается, когда в pwndbg видишь, как твой адрес ложится ровно в RSP. Если хочешь не просто writeup прочитать, а пройти всю атаку самому — на WAPT есть лаба на каждый такой кейс.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...