
Первая pwn-задача на CTF — почти всегда ret2win: бинарник с переполнением стека и готовой функцией win(). На бумаге — десять минут работы. На практике — три часа мучений для тех, кто впервые видит checksec и не понимает, зачем перед адресом функции впихивать гаджет ret. Этот разбор закрывает путь от «скачал бинарник» до «получил флаг» — с каждой командой и объяснением, зачем она здесь и что случится, если её пропустить.
Стек в x86-64 растёт от старших адресов к младшим. При вызове функции процессор создаёт стековый фрейм — на стек кладётся return address (адрес инструкции, куда вернётся управление после завершения функции), затем пролог сохраняет RBP инструкцией push rbp, ставит новый фрейм (mov rbp, rsp) и выделяет место под локальные переменные (sub rsp, N). Это справедливо для сборок без оптимизации (-O0), что типично для CTF-задач. При -fomit-frame-pointer (по умолчанию в GCC/Clang с -O1 и выше на x86-64) RBP может не сохраняться, и offset до return address придётся искать иначе. Подробнее — в нашем материале про бинарный анализ уязвимостей.
А теперь суть эксплуатации. Локальный буфер лежит по младшим адресам, return address — по старшим. Запись идёт от младших к старшим. Данных больше, чем вмещает буфер — они последовательно затирают сохранённый RBP и return address. Это stack-based buffer overflow, CWE-121 по классификации MITRE. Что окажется на месте return address — то и определит судьбу процесса: мусор — segfault; адрес нужной функции — контролируемый переход.
Инструкция ret берёт значение с вершины стека и загружает его в RIP. Перезаписали return address адресом win() — процессор пойдёт туда. В CTF-задачах win() печатает флаг из файла. Отсюда и название — ret2win.
По MITRE ATT&CK переполнение буфера лежит в основе техник Exploitation for Client Execution (T1203) и Exploitation for Privilege Escalation (T1068). В CTF мы учимся механике; в реальных атаках та же механика работает для начального доступа или повышения привилегий. (Примечание: публичные тесты Atomic Red Team для T1068 на момент написания доступны только для Windows, хотя сама техника платформонезависима.)
Нужен Linux — Ubuntu, Debian, Kali — и три инструмента.
pwntools — Python-библиотека для автоматизации эксплойтов. pip install pwntools (рекомендую версию ≥ 4.12.0, в более ранних обнаружена SSTI — GHSA-7xc5-ggpp-g249). Формирование payload, упаковка адресов в little-endian, взаимодействие с локальным процессом или удалённым сервером — всё через неё. Без pwntools эксплойты писать можно, но каждый payload придётся собирать руками через struct.pack. На второй задаче это уже утомляет.
pwndbg — плагин для GDB. Клонируется с GitHub, ставится скриптом setup.sh. Превращает GDB из текстового интерфейса времён 1970-х в рабочий инструмент: контекст регистров, дизассемблированный код вокруг текущей инструкции, содержимое стека — всё на одном экране. Альтернативы — GEF или PEDA, функции похожие, выбор — дело вкуса. Я использую pwndbg, потому что у него самый читаемый вывод контекста.
checksec — утилита из пакета pwntools. checksec ./binary покажет, какие защиты включены. От результата зависит вся стратегия: есть canary — нужна утечка, есть PIE — нужен leak адреса, нет ничего — прямой ret2win.
Ещё пригодится ROPgadget (pip install ROPgadget) для поиска гаджетов. Для реверса посложнее — Ghidra (бесплатная) или IDA Pro. Для первого ret2win хватает objdump -d и GDB.
Скачали бинарник vuln. Первый шаг — checksec ./vuln. Нас интересуют четыре строки:
Stack: No canary found — канарейки нет, стек перезаписываем напрямую.NX: NX enabled — стек неисполняемый, шеллкод на стеке не запустить. Для ret2win это неважно.PIE: No PIE — адреса функций фиксированы, win() будет по одному адресу при каждом запуске.RELRO: Partial RELRO — частичная защита GOT, для простого ret2win значения не имеет.Комбинация «No canary + No PIE» — идеальные условия для ret2win. NX не мешает: мы не кладём шеллкод на стек, а перенаправляем выполнение на существующую функцию.
Ищем цель. objdump -t ./vuln | grep win покажет адрес. В GDB — info functions для полного списка. Если бинарник stripped (символы удалены — на CTF бывает), эти команды не помогут. Тогда objdump -d с ручным поиском сигнатуры или Ghidra. Допустим, win() сидит по адресу 0x401156.
Типичный уязвимый код ret2win-задачи:
#include <stdio.h>
void win() { system("cat flag.txt"); }
void vuln() {
char buf[64];
gets(buf);
}
int main() { vuln(); return 0; }
gets() читает ввод без ограничения длины — классика переполнения буфера. Буфер buf занимает 64 байта, за ним в стеке — сохранённый RBP (8 байт) и return address (8 байт). Подаём строку длиннее 72 байт — return address перезаписан.
Теоретически offset = размер буфера (64) + размер RBP (8) = 72 байта. Но компилятор может добавить выравнивание (alignment padding) или перекроить фрейм. Поэтому offset всегда проверяется экспериментально. Это привычка, которая экономит часы отладки.
Метод — паттерн де Брёйна (cyclic pattern). Последовательность, в которой любая подстрока заданной длины встречается ровно один раз. Подаём на вход — при падении по значению в регистре однозначно определяем, какая часть входа попала в return address.
Запускаем gdb ./vuln. Генерируем паттерн: cyclic 200. Копируем. run, вставляем паттерн. Программа падает с segfault. Pwndbg покажет контекст — в том числе значение на вершине стека (RSP). Если там 0x6161616c6161616b, выполняем cyclic -l 0x6161616b (четыре младших байта). Pwndbg вернёт число — допустим, 72. Это наш offset.
То же из Python: from pwn import *; print(cyclic_find(0x6161616b)). Результат совпадёт.
Правило для переполнения стека: если теоретический offset не совпал с экспериментальным — верьте эксперименту. Один -fstack-protector, одна оптимизация — и расчёты на бумаге идут в мусорку.
Данные собраны: offset = 72, адрес win() = 0x401156, защиты не мешают. Собираем payload.
Нюанс, на котором спотыкается каждый второй новичок: stack alignment. В x86-64 инструкции SSE (movaps и подобные) требуют RSP, кратного 16. Если win() внутри вызывает system() или printf(), а стек выровнен неправильно — segfault прямо внутри функции, хотя адрес верный. Симптом дезориентирует: «адрес правильный, а падает». Я на этом потерял полтора часа на первом CTF, пока не нагуглил про выравнивание.
Решение: поставить перед адресом win() гаджет ret — одиночную инструкцию ret, которая сдвинет RSP на 8 байт и восстановит выравнивание. Находим адрес: ROPgadget --binary ./vuln | grep ": ret$" (формат вывода может отличаться между версиями — если grep не находит совпадений, попробуйте без привязки к концу строки: ROPgadget --binary ./vuln | grep ret). Допустим, 0x40101a.
Финальный скрипт:
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
offset = 72
ret = 0x40101a # гаджет для выравнивания стека
win = elf.symbols['win'] # адрес win() из таблицы символов
payload = b'A' * offset + p64(ret) + p64(win)
p.sendline(payload)
p.interactive()
Построчно. ELF('./vuln') парсит бинарник и даёт доступ к символам. process() запускает его как дочерний процесс. b'A' * offset — заполняем буфер и RBP мусором (0x41, подходит любой байт). p64(ret) — гаджет для выравнивания. p64(win) — адрес, который перезапишет return address. sendline() отправляет payload с \n на конце — gets() ждёт перевод строки, без неё бинарник повиснет. interactive() передаёт интерактивный ввод-вывод: если win() открывает шелл, можно вводить команды.
python3 exploit.py — видим содержимое flag.txt. Для удалённого сервера CTF меняем process('./vuln') на remote('ctf.example.com', 1337), остальное без изменений.
Ret2win без защит — уровень «easy» на CTF. Дальше включают одну или несколько защит. Вот карта: что блокирует каждая и чем обходится.
Случайное значение между буфером и сохранённым RBP. Перед ret программа проверяет канарейку: изменилась — __stack_chk_fail, процесс мёртв. Stack smashing detected. Обход: утечка значения через format string. В задаче My Little Pwny (MetaCTF) printf(buffer) позволяла прочитать канарейку со стека спецификатором %19$p. Утечённое значение вставляется обратно в payload на своё место. Второй вариант — побайтовый перебор в fork-серверах, где канарейка не меняется между дочерними процессами.
NX запрещает исполнение кода на стеке. Шеллкод не запустить. Обход — Return-Oriented Programming: вместо одного адреса возврата выстраиваем цепочку из «гаджетов» — коротких фрагментов существующего кода, каждый заканчивается ret. Последовательность pop rdi; ret → pop rsi; ret → pop rax; ret → syscall; ret складывается в вызов execve("/bin/sh"). При статической компиляции бинарника гаджетов обычно хватает для полной ROP-цепочки без libc.
PIE рандомизирует базовый адрес бинарника при каждом запуске. Адрес win() больше не фиксирован. Обход: утечка любого адреса из бинарника через format string или partial overwrite, затем вычисление базы по известному смещению. В той же My Little Pwny формат %6$p утекал адрес из PIE-области, от которого вычитается смещение 0x20f0 для получения базы. Все остальные адреса вычисляются от неё.
ASLR рандомизирует адреса стека, кучи и разделяемых библиотек. Обход — ret2libc: утечка адреса любой функции из GOT (например, puts), вычисление базы libc, нахождение system() и строки /bin/sh внутри libc — и ROP-цепочка через них. Фундамент тот же: переполнение буфера и контроль return address.
| Защита | Что блокирует | Метод обхода | Уровень |
|---|---|---|---|
| Нет защит | Ничего | Прямой ret2win | Начальный |
| Stack Canary | Перезапись стека | Утечка через format string | Средний |
| NX | Шеллкод на стеке | ROP-цепочки | Средний |
| PIE | Фиксированные адреса | Утечка + расчёт базы | Средний |
| ASLR + NX | Адреса libc | ret2libc | Продвинутый |
Забыт stack alignment. Segfault внутри win() при правильном адресе. movaps требует RSP, кратного 16. Лечится ret-гаджетом перед целевым адресом.
send() вместо sendline(). gets() ожидает \n. Без перевода строки payload не обрабатывается, бинарник зависает. Мелочь, а отлаживать можно долго.
Несовпадение libc. Локально эксплойт работает, на сервере — нет. Разные версии libc — разные смещения. Решение: использовать libc из раздатки CTF и pwninit для патчинга бинарника под конкретную версию.
Теоретический offset вместо экспериментального. Компилятор добавил padding или выровнял стек иначе — offset отличается от «размер буфера + 8». Всегда проверяйте через cyclic. Всегда.
Формула решения: checksec → поиск win() через objdump/GDB → offset через cyclic → гаджет ret через ROPgadget → скрипт на pwntools. Пять шагов, пятнадцать минут от скачивания бинарника до флага — если каждый шаг отработан.
Часто слышу, что эксплуатация переполнения буфера — тема устаревшая, мол, современные компиляторы закрыли всё канарейками и рандомизацией. На практике — нет. CWE-121 (Stack-based Buffer Overflow) стабильно присутствует в CVE-бюллетенях. Canary обходится утечкой. ASLR нейтрализуется через format string или partial overwrite. NX не спасает от ROP-цепочек — гаджетов в самом бинарнике или libc хватает. Защиты подняли планку входа — атакующему нужно больше шагов — но фундамент не изменился: контроль над return address даёт контроль над исполнением. Кто разобрался с ret2win, тот решит и задачу с ROP, и ret2libc — разница в количестве звеньев цепочки, не в принципе. Если хочешь не просто writeup, а пройти полную цепочку от простого переполнения до ROP с нарастающей сложностью — на WAPT эту прогрессию проходят в модулях с лабами и ментором в чате при затыке.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...