Главная / Блог / Pwntools для начинающих: первый эксплойт для CTF

12 мин.00

Pwntools для начинающих: первый эксплойт для CTF

Pwntools для начинающих: первый эксплойт для CTF

Первый buffer overflow на PicoCTF я решал как пещерный человек: отправлял строки через python3 -c "..." | nc, подбирал смещение перебором, переворачивал адрес возврата побайтово в калькуляторе под little-endian. Сорок минут. Тот же эксплойт на pwntools — восемь строк Python и три минуты от открытия бинарника до флага. Разница не в сложности бага, а в инструментарии. Pwntools — стандартная Python-библиотека для бинарной эксплуатации в CTF, и после первого знакомства ручной подход начинает казаться наказанием самому себе. Этот pwntools туториал — для тех, кто понимает концепцию переполнения буфера, но хочет перейти от теории к рабочему эксплойту на Python.

Зачем pwntools, если есть чистый Python и netcat

Решать pwn-задачи можно и без pwntools. Открываешь терминал, подключаешься через nc, вбиваешь данные, наблюдаешь краш. Проблемы начинаются, когда задача требует чуть больше, чем ввод текста с клавиатуры.

Передать непечатные байты — нулевые символы, адреса из верхней половины адресного пространства — через обычный ввод нельзя. Приходится городить конструкции вроде echo -e '\x41\x41...\xef\xbe\xad\xde' | ./vuln. Неудобно, и каждая правка payload — лотерея с опечатками.

Найти точное смещение до адреса возврата вручную — это тупой перебор: 10 символов 'A', потом 20, 30, пока программа не упадёт. Потом бинарным поиском уточняешь конкретный байт. Через cyclic-паттерны pwntools это одна команда.

Переключение между локальным тестированием и удалённым сервером CTF без pwntools — переписывание скрипта. В pwntools — замена одной строки: process на remote.

Упаковка адресов в little-endian вручную — рутина, на которой легко ошибиться. p64 и p32 делают это автоматически.

Подключение GDB к процессу прямо из эксплойт-скрипта — без pwntools это отдельный квест с gdbserver и ручной настройкой.

Pwntools создана командой Gallopsled (финалисты DEF CON CTF) и де-факто стала стандартом для разработки эксплойтов на Python в категории pwn. Практически каждый writeup с соревнований уровня PicoCTF, HackTheBox CTF или DEF CON Quals использует эту библиотеку. Как показывает разбор задач PicoCTF 2021 (heartburn.dev), даже format string эксплойты и ret2libc цепочки строятся через pwntools — от подключения к серверу до парсинга утечек из памяти. Библиотека убирает рутину, и время уходит на анализ уязвимости, а не на конвертацию hex-значений в терминале.

Установка pwntools и подготовка окружения

Pwntools работает на Linux. Основная платформа — 64-битная Ubuntu LTS (22.04 и 24.04). На Windows — WSL2, на macOS — с ограничениями (часть функций, связанных с ELF и GDB, просто не работает). По официальной документации, версия 5.0.0 требует Python 3.10 или новее. Поддержка Python 2 полностью убрана начиная с v5.

Установка в два шага: сначала sudo apt-get update && sudo apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential -y, затем python3 -m pip install --upgrade pwntools. Проверка — pwn version в терминале должна вернуть номер версии. Если команда не найдена — добавьте ~/.local/bin в $PATH (pwntools сама об этом предупреждает при установке без sudo).

Отладчик: sudo apt-get install gdb. Голый GDB подходит для базовой работы, но плагин pwndbg (альтернатива — GEF) добавляет визуализацию стека, регистров и дизассемблера при каждой остановке. Для GDB отладки бинарников в контексте CTF это критически важно — без наглядного состояния стека понять поведение переполнения буфера на порядок сложнее. Я начинал с голого GDB и тратил кучу времени на x/40wx $rsp вручную. С pwndbg стек рисуется автоматически при каждом si.

