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

13 мин.00

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

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

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

На отборочных к DEF CON Quals наша команда потеряла 40 минут на pwn-таске за 100 очков — вводили payload руками через nc, ошиблись в одном непечатном байте, получили segfault вместо флага и откатились на три попытки назад. Скрипт на pwntools из 12 строк решил бы задачу с первой отправки. Автоматизация эксплоитов на Python через pwntools — тот навык, который разделяет «знаю теорию переполнения буфера» и «забрал флаг за три минуты». Разберём библиотеку от установки до рабочего pwn-скрипта, который подключается к CTF-серверу, отправляет payload и вытаскивает флаг — со всеми граблями, на которые я сам наступал.

Место pwntools в цепочке атаки и автоматизация эксплоита на Python

Если говорить в терминах MITRE ATT&CK, pwntools покрывает два этапа: Initial Access — эксплуатацию публичного сервиса (T1190) и Execution — запуск кода через Python (T1059.006). На CTF всё проще: организаторы дают адрес и порт уязвимого сервиса, задача — отправить такой ввод, который заставит программу сделать нужное (вывести флаг, дать shell, вызвать конкретную функцию).

Pwntools не живёт в вакууме. До него вы ковыряете бинарь в GDB с расширением pwndbg (или в IDA/Ghidra), находите уязвимость, вычисляете offset до целевой области на стеке. После — пишете скрипт, который автоматизирует эксплуатацию найденной дыры. Библиотека заменяет ручной ввод через netcat (nc) программным взаимодействием: каждый байт под контролем, каждая отправка воспроизводима, никакого risk-фактора copy-paste.

По документации на docs.pwntools.com, pwntools следует подходу «kitchen sink» — импорт from pwn import * подгружает сборку, разборку, упаковку, ROP-генерацию и десятки других модулей одной строкой. Для CTF — самое то: не нужно думать о зависимостях, когда до конца раунда осталось 20 минут.

[Применимо: CTF, лабораторные среды, тестовые стенды. В продуктивных пентестах — только для проверки PoC на согласованных с заказчиком сервисах.]

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

Требования к окружению

  • ОС: 64-bit Ubuntu 22.04/24.04 LTS или Kali Linux (рекомендуется). Debian и Arch — поддерживаются. macOS работает частично: модули asm/disasm для не-x86 архитектур требуют кросс-компиляторов (brew install cmake и brew install pkg-config)
  • Python: 3.8+ для pwntools 4.15.0 (текущая стабильная на PyPI). Сама библиотека лёгкая, но GDB + отлаживаемый процесс + Python-интерпретатор в сумме съедают 1–2 ГБ
  • Системные зависимости: python3-dev, git, libssl-dev, libffi-dev, build-essential

Установка в два шага. Сначала системные зависимости: sudo apt-get update && sudo apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential. Затем сам pwntools: python3 -m pip install --upgrade pwntools. Документация на docs.pwntools.com/en/stable/install.html подтверждает — этого хватает для большинства функций.

После установки проверьте утилиту pwn — наберите pwn version в терминале. Если видите предупреждение, что скрипты установлены в ~/.local/bin и путь не в $PATH — добавьте строку export PATH="$HOME/.local/bin:$PATH" в ~/.bashrc и перезагрузите сессию.

Шаблон эксплоита генерируется командой pwn template ./binary_name > exploit.py. На выходе — скелет скрипта с импортами, настройками контекста, подключением к процессу или серверу и заглушкой для payload. На первый взгляд шаблон кажется избыточным (там аргументы командной строки, GDB-подключение, обработка SSH), но на соревнованиях он экономит драгоценные минуты.

Дополнительно ставьте GDB и pwndbg (sudo apt-get install gdb + клонирование pwndbg с GitHub). Pwntools умеет автоматически подключать GDB к запущенному процессу через gdb.attach(p) — без этого отладка эксплоитов перед отправкой на сервер превращается в боль.

Tube-объекты pwntools: взаимодействие с сервером через Python вместо netcat

Tube (труба) — центральная абстракция pwntools для ввода-вывода. Каждый канал связи — с локальным процессом, с удалённым TCP-сервером, через SSH — реализован как объект с единым интерфейсом. Вот в чём главное преимущество перед ручным netcat: вместо nc challenge.ctf.com 1337 и клавиатурного ввода вы программно контролируете каждый байт.

Три типа tube-объектов, каждый под свой сценарий:

remote(host, port) — TCP-подключение к удалённому серверу. Прямая замена netcat в CTF. Вызов conn = remote('challenge.ctf.com', 1337) устанавливает TCP-соединение и возвращает tube, через который идёт весь обмен данными. Организаторы дают адрес и порт — вы подключаетесь скриптом. Именно этот тип tube используется для pwntools connect remote к CTF-серверу.

