Главная / Блог / Pwntools туториал: первый эксплойт для CTF pwn-задачи с нуля

14 мин.00

Pwntools туториал: первый эксплойт для CTF pwn-задачи с нуля

Pwntools туториал: первый эксплойт для CTF pwn-задачи с нуля

Pwntools туториал: первый эксплойт для CTF pwn-задачи с нуля

На недавнем командном CTF задача категории pwn выглядела типично: 32-битный ELF без канарейки, переполнение буфера через gets(), функция win() по фиксированному адресу. Трое из команды убили два часа, собирая пейлоад вручную через struct.pack и отлаживая порядок байт. Четвёртый написал семь строк на pwntools — и забрал флаг за десять минут. Все четверо понимали, что такое stack buffer overflow. Разница — в инструменте.

Этот pwntools туториал проведёт от установки до работающего ret2win-эксплойта. Каждое решение — с разбором, каждый шаг — с объяснением, почему именно так.

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

Pwntools — библиотека под Linux. На Windows она работает только через WSL2 с Ubuntu, и это не каприз разработчиков — под капотом куча зависимостей, завязанных на POSIX. Подробнее — в нашем статье о бинарный анализ уязвимостей.

  • ОС: Ubuntu 20.04+ / Kali Linux 2023+ / Debian 11+ (или WSL2 с Ubuntu)
  • Python: 3.8 и выше
  • RAM: 2 ГБ (CTF-задачи на бинарную эксплуатацию не требуют мощного железа)
  • Диск: ~500 МБ на pwntools со всеми зависимостями
  • Сеть: онлайн для установки, далее работа полностью офлайн

Ставится через python3 -m pip install --upgrade pwntools. После установки в системе появляется утилита pwn — через неё генерируются шаблоны эксплойтов командой pwn template ./vuln, а checksec ./vuln проверяет защиты бинаря прямо из терминала. Удобно — не нужно лезть в Python, чтобы быстро глянуть, с чем имеешь дело.

Для отладки нужен GDB с плагином pwndbg: sudo apt install gdb, затем установка pwndbg по инструкции из его репозитория на GitHub. Можно взять peda, но pwndbg удобнее интегрируется с pwntools: функции cyclic и cyclic_find работают в обоих, но вывод pwndbg проще парсить глазами.

Pwntools — активно поддерживаемый проект: текущая стабильная версия 4.15.0, в dev-ветке идёт работа над 5.0.0. Репозиторий Gallopsled/pwntools на GitHub обновляется регулярно. В документации проект описан как «CTF framework and exploit development library» — и это честное описание, без натяжки.

Разведка бинаря: что покажет checksec и зачем это нужно

Каждая pwn-задача CTF начинается с разведки. Не с запуска бинаря, не с чтения исходников — с checksec. Утилита из пакета pwntools показывает, какие защиты компилятор и ОС наложили на исполняемый файл. Запуск: checksec ./vuln в терминале.

Типичный вывод для учебной задачи по бинарной эксплуатации:

  • Arch: i386-32-little — 32-битный ELF, порядок байт little-endian. Адреса упаковываются через p32(), а не p64()
  • RELRO: Partial RELRO — GOT-таблица доступна для перезаписи. Для ret2win не критично, но для продвинутых атак типа GOT overwrite — подарок
  • Stack: No canary found — стековая канарейка отсутствует. Переполняй сколько хочешь, программа не заметит
  • NX: NX enabled — стек помечен как неисполняемый. Шеллкод, записанный в стек, не запустится — нужен ret2win или ROP-цепочка
  • PIE: No PIE — адреса в бинаре фиксированные, ASLR не рандомизирует базу самого файла. Адрес win-функции можно хардкодить

Этот набор (нет канарейки, нет PIE, NX включён) — классика entry-level CTF задач по категории pwn. Бинарь буквально говорит: «Переполни буфер и перенаправь исполнение на существующую функцию». В блоге Arch Cloud Labs разобран аналогичный кейс с Virginia Tech Summit CTF: 32-битный ELF с fgets на 0x400 байт в буфер на 16 байт, без канарейки, с NX — решение сводилось к ret2win.