Первая строка после импорта — настройка контекста: context(arch='amd64', os='linux'). Она задаёт целевую архитектуру и влияет на поведение всех функций: p64 будет паковать в 8 байт, cyclic — генерировать паттерны с 8-байтовыми подстроками, shellcraft — создавать код под x86-64. Без явного указания контекста pwntools попытается угадать архитектуру из загруженного ELF, но полагаться на автоматику — так себе идея.

Ещё одна полезная команда: pwn template ./binary > exploit.py. Генерирует шаблон скрипта с настроенным контекстом, переключением local/remote и заготовкой для payload. Экономит пару минут на каждой задаче и приучает к единообразной структуре.

Разбираем pwn-задачу: buffer overflow с ret2win

Классический сценарий бинарной эксплуатации: программа принимает пользовательский ввод через уязвимую функцию (gets или scanf без ограничения длины), а в коде спрятана функция win, которая выводит флаг. Задача — перезаписать адрес возврата на стеке так, чтобы вместо нормального завершения программа прыгнула в win. Этот паттерн называется ret2win и встречается в каждом втором задании для начинающих на PicoCTF, pwnable.kr и аналогичных платформах. Идеальная отправная точка, чтобы понять, как решать pwn-задачи на практике.

Разведка бинарника — checksec и анализ

Прежде чем писать эксплойт для CTF-задачи, нужно понять, с чем имеешь дело. Две команды дают базовую картину.

file ./vuln покажет архитектуру (ELF 64-bit LSB executable, x86-64), тип линковки (dynamically linked) и наличие символов (not stripped — имена функций доступны, что упрощает жизнь).

checksec ./vuln — утилита из комплекта pwntools — отображает защитные механизмы бинарника. На что смотреть:

NX (No eXecute) — если enabled, стек не исполняемый. Shellcode на стеке не сработает, нужны техники ret2win или ROP-цепочки.

Stack Canary — если found, между буфером и адресом возврата лежит «канарейка». Перезапись адреса возврата без предварительной утечки значения канарейки приведёт к аварийному завершению через __stack_chk_fail.

PIE (Position Independent Executable) — если enabled, базовый адрес бинарника рандомизируется при каждом запуске, адреса из таблицы символов будут относительными.

RELRO — уровень защиты GOT-таблицы: Partial или Full.

Для типичной задачи уровня «первый buffer overflow» конфигурация такая: NX enabled, No canary found, No PIE, Partial RELRO. Перевожу: shellcode на стеке не пойдёт, но адреса функций фиксированы и адрес возврата можно перезаписать напрямую. Почти подарок.

В pwntools-скрипте elf = ELF('./vuln') автоматически выведет checksec и распарсит таблицу символов. Адрес целевой функции — elf.symbols['win']. Никакого ручного ковыряния через objdump.

Поиск смещения через cyclic-паттерны

Ключевой вопрос при написании buffer overflow эксплойта: сколько байт нужно записать, чтобы добраться до адреса возврата? Ручной подход — тот самый перебор: 10 символов 'A', 20, 30, 50 — пока программа не упадёт с сегфолтом. Потом бинарным поиском уточняешь точный байт. Медленно, неточно, бесит.

Pwntools использует де-Брёйнову последовательность — специальный паттерн, в котором каждая подстрока из 4 (или 8 для amd64) байт уникальна. cyclic(200) генерирует 200 байт такого паттерна. Отправляешь его в программу — она падает, и в регистре RSP остаётся фрагмент паттерна. cyclic_find(value) по этому фрагменту мгновенно вычисляет точное смещение.

from pwn import *
context(arch='amd64')
p = process('./vuln')
p.sendline(cyclic(200))
p.wait()
# В pwndbg при краше: cyclic -l $rsp
# Допустим, RSP содержит 0x6161616c
offset = cyclic_find(0x6161616c)
log.success(f'Смещение: {offset}')

