
Восемь строк Python-кода. Столько занимает рабочий эксплойт на стандартный ret2win из категории pwn на picoCTF или HTB. При этом новички тратят десятки минут, вручную подбирая длину паддинга из символов A и путаясь в порядке байт. Pwntools превращает эту рутину в автоматику: cyclic находит offset, p32 упаковывает адрес в Little-Endian, sendline доставляет payload процессу. Проблема в том, что большинство русскоязычных материалов по pwntools останавливаются на пересказе API-документации — без сквозного разбора от скачанного бинарника до захваченного флага. Здесь — полный путь через первый эксплойт buffer overflow, с разбором каждой строки и анализом того, что делать, когда вместо shell видишь Segfault.
Pwntools работает на Linux — Ubuntu, Kali, Parrot, любой дистрибутив с Python 3. Ставим через pip: pip3 install pwntools. После установки в терминале появляется утилита pwn, через которую доступны checksec, cyclic, shellcraft и генератор шаблонов — всё из командной строки, без скрипта. Подробнее — в нашем статье о бинарный анализ уязвимостей.
Минимальный набор для решения pwn CTF задач помимо pwntools:
git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh. Альтернатива — GEF, интерфейс другой, возможности сопоставимые.objdump -d, для сложных — Ghidra с графом вызовов.Проверяем установку: python3 -c "from pwn import *; print('OK')" должен вывести OK без ошибок. Если ImportError — pip установил пакет в другое окружение. Лечится через python3 -m pip install pwntools с явным указанием интерпретатора.
Для работы gdb.attach() из pwntools-скриптов понадобится tmux или screen — pwntools открывает GDB в соседнем терминальном окне. Без мультиплексора gdb.attach() просто выбросит ошибку о недоступном терминале.
Первое действие после скачивания бинарника с CTF-платформы — checksec ./vuln. Эта утилита из пакета pwntools показывает защиты, включённые при компиляции. Результат определяет, какой тип эксплойта вообще применим.
Типичный вывод для учебного ret2win:
p32(). Видим amd64-64-little — переключаемся на p64().win() из Ghidra или objdump будет таким же при эксплуатации. С PIE адреса рандомизируются — нужен information leak.Тот же анализ доступен из Python. При создании ELF('./vuln') pwntools автоматически выводит checksec в консоль и парсит все символы. Адреса функций — через elf.symbols, записи GOT — через elf.got, PLT-стабы — через elf.plt. Никакого хардкода адресов в эксплойте.
Ret2win — самая простая категория pwn-задач и отправная точка для разработки эксплойтов на Python. В бинарнике две составляющие: уязвимость типа stack overflow в main() (обычно через gets() или scanf("%s")) и «выигрышная» функция (win, flag, print_flag), которая читает и выводит флаг, но нигде не вызывается из основного кода.
Задача — перезаписать return address функции main() на адрес win(). Когда main() выполнит ret, управление перейдёт не к стандартному завершению, а к win(), которая выведет флаг. Никакого шеллкода, никаких ROP-цепочек — чистая перезапись одного адреса на стеке.
Берём типичную уязвимую программу. В реальных CTF исходный код обычно не дают, но для понимания механики полезно видеть обе стороны — source и дизассемблирование.
#include <stdio.h>
#include <stdlib.h>
void win() {
system("cat flag.txt");
}
int main() {
char buf[32];
printf("Enter name: ");
gets(buf);
printf("Hello, %s!\n", buf);
}
Функция gets() — классическая уязвимость переполнения буфера. Читает ввод до символа новой строки без ограничения длины и пишет в buf размером 32 байта. При вводе более 32 символов данные перезаписывают всё за буфером на стеке: сохранённый ebp (frame pointer, 4 байта в 32-битной архитектуре) и return address.
Функция win() вызывает system("cat flag.txt"), но из main() к ней нет ни одного обращения. Единственный способ до неё добраться — перезапись return address.
На CTF исходника не будет. Первый шаг — найти интересные функции. Команда objdump -d ./vuln | grep -E "^[0-9a-f]+ <" покажет все символы. Если бинарник не stripped, увидим <win> с адресом — допустим, 0x080491f6.
Быстрее через pwntools: после elf = ELF('./vuln') адрес доступен как elf.symbols['win']. Плюс — если на CTF обновят бинарник, скрипт автоматически подхватит новый адрес без ручного редактирования.
Для анализа main() команда objdump -d ./vuln | grep -A 15 "<main>" покажет пролог с выделением места на стеке (инструкция sub esp, 0x28 или аналогичная) и вызов gets@plt. Ghidra покажет то же самое в виде декомпилированного псевдокода — иногда нагляднее, особенно для больших функций.
Между концом буфера и return address лежит сохранённый ebp и возможное выравнивание от компилятора. Точное расстояние зависит от версии gcc, флагов оптимизации и даже имени переменной (да, серьёзно). Считать руками необязательно — для этого есть cyclic.
Ключевой вопрос при эксплуатации stack overflow: сколько именно байт нужно записать, чтобы перезаписать return address? Ручной подбор через строку AAAAAA... — долго и ненадёжно. Если offset = 44, различить 43 и 44 символа A в дампе памяти GDB — задача не для человеческих глаз.
Pwntools решает это через cyclic — генератор уникальных паттернов, где каждая последовательность из 4 байт (для 32-bit) или 8 байт (для 64-bit) встречается ровно один раз. Генерируешь строку, подаёшь на вход программе, смотришь, какие байты попали в EIP при краше, и за одну команду получаешь точное смещение.
Процесс пошагово:
Генерируем паттерн: cyclic 100 в терминале выводит строку из 100 символов вида aaaabaaacaaadaaaeaaa....
Загружаем бинарник в GDB с pwndbg: gdb ./vuln. Запускаем с паттерном на вводе: r <<< $(cyclic 100). Программа падает с Segmentation fault.
Pwndbg автоматически покажет значение регистра EIP при краше. Допустим, видим EIP: 0x6161616c (это строка laaa в ASCII).
Находим offset: cyclic -l 0x6161616c в терминале возвращает 44. Ровно 44 байта паддинга до return address.
Тот же расчёт в Python: offset = cyclic_find(0x6161616c) возвращает 44. Подставляем сразу в payload.
Неверный регистр. В 32-битной архитектуре return address попадает прямо в EIP — его и смотрим при краше. В 64-битной ситуация хитрее: при ret значение снимается со стека в RIP, но если адрес невалиден, процессор генерирует fault до записи. Поэтому для amd64 смотрим не RIP, а RSP: значение на вершине стека (x/gx $rsp в GDB) — это то, что должно было стать новым RIP. Тут многие спотыкаются.
Размер слова cyclic. По умолчанию cyclic генерирует паттерн с 4-байтовыми уникальными блоками. Для 64-битных бинарников нужно указать: cyclic -n 8 200 для генерации и cyclic -n 8 -l <значение> для поиска. В Python это решается через context.arch = 'amd64' до вызова cyclic() — размер слова подхватится автоматически.
Чистый GDB без плагинов. Без pwndbg значения регистров при краше не выводятся автоматически. Используй info registers eip (или rip) для получения значения вручную. Но я бы просто поставил pwndbg — жизнь слишком коротка для голого GDB.
Все компоненты на руках: offset = 44, адрес win = 0x080491f6, архитектура i386. Пишем эксплойт — тот самый, который укладывается в 8 строк:
from pwn import *
context(arch='i386', os='linux')
elf = ELF('./vuln')
p = process('./vuln')
payload = b'A' * 44
payload += p32(elf.symbols['win'])
p.sendline(payload)
p.interactive()
Каждая строка делает конкретную вещь, и замена любой ломает эксплойт. Разберём.
from pwn import * — импортирует всё из pwntools: функции упаковки, классы для работы с процессами, утилиты для ELF. В production-коде такой импорт — антипаттерн. В CTF-эксплойтах — стандарт, и никто за это не осудит.
context(arch='i386', os='linux') — задаёт целевую архитектуру. От этого зависит поведение p32/p64, asm(), модуля shellcraft и размер слова в cyclic. Для amd64-бинарника: context(arch='amd64', os='linux'). Забыть эту строку — одна из самых частых ошибок новичков, и отлаживать потом мучительно.
elf = ELF('./vuln') — парсит ELF-файл. Класс ELF загружает символы, секции, GOT/PLT. Через elf.symbols['win'] получаем адрес без хардкода.
p = process('./vuln') — запускает локальный процесс и создаёт tube — абстракцию для двусторонней коммуникации. Через tube отправляем данные и читаем вывод.
payload = b'A' * 44 — 44 байта паддинга. Заполняет буфер, выравнивание и saved ebp. Символ A выбран для наглядности — при отладке в GDB видно, где заканчивается паддинг (блок 0x41414141).
payload += p32(elf.symbols['win']) — главная строка. p32() берёт целое число (адрес win) и упаковывает в 4 байта Little-Endian. Адрес 0x080491f6 превращается в \xf6\x91\x04\x08. Руками это делать — гарантированная ошибка: новички путают порядок байт, забывают ведущие нули, пишут \x08\x04\x91\xf6 вместо правильного. Я видел это десятки раз.
p.sendline(payload) — отправляет payload с \n в конце. gets() читает до \n, поэтому sendline — правильный выбор. Если бы программа использовала read() — нужен p.send(payload) без завершающего символа.
p.interactive() — переключает в ручной режим. Если win() вызывает system("/bin/sh") — получаешь интерактивный shell. Если просто печатает флаг — увидишь вывод в терминале.
Переход от 32-битных к 64-битным бинарникам меняет три вещи в эксплойте: функцию упаковки (p32 → p64), размер паддинга (saved rbp = 8 байт вместо 4) и потенциальную необходимость выравнивания стека. Обратные функции — u32() и u64() — распаковывают байты обратно в число. Они нужны при утечке адресов: получаешь 4 или 8 байт из программы и переводишь в число для вычислений.
Проверка: если checksec показал amd64-64-little, а в скрипте стоит p32() — адрес обрежется до 4 байт. Эксплойт гарантированно упадёт. Настройка context.arch предотвращает это, если использовать pack() вместо явных p32/p64, но для CTF прямой вызов p64() нагляднее.
Эксплойт написан, запущен, и вместо флага — Segfault. Или зависание. Или мусор в выводе. По порядку.
Неправильный offset. Программа падает, но EIP указывает не на win(), а на мусорный адрес. Решение: перезапусти цикл с cyclic. Убедись, что значение для cyclic_find берёшь из правильного регистра. Частая ловушка — скопировать из GDB значение RSP вместо содержимого по адресу RSP.
Stack alignment на amd64. Симптом: программа дошла до win(), но упала внутри — на инструкции movaps или при вызове system(). Причина: 64-битный ABI требует 16-байтовое выравнивание RSP перед вызовом функций. Стек не выровнен — SIGSEGV. Эта штука съедает часы у новичков.
Решение: добавь перед адресом win() гаджет ret. Однобайтовая инструкция сдвигает RSP на 8 байт и восстанавливает выравнивание. Адрес ret-гаджета находишь через ROPgadget --binary ./vuln | grep ": ret$" или через pwntools: класс ROP автоматизирует поиск. Payload меняется: padding + p64(ret_addr) + p64(win_addr).
Payload обрезается на плохих байтах. Функция ввода (scanf, fgets) прекращает чтение на определённых символах. scanf("%s", buf) остановится на пробеле 0x20, табе 0x09, переводе строки 0x0a. Если адрес win() содержит один из этих байтов — payload обрежется, return address перезапишется не полностью.
Диагностика: смотри в GDB, сколько байт реально записалось на стек (x/20wx $esp после breakpoint на ret). Если меньше длины payload — ввод обрезан. Решение зависит от конкретной функции и адреса: иногда помогает прыжок на инструкцию внутри win() со смещением +1 или +2, чтобы обойти плохой байт.
Забытый context.arch. p32() упаковывает в 4 байта независимо от context. Но cyclic(), cyclic_find() и asm() опираются на настройки контекста. Если context.arch не задан или задан неверно — cyclic_find вернёт неправильный offset. Тихо, без ошибок. Просто неправильный результат.
Включи debug-лог. Добавь context.log_level = 'debug' в начало скрипта. Pwntools напечатает каждый отправленный и полученный байт. Сразу видно, что уходит в процесс, совпадает ли payload с ожидаемым и не обрезается ли он на полпути.
Локально эксплойт работает, флаг получен — пора отправлять на сервер CTF. Замена — одна строка: process('./vuln') → remote('ctf.example.com', 1337). Весь остальной код идентичен. Tubes API — единый интерфейс для процессов, сокетов и SSH-сессий.
Стандартная практика — делать скрипт универсальным. Утилита pwn template генерирует шаблон с переключателем: при запуске python3 exploit.py REMOTE скрипт подключается к серверу, без аргумента — работает локально. Внутри используется args.REMOTE — встроенный механизм pwntools для парсинга аргументов.
При удалённой эксплуатации offset и адреса функций остаются прежними (определяются бинарником, а не средой), при условии что PIE выключен. Но есть нюансы.
Сетевые задержки. Если payload уйдёт до того, как сервер напечатает приглашение к вводу, данные могут потеряться. Добавь p.recvuntil(b'Enter name:') перед p.sendline(payload) — скрипт дождётся промпта.
Различие libc. Для ret2libc-эксплойтов (не наш текущий случай, но следующий уровень) версия libc на сервере почти наверняка отличается от локальной. Адреса функций внутри libc будут другими. Pwntools помогает через модуль libcdb и класс DynELF, который резолвит символы по leak-функции в runtime.
Всё взаимодействие с процессом или сокетом идёт через tubes — единый интерфейс pwntools для ввода-вывода:
p.send(data) — отправить данные без \np.sendline(data) — отправить данные + символ новой строкиp.recv(n) — принять ровно n байтp.recvline() — принять данные до \n включительноp.recvuntil(delim) — принять данные до указанного разделителяp.interactive() — передать управление пользователю для ручного взаимодействияДля 90% CTF-задач категории pwn хватает трёх вызовов: recvuntil для синхронизации с промптом, sendline для отправки payload и interactive для получения shell. Тонкое управление через recvn, recvall, clean нужно в многошаговых эксплойтах, где данные приходят частями.
Ret2win — стартовая точка. Дальше по сложности задач в pwn категории CTF:
ret2shellcode. Если checksec показал NX disabled и Has RWX segments — стек исполняемый. Записываешь шеллкод (машинный код, выполняющий execve("/bin/sh")) на стек и прыгаешь на него через return address. Pwntools содержит модуль shellcraft с готовыми шеллкодами: shellcraft.sh() генерирует вызов shell, asm() компилирует ассемблер в байты. При шеллкод-инъекции нужно следить за плохими символами (0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20) — scanf и аналоги обрезают на них ввод.
ROP-цепочки. Когда NX включён и нет win-функции — строишь цепочку из гаджетов: коротких инструкций, заканчивающихся ret. Каждый гаджет выполняет одно действие (загрузить значение в регистр, вызвать syscall), а последовательность складывается в полноценный вызов. Класс ROP в pwntools находит гаджеты автоматически, но на практике сложные цепочки часто приходится собирать вручную, проверяя каждое звено в GDB. Автоматика тут помогает, но не спасает.
ret2libc. Вызов system("/bin/sh") через стандартную библиотеку. Нужен leak адреса libc-функции (через puts, printf), вычисление базового адреса libc и построение payload с правильными смещениями. Pwntools помогает через DynELF для динамического резолва символов или через ручную работу с ELF('./libc.so.6').
Format string. Эксплуатация уязвимости форматной строки (printf(user_input) вместо printf("%s", user_input)). Класс FmtStr в pwntools автоматизирует подбор offset и генерацию payload для произвольного чтения и записи в память.
Каждый следующий тип задачи добавляет одну-две техники к предыдущему. Pwntools масштабируется вместе с навыком: от p32() + sendline() на первом ret2win до полноценного ROP() с автоматическим построением цепочек.
По моему опыту, основы — это не знание API. Основы — это умение отлаживать. Когда эксплойт не срабатывает (а с первой попытки он не срабатывает почти никогда), именно навык открыть GDB, поставить breakpoint на ret, посмотреть содержимое стека и понять, почему payload лёг не так — вот это отличает человека, решающего задачи, от человека, копирующего writeup-ы. Каждый Segfault — это информация: обрезался payload на плохом байте, сместился offset на 4 байта из-за выравнивания, забыл переключить p32 на p64 после перехода к 64-битному бинарнику. Три-четыре таких провала на одну задачу дают больше понимания механики стека, чем десять прочитанных разборов чужих решений. Если writeup-ы уже читаются, но при самостоятельном решении руки буксуют — на WAPT бинарная эксплуатация разбирается в нескольких модулях с лабами, где каждый вектор нужно отработать самому, а не по подсказке.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...