process('./binary') — запуск локального бинаря как дочернего процесса. Главный инструмент отладки: тестируете эксплоит на своей машине, убеждаетесь что payload работает, и только потом переключаетесь на remote(). Вызов p = process('./vuln') запускает бинарь и даёт tube для взаимодействия с его stdin/stdout.

listen(port) — серверный сокет. Нужен для reverse shell: l = listen(4444) открывает порт 4444 и ждёт входящее подключение. Метод l.wait_for_connection() блокируется до получения соединения.

Переключение между локальной отладкой и атакой на сервер — замена одной строки. В шаблоне pwn template это реализовано через аргумент командной строки: python3 exploit.py REMOTE challenge.ctf.com 1337 использует remote(), запуск без аргументов — process(). Спасает от классической ситуации «у меня работает, на сервере — нет», потому что можно быстро сравнить поведение.

Все три типа tube наследуют один интерфейс из pwnlib.tubes — методы send(), recv(), interactive() работают одинаково, общаетесь ли вы с локальным процессом или с удалённым сервером. Многие туториалы это упускают, а зря: вам не нужно переписывать логику эксплоита при смене target.

Отправка и приём данных в pwn CTF Python-скриптах

Tube-объект даёт набор методов для передачи данных. Различие между ними принципиально, и путаница здесь — источник большинства ошибок у новичков.

Методы отправки:

send(data) — отправляет байты как есть, без добавления чего-либо. Используйте, когда payload должен быть побайтово точным — при перезаписи адреса возврата каждый лишний байт ломает эксплоит.

sendline(data) — отправляет байты плюс символ новой строки \n. Аналог нажатия Enter в терминале. В большинстве CTF-тасков сервер ожидает ввод с переводом строки — используйте sendline() по умолчанию.

sendafter(delim, data) — ждёт появления строки delim в ответе сервера, затем отправляет data. Удобно при многоступенчатом диалоге: сервер выводит Enter your name:, скрипт ждёт и только потом отвечает.

sendlineafter(delim, data) — комбинация: ждёт delim, отправляет data + \n. Рабочая лошадка — покрывает 90% сценариев взаимодействия с CTF-серверами.

Методы приёма:

recv(n) — принимает до n байт. Не гарантирует ровно n — может вернуть меньше, если столько данных есть в буфере.

recvline() — принимает данные до символа \n включительно. Одна логическая строка вывода сервера.

recvuntil(delim) — принимает данные до появления строки delim. Рабочая лошадка для парсинга: conn.recvuntil(b'flag{') дождётся начала флага в выводе. Параметр drop=True убирает сам разделитель из результата.

recvall() — принимает всё до закрытия соединения. Полезно в конце эксплоита — забираете весь оставшийся вывод, включая флаг.

interactive() — переключает tube в интерактивный режим. Скрипт «отпускает руль», и вы работаете напрямую с сервером, как через netcat. Типичный сценарий: отправили payload, получили shell, переключились в interactive() и выполняете команды руками.

Все методы принимают и возвращают байтовые строки (bytes), не текстовые (str). Это принципиальный момент — conn.send(b'AAAA'), не conn.send('AAAA'). Почему это так важно — в разделе об ошибках.

Упаковка чисел и настройка контекста архитектуры

При эксплуатации переполнения буфера нужно записать конкретный адрес в память. Адрес 0xdeadbeef на x86 (little-endian) хранится как \xef\xbe\xad\xde — байты идут в обратном порядке. Переворачивать каждый адрес вручную — верный путь к ошибке, для этого в pwntools есть функции упаковки из модуля pwnlib.util.packing.

p32(0xdeadbeef) возвращает b'\xef\xbe\xad\xde' — упакованное 32-битное значение в little-endian. p64() делает то же для 64-битных адресов. Обратные функции u32() и u64() распаковывают байты в число: u32(b'\xef\xbe\xad\xde') вернёт 0xdeadbeef. По сути p32(0xdeadbeef) == struct.pack('I', 0xdeadbeef) — pwntools просто избавляет от необходимости помнить format-коды модуля struct.

Поведение функций упаковки зависит от глобального объекта context. Он задаёт архитектуру, ОС и порядок байт для всего скрипта:

  • context.arch = 'amd64' — 64-битная архитектура, обобщённая pack() выберет 64 бита
  • context.arch = 'i386' — 32-битная, обобщённая pack() выберет 32 бита

Нюанс, на котором спотыкаются: p32()/p64() имеют фиксированную разрядность и не зависят от context.arch — из контекста они берут только context.endian для порядка байт. А вот обобщённые pack()/unpack() (без суффикса разрядности) определяют ширину именно по context.arch.

  • context.endian = 'little' — little-endian (по умолчанию для x86/x86-64)
  • context.os = 'linux' — целевая ОС
  • context.log_level = 'debug' — включает hex-дамп всех отправленных и полученных данных. Первый инструмент диагностики — видно каждый байт обмена