cyclic(200) создаёт 200-байтовый паттерн. После краша значение RSP смотрим через GDB с pwndbg (команда cyclic -l $rsp делает то же, что cyclic_find в Python). Если RSP содержит 0x6161616c — вызов cyclic_find(0x6161616c) вернёт, допустим, 40. Это значит: 40 байт заполняют буфер и сохранённый RBP, а начиная с 41-го байта идёт перезапись адреса возврата. На ручной подбор ушло бы десять-пятнадцать итераций. Cyclic-паттерн даёт точный ответ с первой попытки.

Собираем эксплойт на pwntools — построчный разбор

Смещение найдено (40 байт), адрес целевой функции доступен через elf.symbols['win']. Собираем рабочий buffer overflow эксплойт на Python:

from pwn import *
context(arch='amd64', os='linux')
elf = ELF('./vuln')
p = process('./vuln')
payload = b'A' * 40          # забиваем буфер + saved RBP
payload += p64(elf.symbols['win'])  # перезаписываем ret addr
p.sendline(payload)
p.interactive()

Построчно.

from pwn import * — импорт всей библиотеки pwntools. Для эксплойтов это стандартная практика, хотя в продакшн-коде так не делают.

context(arch='amd64', os='linux') — целевая архитектура. Влияет на p64 (будет паковать в 8 байт) и другие функции.

elf = ELF('./vuln') — загрузка бинарника. Парсит ELF-заголовки, секции, таблицу символов. Автоматически выводит checksec.

process('./vuln') — запуск локального процесса. Для удалённого сервера меняется на remote('host', port) — одна строка, остальной скрипт без изменений.

b'A' * 40 — padding до адреса возврата. Конкретный символ роли не играет, важна длина.

p64(elf.symbols['win']) — упаковка адреса функции win в 8 байт little-endian. Адрес 0x00401186 превратится в \x86\x11\x40\x00\x00\x00\x00\x00. Без pwntools пришлось бы вручную переворачивать каждый байт — и ошибаться на третьем.

sendline(payload) — отправка payload с символом новой строки.

interactive() — переключение в ручной режим. Если эксплойт сработал и win вывела флаг — он появится на экране. Если win открывает shell — можно вводить команды.

Восемь строк. От «не знаю смещение» до «флаг получен» — три минуты.

Написание эксплойта Python: ключевые функции pwntools

Ввод-вывод — process, remote и варианты send/recv

Pwntools абстрагирует взаимодействие с целью через единый интерфейс (объект tube). Две основные точки входа: process('./binary') — локальный запуск для отладки; remote('host', port) — подключение к серверу CTF. Оба поддерживают одинаковые методы, поэтому при переходе от локального тестирования к удалённой атаке меняется одна строка.

На практике переключение оформляют через проверку аргумента: запущен с REMOTE — подключаемся к серверу, иначе работаем локально. Шаблон pwn template генерирует этот код автоматически.

Методы отправки: send(data) — сырые байты без добавления чего-либо. sendline(data) — добавляет \n, аналог Enter. sendafter(delim, data) — ждёт, пока процесс выведет строку delim, и только потом отправляет. Незаменим, когда программа выводит промпт перед ожиданием ввода.

Методы приёма: recv(n) читает до n байт. recvline() — до символа новой строки. recvuntil(delim) — до указанного разделителя (используется чаще всего). recvall() — до закрытия соединения.

Типичный шаблон из writeup-ов PicoCTF (heartburn.dev): p.recvuntil(b'Enter name: ') дожидается промпта, затем p.sendline(payload) отправляет эксплойт. Этот паттерн работает в абсолютном большинстве pwn-задач.

Для отладки обмена данными: context.log_level = 'debug'. Pwntools будет выводить каждый отправленный и принятый байт. Когда скрипт «зависает» и непонятно почему — debug-лог обычно показывает, что программа ждёт ввода, а мы ждём вывода. Классическая взаимная блокировка.