Когда все защиты включены (canary + PIE + NX + Full RELRO), задача усложняется на порядок: нужна утечка канарейки через format string, утечка базы бинаря для обхода PIE и ROP-цепочка для обхода NX. Но для первого buffer overflow эксплойта базовый набор — правильная стартовая точка.

В pwntools python-скрипте анализ бинаря делается через класс ELF: вызов elf = ELF('./vuln') автоматически запускает checksec и выводит результат в консоль. Из объекта elf потом достаёшь адреса функций через elf.symbols['win'], адреса GOT-записей через elf.got['puts'], PLT-записей через elf.plt['puts'] и секции BSS через elf.bss(). По Getting Started в документации pwntools, ELF избавляет от ручного ковыряния через objdump, readelf или IDA — всё доступно как атрибуты объекта.

Находим смещение через cyclic: автоматизация вместо ручного подбора

Смещение (offset) — количество байт от начала буфера до адреса возврата на стеке. Без правильного смещения эксплойт не работает: пейлоад либо не дотянется до return address, либо перезапишет не те байты.

Классический подход — отправить в бинарь строку AAAABBBBCCCC... и смотреть, какие четыре байта оказались в регистре EIP при крэше. Для буферов в 20-30 байт это терпимо, но при размере 200+ ручной подсчёт превращается в мучение. Pwntools решает это через cyclic patterns: уникальные последовательности, в которых любые четыре подряд идущих байта встречаются ровно один раз.

Генерация из командной строки: cyclic 200 создаёт паттерн длиной 200 символов — aaaabaaacaaadaaaeaaa.... Каждая четвёрка байт уникальна, и по значению в EIP при крэше точно определяешь смещение.

Процесс нахождения offset для stack buffer overflow:

  1. Генерируем паттерн и подаём на вход: cyclic 200 | ./vuln
  2. Программа падает с SIGSEGV. В pwndbg значение EIP отображается автоматически — допустим, 0x61616167
  3. Вычисляем смещение: cyclic -l 0x61616167 возвращает 24
  4. Проверяем: отправляем 24 байта мусора + 0xdeadbeef и убеждаемся, что EIP = 0xdeadbeef

Для 64-битного бинаря процесс аналогичен, но с нюансами: используется cyclic -n 8 (группы по 8 байт вместо 4), а смещение ищется по значению регистра RSP или RIP. В Python-скрипте то же самое: cyclic(200) генерирует паттерн, cyclic_find(0x61616167) возвращает смещение. Функция cyclic_find принимает и числа, и байтовые строки — cyclic_find(b'gaaa') даст тот же результат.

Частая ошибка при работе с cyclic — забывают про выравнивание стека в x86-64. На 64-битной архитектуре system() и некоторые другие libc-функции требуют 16-байтового выравнивания RSP перед вызовом. Бывает так: смещение правильное, EIP указывает на win-функцию, а программа всё равно падает с SIGSEGV — но уже внутри system(). Проблема в alignment. Решение: добавить перед адресом win-функции адрес инструкции ret (один гаджет), которая сдвинет RSP на 8 байт и восстановит выравнивание. Мелочь, но на ней спотыкается каждый второй новичок.

Пишем ret2win эксплойт на pwntools: от нуля до флага

Ret2win — базовый тип pwn CTF задачи: в бинаре есть функция (обычно win, flag или print_flag), которая выводит флаг или открывает шелл. Эта функция никогда не вызывается в нормальном потоке — задача атакующего перенаправить на неё управление через переполнение буфера.

Допустим, разведка дала: 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»: один импорт — и у тебя сборка, упаковка, работа с процессами и сетью. Кто-то скажет «плохая практика», но для эксплойтов, которые живут один CTF, — в самый раз.

context.binary = elf = ELF('./vuln') — загружает бинарь и автоматически выставляет архитектуру (context.arch), порядок байт (context.endian) и разрядность (context.bits). Одна строка вместо четырёх ручных настроек через context.update(arch='i386', os='linux').

