
На последнем CTF потратил 40 минут на pwn-задачу, которую сосед по команде решил за пять. Я вручную подбирал offset, копировал адреса из objdump, склеивал payload через struct.pack. Он написал 12 строк на pwntools и забрал флаг, пока я ещё считал байты. После этого стало ясно: без pwntools в категории pwn ты работаешь руками там, где все давно на автомате. Библиотека берёт на себя рутину — упаковку адресов, поиск смещений, взаимодействие с процессом — а ты занимаешься самим эксплойтом. Buffer overflow до сих пор живее всех живых: Exploitation for Client Execution (T1203) и Exploitation for Privilege Escalation (T1068) по MITRE ATT&CK никуда не делись. А на CTF это вообще хлеб pwn-категории. Разберём ключевые модули pwntools для начинающих: от установки до рабочего шаблона с ROP-цепочкой.
Pwntools работает на Linux и в WSL2 на Windows. Установка стандартная: pip install pwntools для Python 3.6 и выше. После установки в терминале появляется утилита pwn — CLI-обёртка, которая генерирует шаблоны эксплойтов, проверяет защиты бинарников и умеет ещё с десяток полезных штук. Подробнее — в нашем материале про бинарный анализ уязвимостей.
Две команды для первой проверки. pwn version покажет установленную версию. pwn checksec ./binary выведет включённые защиты: NX, ASLR, Stack Canary, PIE, RELRO. Именно checksec — первое, что стоит запускать перед написанием эксплойта. Результат определяет стратегию: NX включён — забудь про shellcode на стеке, нужен ROP. Stack Canary — придётся сначала утечь канарейку, и только потом перезаписывать return address. PIE — адреса бинарника рандомизированы, без утечки базы никуда. Без этой информации эксплойт пишется вслепую.
Дополнительно стоит поставить GDB с плагином pwndbg или GEF — apt install gdb, плагин по инструкции из репозитория. GDB понадобится для отладки: pwntools умеет подключать дебаггер к запущенному процессу прямо из Python-скрипта через gdb.attach().
Для быстрого старта pwn template ./vuln > exploit.py создаёт готовый скелет эксплойта с автоматически заполненными параметрами бинарника: архитектура, путь, стандартная обвязка. Этот скелет — отправная точка для каждой задачи, где меняются только payload и целевые адреса.
Модуль context — глобальная конфигурация pwntools, от которой зависит поведение всей библиотеки. Три параметра задаются всегда.
context.arch определяет целевую архитектуру: 'i386' для 32-бит, 'amd64' для 64-бит. От выбора зависит размер упакованных адресов (4 или 8 байт), синтаксис ассемблера в shellcraft, формат ROP-гаджетов и длина последовательности в cyclic(). Поставил не ту архитектуру — получил payload, который не влезает в нужное окно или содержит адреса неверного размера. И pwntools тебе об этом не скажет.
context.os задаёт операционную систему, по умолчанию 'linux'. Влияет на генерацию shellcode: номера системных вызовов различаются между Linux и FreeBSD, и неправильный номер syscall превратит execve('/bin/sh') в бессмысленный вызов.
context.log_level управляет детальностью вывода. 'debug' показывает каждый отправленный и принятый байт — при отладке без этого никак. 'info' — стандартный уровень. 'error' подавляет всё, кроме ошибок.
Самый удобный способ — привязать контекст к бинарнику одной строкой: context.binary = './vuln'. Pwntools сам вытащит архитектуру, эндианность и ОС из ELF-заголовка. Экономит три строки кода и убирает ручные ошибки. Типичная проблема новичков: забыть про context.arch и получить 4-байтовый адрес через p32() для 64-битного бинарника. Pwntools молча соберёт кривой payload, ни слова не скажет. Привязка через context.binary решает это раз и навсегда.
Модуль tubes — центральная часть pwntools для написания эксплойтов на Python. Единый интерфейс для работы с локальными процессами и удалёнными сервисами. Tubes покрывают процессы, сокеты, серийные порты и SSH-соединения через одинаковый набор методов.
process('./vuln') запускает локальный бинарник. remote('ctf.example.com', 1337) подключается к удалённому серверу. Оба возвращают объект с идентичными методами. Эксплойт, отлаженный локально, переключается на удалённый сервис заменой одной строки — логику переписывать не надо.
Методы отправки данных: p.send(data) отправляет байты без символа новой строки. p.sendline(data) добавляет \n в конец. p.sendafter(delim, data) ждёт появления строки delim в выводе программы и только потом отправляет — надёжнее слепой отправки, потому что синхронизирует скрипт с состоянием процесса. p.sendlineafter(delim, data) делает то же с добавлением \n.
Методы приёма: p.recv(n) принимает до n байт. p.recvline() читает строку до \n. p.recvuntil(delim) собирает данные до появления разделителя — удобно для парсинга утечек адресов. p.recvall() читает всё до закрытия соединения.
p.interactive() переключает скрипт в интерактивный режим: ввод с клавиатуры идёт в процесс, вывод процесса — на экран. Используется после успешной эксплуатации для работы с полученным shell.
Тут есть классическая ловушка — send() вместо sendline() и наоборот. Если программа читает ввод через gets() или fgets(), она ждёт \n. Без sendline() процесс просто зависнет. Если программа читает фиксированное количество байт через read(fd, buf, n), лишний \n от sendline() добавит нежелательный байт в payload и сдвинет все адреса на единицу. Правило простое: всегда смотрите, какая функция ввода используется в бинарнике.
Ещё полезный паттерн для python CTF задач: args.LOCAL — встроенный флаг pwntools, который проверяет аргументы командной строки. Конструкция p = process() if args.LOCAL else remote(HOST, PORT) позволяет переключаться через python exploit.py LOCAL или python exploit.py REMOTE без правки кода.
Бинарная эксплуатация — это работа с байтами. Адреса памяти на x86/x64 хранятся в Little-Endian формате: младший байт первым. Число 0xdeadbeef в памяти выглядит как \xef\xbe\xad\xde. Ручная конвертация через struct.pack('<I', addr) — занятие утомительное и ошибкоёмкое. Я на этом терял минуты на каждой задаче, пока не перешёл на pwntools.
p32(addr) упаковывает 32-битный адрес в 4 байта Little-Endian. p64(addr) — 64-битный в 8 байт. Для мелких значений есть p16() и p8(). Обратные операции — u32(data) и u64(data) — распаковывают байты обратно в числа, что нужно при парсинге утечек адресов из вывода программы.
Функция flat() собирает payload из смешанных типов — строк, чисел и байтовых последовательностей. Вместо ручной конкатенации b'A' * 72 + p64(0x401234) можно написать flat({72: 0x401234}). Словарный синтаксис особенно хорош для сложных payload с несколькими адресами на разных смещениях: flat({72: addr_win, 80: addr_arg}) заполнит первые 72 байта паддингом, подставит адрес на позицию 72 и второй — на позицию 80. Красота.
Все функции паковки автоматически учитывают context.endian и context.word_size. Если context.binary задан — размер и порядок байт подхватываются из бинарника. Ещё одна причина всегда привязывать контекст.
Ключевой вопрос при буфер overflow эксплойте: сколько байт нужно отправить до перезаписи return address? Компилятор может добавить padding до кратности 16 — буфер на 20 байт реально займёт 32, и ручной подсчёт по исходнику не совпадёт с действительностью. Классический способ с увеличением длины строки из 'A' — медленный и ненадёжный.
Pwntools даёт cyclic() — генератор последовательности де Брёйна. Строка, где каждая подстрока заданной длины уникальна. Отправляете её в программу, программа падает с segfault, по значению в регистре RSP/EIP определяете смещение через cyclic_find().
from pwn import *
context.binary = './vuln'
p = process()
p.sendline(cyclic(200))
p.wait()
core = Coredump('./core')
offset = cyclic_find(core.read(core.rsp, 4))
log.info(f'Offset до return address: {offset}')
Класс Coredump читает core-файл после падения и даёт доступ к регистрам и памяти. core.rsp указывает на адрес, куда программа попыталась вернуться — часть cyclic-строки. cyclic_find() вычисляет точную позицию этого фрагмента в исходной последовательности.
Для 64-битных бинарников cyclic() автоматически переключается на 8-байтовые уникальные подстроки, если context.arch = 'amd64'. Для 32-битных используйте core.eip вместо core.rsp. Путь к core-файлу зависит от настроек системы: обычно ./core или /tmp/core.PID. Проверить и настроить — cat /proc/sys/kernel/core_pattern.
Альтернатива — pwndbg в GDB: после падения команда cyclic -l $rsp вычислит offset. Но скриптовый подход через автоматизацию эксплойтов pwntools быстрее, когда решаешь пять задач за час на соревновании.
Хардкодить адреса в эксплойте — путь к поломке при каждой перекомпиляции. Класс ELF парсит бинарник и даёт программный доступ к символам, секциям и таблицам.
elf = ELF('./vuln') загружает файл. Дальше доступны: elf.symbols['win'] — адрес функции win, elf.got['puts'] — запись puts в Global Offset Table, elf.plt['puts'] — адрес в Procedure Linkage Table, elf.bss() — начало секции .bss, elf.search(b'/bin/sh') — итератор для поиска строки в бинарнике.
Зачем GOT и PLT? Для утечки адресов libc при включённом ASLR. Типичный сценарий pwn CTF задачи: NX включён, PIE выключен, ASLR включён. Стратегия: через PLT вызвать puts(got['puts']), получить реальный адрес puts в libc, вычислить базу, построить финальную ROP-цепочку с system('/bin/sh'). Без ELF-класса пришлось бы ковырять каждый адрес вручную через objdump или readelf.
Метод elf.checksec() выводит защиты из скрипта — аналог CLI-команды pwn checksec. Полезно для автоматизации: проверить наличие канарейки и программно выбрать стратегию эксплуатации.
Минимальный рабочий шаблон для задачи с переполнением буфера и функцией win:
from pwn import *
context.binary = elf = ELF('./vuln')
p = process() if args.LOCAL else remote('ctf.example.com', 1337)
offset = 72
payload = flat(cyclic(offset), elf.symbols['win'])
p.sendlineafter(b'Input: ', payload)
p.interactive()
Первая строка одновременно привязывает контекст и создаёт ELF-объект. process() без аргументов берёт бинарник из context.binary. flat() собирает payload: 72 байта паддинга из cyclic-последовательности + адрес win в правильном формате. sendlineafter() ждёт приглашения 'Input: ' и только потом отправляет — надёжнее слепого sendline(), потому что гарантирует синхронизацию с процессом. interactive() переключает в ручной режим для работы с shell.
Шаблон покрывает большинство простых pwn-задач. Меняются три вещи: имя бинарника, значение offset и целевой адрес. Остальное — каркас, который переносится между задачами целиком.
Для задач посложнее, где нужно сначала получить утечку, а потом отправить финальный payload, шаблон расширяется: после первой отправки добавляется leak = u64(p.recvline().strip().ljust(8, b'\x00')) для парсинга утечённого адреса, затем вычисление базы libc и построение второго payload.
Когда включён NX (а это сейчас практически всегда) — shellcode на стеке не исполнится. Нужна ROP-цепочка: последовательность адресов существующих гаджетов в бинарнике, каждый из которых заканчивается инструкцией ret и выполняет мелкую операцию — загрузить значение в регистр, вызвать функцию.
Класс ROP в pwntools автоматизирует построение цепочки:
from pwn import *
context.binary = elf = ELF('./vuln')
rop = ROP(elf)
rop.raw(rop.find_gadget(['ret'])[0])
rop.call('puts', [elf.got['puts']])
rop.call(elf.symbols['main'])
log.info(rop.dump())
payload = flat(cyclic(72), rop.chain())
ROP(elf) загружает бинарник и ищет доступные гаджеты. find_gadget(['ret']) находит одиночный ret — он нужен для выравнивания стека на 16 байт (требование movaps в libc на Ubuntu 18.04+). Без этого гаджета вызов puts через PLT упадёт с segfault. Одна из самых раздражающих ошибок для новичков — всё правильно, адреса верные, а segfault. И всё из-за выравнивания. rop.call('puts', [elf.got['puts']]) строит вызов puts с аргументом — адресом из GOT, что даёт утечку реального адреса libc. rop.call(elf.symbols['main']) возвращает управление в main для повторной эксплуатации. rop.dump() выводит человекочитаемое представление цепочки — советую всегда смотреть перед отправкой.
Для случаев без NX (редкость, но на CTF встречается) pwntools предлагает shellcraft — генератор shellcode. Вызов asm(shellcraft.sh()) создаёт shellcode для execve('/bin/sh', 0, 0) под архитектуру из context. Модуль поддерживает десятки платформ: shellcraft.amd64.linux.cat('/flag') для чтения файла, shellcraft.setreuid() + shellcraft.dupsh(4) для сброса привилегий с дублированием файлового дескриптора.
Для поиска гаджетов за пределами pwntools есть утилита ropper — умеет искать по конкретным инструкциям и фильтровать по bad-байтам.
Восемь из десяти нерабочих эксплойтов ломаются на одних и тех же граблях. Знание этих граблей экономит часы.
Неправильный offset. Самая частая причина. Компилятор добавляет padding до кратности 16: буфер на 20 байт может реально занять 32 в стековом фрейме. Единственный надёжный способ — cyclic() + анализ coredump или GDB. Не доверяй исходнику.
send вместо sendline и наоборот. Лишний \n сдвигает payload на байт и ломает все адреса. Отсутствующий \n вешает процесс в ожидании ввода. Правило: gets() и fgets() требуют \n, read() — нет.
Забытый context. Бинарник 64-битный, а p32() генерирует 4-байтовый адрес. Pwntools не предупредит. Решение: context.binary = './vuln'.
Stack alignment. На x86-64 функции libc (особенно system()) требуют 16-байтового выравнивания стека из-за инструкции movaps. Симптом: segfault внутри libc при вызове корректного адреса. Лечение: добавить гаджет ret перед адресом функции — он сдвигает стек на 8 байт. Я на этом потерял часа два на первом CTF, пока не разобрался.
Отсутствие debug-логирования. При отладке context.log_level = 'debug' показывает каждый байт, отправленный и принятый через tube. Часто проблема видна сразу: отправили 72 байта, а программа ждала 76.
Для глубокой отладки — gdb.attach(p) из скрипта. Вызов открывает GDB, подключённый к процессу. Можно передать начальный скрипт: gdb.attach(p, 'b *main+42\nc') поставит breakpoint и продолжит выполнение. Альтернатива — gdb.debug('./vuln'), запускающая бинарник сразу под дебаггером. Из командной строки — pwn debug ./vuln через tmux.
Если эксплойт работает локально, но ломается на удалённом сервере — проверяйте версию libc. Адреса функций зависят от сборки. Для идентификации libc по утечённым адресам есть онлайн-базы и класс DynELF в pwntools: он принимает функцию утечки и автоматически вычисляет базовый адрес libc через серию запросов. На соревнованиях формула decay по времени мотивирует решать быстрее — и тут автоматизация через pwntools даёт ощутимое преимущество перед ручным подходом.
Половина новичков в CTF читают writeup, запоминают последовательность действий и пытаются воспроизвести на следующей задаче. Не работает — задачи различаются достаточно, чтобы копипаст ломался. Разница между тем, кто стабильно решает pwn, и тем, кто застревает — не в знании шаблонов, а в понимании того, что делает каждая строка. Не какой адрес подставить, а почему именно этот. Механическое заучивание паттернов pwntools бесполезно без понимания стекового фрейма, calling conventions и того, как компилятор раскладывает переменные. Кто этого не понимает — тот не увидит, что offset не 72, а 76, потому что GCC добавил padding. На практике первые десять эксплойтов будут кривыми: не тот offset, забытый ret-гаджет, перепутанный send с sendline. На одиннадцатом руки сами напишут рабочий скрипт за три минуты, потому что все ошибки уже пройдены. Если хочешь не просто writeup, а пройти всю цепочку самостоятельно с нарастающей сложностью — на WAPT есть лабы от базового переполнения до полноценных ROP-цепочек с ментором в чате при затыке.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...