Упаковка адресов — p64, p32 и little-endian

Архитектуры x86 и x86-64 хранят данные в little-endian: младший байт по младшему адресу. Число 0xDEADBEEF в памяти выглядит как \xef\xbe\xad\xde. Ручная побайтовая конвертация — источник ошибок, особенно под давлением таймера на CTF.

p32(value) — 4 байта little-endian для 32-битных бинарников. p64(value) — 8 байт для amd64, основная функция в современных задачах. Обратные операции: u32(data) и u64(data) — распаковка байтовой строки в число. Пригодятся при чтении утёкших адресов из вывода программы (leak libc base address для ret2libc).

Выбор между p32 и p64 определяется архитектурой бинарника. Ошибка тут — одна из самых частых у начинающих: p32 на 64-битном бинарнике генерирует 4-байтовый адрес вместо 8-байтового, payload получается короче нужного и смещения «плывут». Я сам на это наступал — потратил полчаса на отладку, пока не заметил, что context стоит i386 на amd64-бинарнике.

Для работы с таблицей символов ELF: elf.symbols['main'] — адрес main, elf.got['puts'] — запись в GOT, elf.plt['puts'] — адрес PLT-заглушки. Эти адреса критичны для продвинутых техник. Класс ROP автоматически находит гаджеты (ret, pop rdi; ret и другие) в бинарнике — основа для построения ROP-цепочек, когда простого ret2win недостаточно.

GDB отладка бинарников через pwntools

Когда эксплойт не срабатывает — а это случается чаще, чем хотелось бы — нужна отладка. Вместо process('./vuln') используйте gdb.debug('./vuln'). Откроется окно GDB, подключённое к запущенному процессу. Ставите breakpoint перед уязвимой функцией, пошагово выполняете инструкции, смотрите состояние стека и регистров в момент перезаписи.

Для автоматических breakpoints при запуске: gdb.debug('./vuln', 'break main\ncontinue'). Экономит время при итеративной отладке, когда нужно раз за разом останавливаться в одной точке и проверять payload. На третьей итерации без этого начинаешь сходить с ума.

Полезный приём — трассировка системных вызовов без полного реверса. Как отмечает welchbj в CTF-справочнике на GitHub, можно запустить процесс через strace: io = process(["strace", "-o", "trace.txt", "-f", "./vuln"]). Файл trace.txt покажет каждый syscall — какие файлы открывает программа, какие функции libc вызывает, где именно падает.

Плагин pwndbg добавляет команды для работы с cyclic-паттернами прямо в отладчике: cyclic 200 генерирует паттерн, cyclic -l $rsp находит смещение по значению регистра. Результат идентичен cyclic() и cyclic_find() в Python — два пути к одной цели.