flat(cyclic(offset), elf.symbols['win']) — собирает пейлоад: 28 байт мусора (через cyclic) и адрес win-функции. flat() принимает произвольное количество аргументов — байтовые строки и числа — автоматически упаковывает числа через p32/p64 (в зависимости от context.bits) и склеивает в одну байтовую строку. Без неё пришлось бы писать cyclic(28) + p32(elf.symbols['win']) — работает, но flat() чище.

process() — запускает бинарь локально. Когда context.binary установлен, путь указывать не нужно.

sendlineafter(b':', payload) — ждёт символ : в выводе программы, затем отправляет пейлоад с \n. Надёжнее, чем голый sendline(), потому что гарантирует синхронизацию: скрипт не отправит данные до того, как программа будет готова их принять. Без этого на медленных машинах эксплойт может отработать через раз.

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

Переключение на удалённый сервер CTF: заменяем p = process() на p = remote('ctf.example.com', 1337). Весь остальной код — без изменений. Интерфейс tubes в pwntools одинаков для локальных процессов и сетевых соединений. Эксплойт, отлаженный локально, работает на удалённом сервере без правок. Это, пожалуй, главное, за что я ценю pwntools — не нужно переписывать сокеты при переходе от локальной отладки к боевому серверу.

Ключевые функции pwntools для автоматизации эксплойтов

Tubes: как отправлять и принимать данные

Модуль pwnlib.tubes — ядро взаимодействия с целевым процессом. Все типы подключений реализуют единый интерфейс: пишешь эксплойт один раз, запускаешь против локального бинаря, TCP-сервера или SSH-хоста.

Основные методы отправки: send(data) отправляет байты без символа новой строки, sendline(data) добавляет \n, sendafter(delim, data) ждёт определённую строку в выводе перед отправкой. Основные методы приёма: recv(n) принимает до n байт, recvline() читает до \n, recvuntil(delim) читает до появления разделителя, recvall() забирает всё до закрытия соединения.

Частая ошибка новичков — recv() без аргументов, который зависает в ожидании. Правило простое: если программа выводит конкретную строку перед вводом — recvuntil(). Если формат вывода неизвестен — recv(timeout=2) с явным таймаутом. Иначе скрипт повиснет, и ты будешь полчаса искать баг в эксплойте, хотя баг — в способе чтения.

Второй подводный камень: sendline() добавляет \n, что увеличивает длину пейлоада на один байт. Если пейлоад должен быть ровно определённой длины без хвоста — send() вместо sendline().

Типы соединений: process('./vuln') для локального процесса, remote('host', port) для TCP, ssh('user', 'host', password='pass') для SSH. SSH-модуль полезен для площадок типа OverTheWire Bandit, где эксплуатируемый бинарь доступен только через SSH — shell = ssh(...) позволяет запускать процессы на удалённой машине через shell.process('./vuln').

Упаковка адресов: p32, p64 и порядок байт

Адрес 0x08049216 в памяти x86 хранится как \x16\x92\x04\x08 — в обратном порядке байт (little-endian). Ручная упаковка через struct.pack('<I', 0x08049216) работает, но засоряет код. p32(0x08049216) — результат идентичен, но читается без мысленного парсинга форматной строки.

Набор функций упаковки: p32() для 32-битных значений, p64() для 64-битных, p16() и p8() для 16 и 8 бит. Обратные функции распаковки: u32(data) превращает четыре байта обратно в число, u64(data) — восемь байт. p32(0xdeadbeef) полностью эквивалентен struct.pack('I', 0xdeadbeef), но не нужно помнить, что <I — это unsigned int в little-endian.

flat() объединяет упаковку и конкатенацию: принимает смесь байтовых строк и чисел, автоматически упаковывает числа по context.bits и склеивает в одну строку. flat(b'AAAA', 0x08049216, b'\x00' * 8) — и никаких явных вызовов p32.