Сокращённая запись: context(arch='amd64', os='linux') ставит всё одной строкой. Можно задать архитектуру автоматически из ELF-файла: context.binary = ELF('./vuln') — pwntools сам определит разрядность, endianness и ОС. Я всегда так делаю — меньше шансов ошибиться.

Пишем первый Python-скрипт для CTF: от подключения до флага

Типовой pwn-таск. Сервер запускает уязвимый бинарь: программа читает ввод через функцию без ограничения длины (вроде gets()), и если перетереть переменную на стеке определённым значением — программа выдаёт флаг. Offset до целевой переменной — 28 байт (20 байт буфер + 8 байт padding от компилятора), нужно записать значение 0x1337 в следующие 4 байта.

Для определения offset используется cyclic() из pwnlib.util.cyclic — генератор уникальных последовательностей. Вызов cyclic(200) создаёт строку b'aaaabaaacaaadaaa...', где каждые 4 байта уникальны. Отправляете эту строку в программу — она падает, в GDB видите значение, попавшее в нужный регистр (например, 0x61616168). Вызов cyclic_find(0x61616168) возвращает точный offset — в нашем случае 28. Без cyclic() пришлось бы тупо считать позиции в паттерне на глаз.

from pwn import *

context(arch='i386', os='linux', log_level='info')

# conn = process('./vuln')          # локальная отладка
conn = remote('challenge.ctf.com', 1337)  # CTF-сервер

conn.recvuntil(b'Enter input: ')   # ждём приглашение сервера
payload = b'A' * 28               # заполняем буфер + padding
payload += p32(0x1337)             # перетираем целевую переменную
conn.sendline(payload)             # отправляем payload
print(conn.recvall().decode())     # читаем ответ с флагом

Построчно: from pwn import * подключает все модули — так рекомендует документация. Контекст задаёт 32-битную архитектуру и уровень логирования info (статусные сообщения без hex-дампов). recvuntil(b'Enter input: ') синхронизирует скрипт с сервером — без этой строки sendline() может уйти раньше, чем сервер готов принять данные. Payload собирается конкатенацией: 28 байт заполнителя + упакованное значение 0x1337. Функция p32() автоматически конвертирует число в 4 байта little-endian. recvall() забирает весь оставшийся вывод, включая строку с флагом.

Для переключения между локальной отладкой и атакой на сервер — меняете remote() на process(), логика payload остаётся идентичной.

Работа с ELF: автоматизация поиска адресов для pwn-скриптов

На реальных CTF-тасках уровнем выше простого перетирания переменной нужно вставить в payload адрес конкретной функции — например, win() для ret2win атаки. Жёстко прописывать адрес в скрипт — плохая практика: при перекомпиляции бинаря адрес поплывёт.

Pwntools решает это через класс ELF из pwnlib.elf.elf. Вызов e = ELF('./vuln') загружает бинарь и даёт доступ к таблицам символов. e.symbols['win'] возвращает адрес функции win. e.got['puts'] — адрес puts в GOT (Global Offset Table), e.plt['puts'] — адрес в PLT. Вызов e.checksec() показывает активные защиты бинаря: NX (запрет исполнения на стеке), Stack Canary, PIE (рандомизация базы), RELRO. В интерактивной сессии Python результат отображается сразу; в скрипте используйте print(e.checksec()). Проверка защит — первое, что стоит сделать перед написанием эксплоита: от набора активных защит зависит выбор техники атаки.

При включённом PIE (Position Independent Executable) базовый адрес рандомизируется при каждом запуске. В этом случае e.symbols['win'] даёт offset относительно базы, а не абсолютный адрес. Для эксплуатации потребуется information leak — утечка адреса из адресного пространства процесса, чтобы вычислить реальную базу. И вот тут начинается настоящий pwn.

В коде это выглядит так: e = ELF('./vuln'), затем в payload вместо захардкоженного адреса — p64(e.symbols['win']). Если нужна работа с libc — libc = ELF('./libc.so.6'), затем libc.symbols['system'] для адреса system() и next(libc.search(b'/bin/sh')) для адреса строки /bin/sh (пригодится в ret2libc атаках). Pwntools сам парсит ELF-заголовки — ни одного ручного вычисления.

Типичные ошибки новичков в pwn CTF Python-скриптах

Байты вместо строк. Ошибка номер один: conn.send('AAAA') вместо conn.send(b'AAAA'). Pwntools 4.x работает с типом bytes. Передача текстовой строки вызывает TypeError или, что хуже, отправляет данные в UTF-8, где многобайтовые символы ломают структуру payload. Правило без исключений: все данные для send()/sendline() — только b'...' или результат p32()/p64().