При отладке удалённых задач GDB подключить напрямую нельзя. Схема двухэтапная: сначала отлаживаете эксплойт локально (через process и gdb.debug), потом переключаетесь на remote. Утилита pwninit (упоминается в The Pwner's Roadmap, izzy.sh) решает проблему несовпадения libc между локальной машиной и сервером — патчит бинарник для работы с серверной версией библиотеки.

Типичные ошибки при решении pwn-задач

Неправильная архитектура в context. Бинарник 32-битный, а context.arch стоит amd64p64 генерирует 8-байтовые адреса вместо 4-байтовых. Payload длиннее ожидаемого, всё ломается. Правило: всегда проверяйте архитектуру через file ./binary до написания скрипта.

Путаница между send и sendline. sendline добавляет \n — один дополнительный байт. Если payload рассчитан до байта (что типично для buffer overflow эксплойтов на Python), этот лишний символ сдвигает данные. Для точного контроля — send, а sendline только когда программа ожидает завершение ввода по Enter.

Несовпадение версий libc. Эксплойт с ret2libc работает локально (glibc 2.39), но падает на сервере (glibc 2.31). Адреса system, строки /bin/sh отличаются между версиями. Как подчёркивает The Pwner's Roadmap (izzy.sh), «это крупнейшая головная боль начинающих при переходе к удалённой эксплуатации». Решение — утилита pwninit: скачивает нужный линкер и патчит бинарник для работы с целевой libc.

Невыровненный стек на amd64. В 64-битных системах многие функции libc (включая system) требуют, чтобы RSP был выровнен на 16 байт. Нарушили выравнивание — процесс падает с SIGSEGV на инструкции movaps, хотя адрес возврата перезаписан корректно. В GDB видно правильный RIP, но функция крашится на первой SIMD-инструкции. Это ловушка, на которую наступают все. Решение: добавить гаджет ret перед адресом целевой функции — один ret сдвигает RSP на 8 байт и восстанавливает выравнивание.

EOFError при recvuntil. Процесс закрылся до того, как pwntools получил ожидаемую строку. Причины: payload некорректно завершил процесс, формат ввода не совпадает с ожидаемым, сработал таймаут. Включите context.log_level = 'debug' — обычно причина становится очевидной за минуту.

Null-byte в середине payload. Если целевой адрес содержит 0x00 внутри (не в конце), функции вроде gets и strcpy обрежут ввод на null-byte. Для простых ret2win это обычно не проблема (null-bytes идут в старших байтах, то есть в конце payload), но для ROP-цепочек с несколькими адресами может стать блокером. Тогда нужны partial overwrite или gadgets без null-byte.


Полгода назад я считал, что pwntools — «костыль для ленивых» и настоящий навык — собирать payload руками в hex-редакторе. На практике вышло наоборот: чем быстрее автоматизируешь рутину, тем больше времени на анализ самой уязвимости. В CTF побеждает не тот, кто медленнее всех набирает \x41\x41\x41, а тот, кто быстрее находит нестандартный баг и строит цепочку эксплуатации. Pwntools для начинающих — фундамент, без которого бинарная эксплуатация с нуля превращается в борьбу с инструментами вместо борьбы с задачей.

Но есть ловушка. Библиотека настолько хорошо прячет низкоуровневые детали, что возникает соблазн использовать её как чёрный ящик. Вызываешь p64, cyclic_find, получаешь флаг — но не понимаешь, почему payload работает. На простых ret2win это проходит. На heap exploitation, ASLR bypass или format string — уже нет, потому что каждая нестандартная ситуация требует понимания на уровне байтов. Совет: каждую новую технику в первый раз проходите руками — GDB, ручной расчёт, калькулятор. А pwntools подключайте на второй итерации, когда абстракции ложатся на реальное понимание. Если хочешь не просто writeup, а пройти всю атаку самому — на WAPT есть лаба с этим вектором плюс ментор в чате при затыке.

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

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

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

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

Читайте также

Race condition эксплуатация в CTF на практике

11 мин.

4

Race condition эксплуатация в CTF на практике

Разбор 3 типов race condition с эксплуатацией в Burp Suite и Turbo Intruder. Single-packet attack, TOCTOU-паттерны и реальные CVE — практика для CTF-игроков.

16 СЕНТЯБРЬ, 2026

Уязвимости JWT токенов в CTF: от alg none до RCE

13 мин.

6

Уязвимости JWT токенов в CTF: от alg none до RCE

Пошаговые PoC для 7 атак на JWT: alg none, brute force, algorithm confusion, kid injection. Команды jwt_tool и hashcat для решения CTF-задач.

16 СЕНТЯБРЬ, 2026

Format string уязвимость: от %p до shell

15 мин.

6

Format string уязвимость: от %p до shell

Разбор format string в CTF: поиск offset, утечка libc через %p, побайтовая запись через %hhn, GOT overwrite с pwntools — полная цепочка эксплуатации

15 СЕНТЯБРЬ, 2026