Для отладки пейлоада — hexdump(). Вызов print(hexdump(payload)) покажет, как устроен пейлоад побайтово. Когда что-то не работает, первым делом смотришь hex-дамп — в 80% случаев ошибка видна сразу.

ELF: парсинг бинаря без статического анализатора

Класс ELF (сигнатура: ELF(path, checksec=True)) разбирает исполняемый файл и даёт программный доступ к его внутренностям:

  • elf.symbols['win'] — адрес символа (если бинарь не stripped)
  • elf.got['puts'] — запись в GOT (для GOT overwrite и утечек адресов libc)
  • elf.plt['puts'] — PLT-запись (для ret2plt: вызов puts@plt с аргументом got['puts'] утекает реальный адрес puts в libc)
  • elf.address — базовый адрес загрузки (для PIE-бинарей можно переустановить после утечки базы)

Для задач с подгруженными библиотеками: libc = ELF('./libc.so.6') разбирает libc, а libc.symbols['system'] даёт смещение system() относительно базы. В связке с утечкой адреса puts через PLT/GOT это позволяет вычислить реальный адрес system в рантайме — основа ret2libc-эксплойтов. Без ELF пришлось бы вручную парсить вывод readelf -s и вбивать адреса руками. Работает, но медленно и ошибкоёмко.

Отладка из эксплойт-скрипта: GDB и pwntools вместе

Самая недооценённая возможность pwntools — запуск GDB прямо из Python-скрипта. gdb.debug() запускает процесс сразу под отладчиком, gdb.attach() подключается к уже работающему процессу. Для работы нужен мультиплексор терминала — tmux или screen. Без него pwntools не откроет окно GDB. Порядок: сначала tmux в терминале, затем python3 exploit.py.

from pwn import *
context.binary = elf = ELF('./vuln')
p = gdb.debug(elf.path, '''
break *main+42
continue
''')
payload = flat(cyclic(28), elf.symbols['win'])
p.sendlineafter(b':', payload)
p.interactive()

gdb.debug() запускает бинарь под GDB и выполняет переданный скрипт — ставит брейкпоинт и продолжает исполнение. Второй аргумент — обычный GDB-скрипт в виде строки. Можно ставить брейкпоинты на конкретные адреса, смотреть память через x/20wx $esp, менять регистры на лету.

Разница между двумя функциями: gdb.debug() запускает процесс сразу под GDB — для пошаговой отладки с самого начала. gdb.attach(p) присоединяется к процессу, который уже создан через process() — для отладки конкретного момента, когда нужно поставить брейкпоинт перед ret и посмотреть состояние стека.

Типичный workflow: ставишь брейкпоинт перед инструкцией ret в уязвимой функции, отправляешь пейлоад, в окне GDB проверяешь — легли ли байты адреса win-функции ровно на позицию return address. Если вместо 0x08049216 в EIP видишь 0x41414141 — смещение неверное, пересчитывай через cyclic_find.

Нюанс из курса CS6265 Georgia Tech: gdb.attach() не работает с setuid-бинарями из-за политик безопасности ядра Linux. Для отладки таких задач бинарь копируют в свою директорию и снимают setuid-бит через chmod u-s ./vuln.

Для быстрого переключения между режимами отладки и боевого запуска — context.log_level = 'debug'. Все отправленные и полученные данные выводятся в терминал. Видишь, что именно уходит в процесс, без полноценного подключения GDB. На практике этого часто хватает, чтобы понять, почему эксплойт не срабатывает.

Ограничения pwntools и место в цепочке атаки

Где pwntools в kill chain

В терминологии MITRE ATT&CK pwntools задействуется на нескольких этапах. Разработка эксплойтов (Exploits, T1587.004, Resource Development) — написание и отладка PoC. Исполнение через Python (Python, T1059.006, Execution) — запуск эксплойт-скрипта. Эксплуатация уязвимости (Exploitation for Client Execution, T1203 или Exploitation for Privilege Escalation, T1068) — момент захвата контроля.