Зависание на таймаутах. По умолчанию recv() использует context.timeout (равен Timeout.forever, то есть -1 — «без ограничения»), поэтому при отсутствии данных вызов блокируется навечно. Сервер ничего не отправляет (ждёт ещё порцию ввода) — скрипт висит, и непонятно почему. Решение: всегда передавайте явный timeout — conn.recvline(timeout=5) вернёт пустые байты b'' через 5 секунд, если данных нет. Глобальный таймаут: context.timeout = 10.

Лишний перевод строки. sendline() автоматически добавляет \n (байт 0x0a). Если payload уже содержит завершающий символ, sendline() добавит второй. При перезаписи адреса на стеке лишний 0x0a попадает в старший байт адреса и ломает его. Используйте send() вместо sendline(), когда контролируете каждый байт payload. На этом я сам горел не раз.

Отсутствие синхронизации. Отправка payload без предварительного recvuntil() — классическая причина «работает локально, не работает на сервере». Удалённый сервер может не успеть вывести приглашение из-за сетевой задержки. Всегда ждите конкретную строку от сервера перед отправкой данных.

Неправильная архитектура в контексте. p32() на 64-битном бинаре упакует адрес в 4 байта вместо 8. Адрес обрезается — эксплоит молча не работает. Правило: запустите checksec на бинаре, определите архитектуру и выставьте context.arch в первых строках скрипта.

# Включаем debug — видим каждый отправленный байт
context.log_level = 'debug'
conn = remote('challenge.ctf.com', 1337)
conn.recvuntil(b'> ', timeout=5)
conn.sendline(b'A' * 100)
# Hex-дамп покажет что реально ушло и что пришло

С log_level='debug' pwntools выводит hex-дамп всех отправленных и полученных пакетов. В 80% случаев, когда эксплоит не работает, проблема видна в дампе: лишний \n, обрезанный адрес, неверный порядок байт, неожиданный ответ сервера. Это первое, что стоит включить при отладке.

Ограничения pwntools и когда инструмент не подходит

Windows-бинари. Библиотека заточена под Linux и ELF-формат. Модули asm, shellcraft, rop не поддерживают PE-файлы. Для Windows pwn используйте только tube-функции (TCP-подключение), а генерацию payload ведите другими средствами.

Web-эксплуатация. Для HTTP-запросов, работы с cookies, CSRF-токенами, WebSocket pwntools не годится. Модуль pwnlib.tubes работает на уровне сырого TCP-потока. Для веб-тасков берите requests + beautifulsoup4 или Burp Suite.

Производительность. Python — не самый быстрый язык. Для brute-force задач с миллионами итераций (подбор Stack Canary побайтово, ASLR brute-force на 32-битной системе) скрипт на C или Go будет на порядок быстрее. Pwntools тут работает, но медленно — будьте готовы ждать.

Протоколы выше TCP. Tube-объект оперирует потоком байтов. Если CTF-таск требует TLS-подключения, HTTP/2 или UDP — потребуются дополнительные обёртки поверх стандартных tube.

Пространство имён. Импорт from pwn import * загружает сотни функций в глобальное пространство. Для одноразового CTF-скрипта — удобно. Для чего-то долгоживущего — проблема. В таком случае импортируйте конкретные модули:

# Чистый импорт для долгоживущего кода
from pwnlib.tubes.remote import remote
from pwnlib.util.packing import p64, u64
conn = remote('challenge.ctf.com', 1337)
conn.sendline(b'A' * 40 + p64(0xdeadbeef))
print(conn.recvall())

Pwntools — узкоспециализированный инструмент для binary exploitation и CTF pwn. За пределами этой ниши (web, forensics, crypto-категории CTF) от него мало толку. Но в своей нише ему замены нет.

Pwntools решает одну задачу — автоматизацию побайтового обмена с уязвимым сервисом — и решает её надёжно. Я вижу, как новички тратят часы на ручной ввод через netcat, борются с непечатными символами, теряют payload в буфере обмена. Каждый раз хочется сказать: 12 строк на Python. Но есть деталь, которую упускают почти все русскоязычные туториалы: pwntools не учит находить уязвимость. Он автоматизирует эксплуатацию уже найденной. Без понимания того, как устроен стек вызовов, что делает gets() в отличие от fgets(), почему little-endian переворачивает порядок байт — скрипт остаётся магическим заклинанием. Работает, пока таск совпадает с туториалом. Стоит добавить canary или включить PIE — магия рассыпается, и человек не понимает почему.

Мой подход: разберите хотя бы один таск полностью руками. Через GDB, командой x/20x $rsp, с ручным подсчётом байт на стеке. Поймите, что происходит физически, потом автоматизируйте. Если хочешь не просто writeup пройти, а системно прокачать pwn от простого BOF до ROP-цепочек — на WAPT есть модули с лабами именно по этой прогрессии, с ментором при затыке.

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

Поделиться

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

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

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