В CTF-контексте цепочка: разведка (checksec, реверс в Ghidra/radare2) → поиск уязвимости (buffer overflow, format string) → разработка эксплойта (pwntools) → эксплуатация (запуск скрипта) → флаг. Pwntools покрывает третий и четвёртый этапы. Для второго нужен реверсер (Ghidra, IDA, radare2 — последний на версии 6.1.7 активно поддерживается), для первого — checksec и file.

Когда pwntools не поможет

  • Реверс-инжиниринг — для анализа логики бинаря нужен декомпилятор. Pwntools умеет дизассемблировать через disasm() и ассемблировать через asm(), но полноценного декомпилятора не содержит. Ghidra или IDA — без вариантов
  • Heap exploitation — для задач на heap (use-after-free, tcache poisoning) pwntools даёт только транспорт. Логику атаки на аллокатор строишь сам, и тут нужно реально понимать, как работает glibc malloc
  • Kernel pwn — pwntools ориентирован на userspace. Для ядра — другие подходы и другие инструменты
  • Windows PE — библиотека заточена под Linux ELF. Для PE-файлов возможности минимальны, и это осознанный выбор разработчиков
  • Боевые среды с EDR — pwntools не предназначен для evasion. Это инструмент для CTF и исследований, не для обхода CrowdStrike Falcon или SentinelOne. Если вы пытаетесь использовать pwntools в обход EDR — вы используете не тот инструмент
Критерий Чистый Python + struct Pwntools
Упаковка адресов struct.pack('<I', addr) — нужно помнить формат p32(addr) — один вызов
Работа с процессом subprocess + ручной stdin/stdout process() с единым API
Удалённое подключение socket вручную remote() с теми же методами
Поиск смещения Ручной паттерн + подсчёт cyclic() + cyclic_find()
Парсинг ELF readelf / objdump вручную ELF() с доступом к символам
GDB-интеграция Отсутствует gdb.attach() / gdb.debug()
Генерация шеллкода Копировать из интернета shellcraft + asm()

Pwntools не делает ничего магического — автоматизирует рутину. На CTF, где время ограничено четырьмя-восемью часами, эта автоматизация — разница между решённой задачей и пропущенной.

За полтора года решения CTF задач по категории pwn я пришёл к неудобному выводу: pwntools — одновременно лучший друг и худший враг новичка в бинарной эксплуатации. Друг — потому что снимает слой рутины и позволяет сосредоточиться на уязвимости. Враг — потому что создаёт иллюзию понимания.

Я видел десятки writeup'ов, где автор копирует шаблон from pwn import *, подставляет смещение из чужого решения — и получает флаг. Формально задача решена. Фактически человек не понимает, почему p32() переворачивает байты, зачем sendlineafter вместо sendline и что произойдёт, если компилятор добавит выравнивание между переменными на стеке.

Первая нестандартная задача — где буфер не прилегает к return address напрямую, где нужно учесть alignment RSP в x86-64, где канарейка утекает через format string — и всё рассыпается.

Мой подход: каждый новый приём стоит один раз сделать вручную. Собрать пейлоад через struct.pack, руками посчитать смещение в GDB, отправить через сокет без обёрток. Потом перейти на pwntools — и осознать, какую именно работу он берёт на себя. Это занимает лишний час на первых пяти задачах, но экономит десятки часов на следующих пятидесяти.

Тренд CTF-движения идёт в сторону усложнения: полный RELRO, seccomp-фильтры, heap isolation. Pwntools останется транспортом, но реальным навыком становится умение декомпозировать защиту и выстраивать цепочку примитивов. Те, кто учит pwntools как фреймворк поверх понимания стека, будут решать задачи через год. Те, кто учит pwntools вместо понимания стека — застрянут на уровне ret2win навсегда. Попробуйте прямо сейчас: возьмите любой ret2win с picoCTF, решите его сначала через struct.pack и socket, а потом перепишите на pwntools. Разница станет очевидной — и вы будете точно знать, что происходит под капотом.

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

Поделиться

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

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

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