
Первая pwn-задача на CTF обычно заканчивается одинаково: бинарник падает с Segmentation fault, терминал молчит, а ты сидишь и не понимаешь, что вообще произошло. Мой первый ret2win-таск занял четыре часа — три из которых ушли на выяснение, почему offset оказался 72, а не 64. Ответ: saved RBP занимает 8 байт на x86-64, и ни один русскоязычный гайд мне об этом явно не сказал. Эта статья — тот разбор, который я хотел прочитать в тот момент: от устройства стека до работающего эксплойта на pwntools, без пропущенных шагов и без «это очевидно».
Когда функция вызывается в C-программе, процессор выделяет на стеке стековый фрейм — область памяти для локальных переменных, сохранённого базового указателя (saved RBP) и адреса возврата (saved RIP). Ключевое противоречие тут вот в чём: стек растёт вниз (от старших адресов к младшим), а данные в буфер записываются вверх (от младших к старшим). Именно это противоречие направлений и делает переполнение буфера стека возможным. Подробнее — в нашем статье о бинарный анализ уязвимостей.
Вот как выглядит стековый фрейм функции с буфером на 64 байта на x86-64:
Старшие адреса (дно стека)
┌───────────────────────────┐
│ Адрес возврата (RIP) │ ← 8 байт, ЦЕЛЬ атаки
├───────────────────────────┤
│ Saved RBP │ ← 8 байт
├───────────────────────────┤
│ │
│ buffer[64] │ ← 64 байта, СЮДА пишет read()
│ данные растут ВВЕРХ ↑ │
└───────────────────────────┘
Младшие адреса (верх стека)
Буфер начинается в нижней части фрейма. Когда read() получает больше данных, чем вмещает буфер, лишние байты ползут вверх по памяти: сначала затирают padding (если компилятор его добавил), затем saved RBP, а следом — адрес возврата. В момент выполнения инструкции ret процессор снимает значение с вершины стека и прыгает по этому адресу. Если эти байты подконтрольны атакующему — он решает, куда прыгнет программа.
Это и есть перезапись адреса возврата. В CTF-задачах целевая функция обычно называется win(), flag() или содержит вызов system("cat /flag") — техника называется ret2win. Бинарник содержит нужную функцию, но ни одна легитимная ветка кода к ней не ведёт. Переполнение буфера — единственный способ туда попасть.
В терминологии MITRE ATT&CK переполнение буфера относится к технике Exploitation for Client Execution (T1203, тактика Execution) — классическая точка входа. На CTF buffer overflow — изолированный таск, но в реальном мире это точка initial access: эксплуатация уязвимости → выполнение произвольного кода → foothold на целевой машине. Понимание стековых фреймов и перезаписи адреса возврата — фундамент для всех последующих техник бинарной эксплуатации: ROP-цепочек, format string, heap exploitation. Без этого фундамента каждая следующая ступень сложности превращается в стену.
Перед тем как открыть GDB, проверьте, что всё на месте:
| Компонент | Минимум | Рекомендуется |
|---|---|---|
| ОС | Ubuntu 20.04 / Kali 2023+ (x86-64) | Kali 2024+ или Ubuntu 22.04 |
| RAM | 2 ГБ | 4 ГБ |
| Python | 3.8+ | 3.10+ |
| pwntools | pip install pwntools (4.x) |
Актуальная версия из PyPI |
| GDB | Из системного репозитория | С плагином pwndbg или peda |
| Дизассемблер | -- | Ghidra 11.x или radare2 6.x |
Плагин pwndbg ставится одной командой: git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh. После установки GDB при запуске показывает цветной вывод с регистрами, стеком и дизассемблированным кодом. Без этого отладка эксплойта — чтение шестнадцатеричных дампов вслепую. Можно, но больно.
Вся статья построена вокруг 64-битной архитектуры (x86-64) — это стандарт для современных CTF-задач. В 32-битных бинарниках принцип тот же, меняются размеры регистров (4 байта вместо 8) и имена (EIP вместо RIP, EBP вместо RBP). Попадётся 32-битный таск — заменяйте p64() на p32() и пересчитывайте offset.
Получив бинарник с CTF-сервера, первое действие — checksec. Утилита входит в pwntools и показывает, какие защиты включены при компиляции. От результата зависит, какая техника эксплуатации вообще применима.
Рассмотрим типичную задачу уровня beginner. Исходный код уязвимой программы:
#include <stdio.h>
#include <unistd.h>
void win() {
system("cat /flag"); // сюда надо попасть
}
int main() {
char buffer[64];
printf("Enter input: ");
read(0, buffer, 0x100); // читает 256 байт в 64-байтный буфер
return 0;
}
buffer[64] вмещает 64 байта, но read() готов записать 0x100 (256) байт. Разница в 192 байта — более чем достаточно, чтобы перезаписать saved RBP и адрес возврата. Функция win() вызывает system("cat /flag"), но ни одна легитимная ветка main() к ней не ведёт.
Запускаем checksec ./vuln и читаем вывод. Для учебной задачи характерная картина: Canary — disabled (нет проверки целостности стека), NX — enabled (стек неисполняемый), PIE — disabled (адреса функций фиксированные), RELRO — partial. Для ret2win нужно именно такое сочетание.
Адрес функции win() можно получить несколькими способами. Через pwntools: создаём объект e = ELF('./vuln') и обращаемся к e.symbols['win'] — получаем адрес, допустим 0x401196. Через GDB: команда info functions выведет все символы с адресами. Через утилиту nm: nm ./vuln | grep win работает для бинарников без stripped-символов.
От набора защит зависит применимость техники. Таблица ниже — по сути decision tree для выбора вектора атаки в pwn-задачах CTF:
| Защита | Механизм | Влияние на ret2win |
|---|---|---|
| Stack Canary | Случайное значение между буфером и saved RBP; проверяется перед ret | Блокирует — переполнение затрёт canary, программа вызовет __stack_chk_fail() |
| NX (DEP) | Запрещает выполнение кода на стеке | Не мешает — ret2win не кладёт шеллкод на стек |
| PIE | Рандомизирует базовый адрес бинарника при каждом запуске | Блокирует — адрес win() неизвестен без утечки |
| ASLR | Рандомизирует адреса стека, heap и libc (уровень ОС) | Не мешает, если PIE выключен — адреса бинарника фиксированы |
| Full RELRO | Делает GOT доступной только для чтения | Не влияет на ret2win |
Если checksec показывает Canary: enabled — ret2win в лоб не сработает, нужна утечка canary. Если PIE: enabled — без утечки адреса бинарника атака невозможна. Для задач категории beginner обе защиты обычно отключены.
Offset — количество байтов от начала буфера до адреса возврата. Теоретически для нашего примера: 64 байта буфер + 8 байт saved RBP = 72 байта. Но на практике компилятор может выравнивать стек, добавлять padding, менять layout переменных. Так что реальный offset находят экспериментально.
pwntools даёт функцию cyclic(), которая генерирует детерминированную последовательность байтов. Каждое окно из 4 (32-бит) или 8 (64-бит) байтов в этой последовательности уникально — по нему можно точно определить, какой фрагмент ввода оказался в конкретном месте памяти.
Генерируем паттерн длиной 200 байт (заведомо больше буфера): в терминале cyclic 200 или в Python-скрипте cyclic(200). Загружаем бинарник в GDB: gdb ./vuln. Запускаем командой run, вставляем сгенерированный паттерн. Программа упадёт с SIGSEGV — Segmentation fault. Процессор попытался прыгнуть по невалидному адресу, то есть мы перезаписали RIP. Отлично.
После краша смотрим содержимое RSP (stack pointer). В pwndbg панель регистров показывается автоматически. Если чистый GDB — x/gx $rsp. Увидите что-то вроде 0x7fffffffe228: 0x6161617461616173. Это 8 байт нашего паттерна, которые оказались на месте адреса возврата.
Теперь находим offset: cyclic -l 0x6161617461616173 в терминале или cyclic_find(0x6161617461616173) в Python. Результат — 72. Первые 72 байта заполняют буфер и saved RBP, а начиная с 73-го мы перезаписываем адрес возврата.
Проверка: отправляем 72 байта 'A' + 8 байт 'B' (python3 -c "print('A'*72 + 'B'*8)") в GDB. Если после краша в RSP видим 0x4242424242424242 — offset верный, мы контролируем адрес возврата. Если значение другое — пересчитываем. Альтернативный путь — через Metasploit: pattern_create.rb -l 200 для генерации и pattern_offset.rb -q <value> для поиска. Но pwntools удобнее — работает в том же Python-скрипте, что и эксплойт, не надо прыгать между инструментами.
Два числа — offset (72) и адрес win() (допустим, 0x401196) — это всё, что нужно для эксплойта. Собираем payload:
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
offset = 72
win_addr = elf.symbols['win']
payload = b'A' * offset + p64(win_addr)
p.sendlineafter(b'Enter input: ', payload)
p.interactive()
Разберём каждую строку, потому что именно тут новички обычно теряются.
ELF('./vuln') — загружает бинарник и парсит заголовки ELF-файла. Через elf.symbols доступна таблица символов: имена функций с их адресами. Для ret2win нужен только адрес win().
process('./vuln') — запускает бинарник как дочерний процесс и создаёт интерактивный канал ввода-вывода. Для атаки на удалённый CTF-сервер заменяется на remote('ctf.example.com', 1337) — API идентичный, менять в коде одну строку.
b'A' * offset — 72 байта мусора. Символ 'A' (0x41) — традиционный выбор, но подойдёт любой. Эти байты перезаписывают buffer[64] и saved RBP. Содержимое не важно — нам нужно просто «добраться» до адреса возврата.
p64(win_addr) — упаковывает 64-битный адрес в 8 байт формата little-endian. На x86-64 байты адреса хранятся в памяти от младшего к старшему: адрес 0x401196 превращается в \x96\x11\x40\x00\x00\x00\x00\x00. Функция p64() делает это автоматически и без ошибок в порядке байтов (руками на этом этапе ошибаются все). Для 32-битных бинарников — p32().
sendlineafter(b'Enter input: ', payload) — ждёт строку "Enter input: " от программы, затем отправляет payload. Метод sendline добавляет символ новой строки. Если программа читает через read() без разделителя — можно использовать send() без '\n'.
p.interactive() — переключает скрипт в интерактивный режим. Если win() содержит system("/bin/sh") — получите шелл. Если system("cat /flag") — увидите флаг прямо в терминале.
На 64-битных системах с glibc есть неприятное требование: перед вызовом функций вроде system() стек должен быть выровнен по 16 байтам. Если прыгнуть прямо на начало win(), внутренний вызов system() может упасть с SIGSEGV — не потому что адрес неправильный, а потому что стек не выровнен. Я на этом потерял час, и по форумам видно, что не я один.
Симптом: эксплойт правильно находит offset, правильно перезаписывает RIP, программа прыгает в win(), но падает внутри system(). В GDB видно, что краш происходит на инструкции movaps — она требует 16-байтного выравнивания.
Решение: добавить в payload один ret-gadget — адрес инструкции ret в бинарнике. Найти его можно через ROPgadget --binary ./vuln | grep ": ret" или через pwntools: ROP(elf).find_gadget(['ret'])[0]. Payload становится: b'A' * offset + p64(ret_gadget) + p64(win_addr). Дополнительный ret сдвигает стек на 8 байт и выравнивает его, после чего system() отрабатывает корректно.
Это не ROP-цепочка в полном смысле — это одиночный gadget для выравнивания. Но если не знать про этот нюанс, можно долго отлаживать правильный эксплойт, который почему-то не работает. Запомните: если win() падает на movaps — добавляйте ret перед адресом.
Когда эксплойт стабильно работает локально, заменяем process('./vuln') на remote('ctf.example.com', 1337). Для ret2win без PIE/ASLR адреса зашиты в сам бинарник, так что разница между локальным и удалённым запуском минимальна. Единственный нюанс: на удалённом сервере может отличаться тайминг, поэтому sendlineafter() надёжнее, чем sendline() с sleep().
Ret2win — входная точка в бинарную эксплуатацию. Её ограничения определяют, когда пора переходить к более серьёзным техникам.
Stack Canary включён. Переполнение затрагивает canary-значение между буфером и saved RBP. Перед ret функция проверяет canary и, обнаружив подмену, убивает процесс через __stack_chk_fail(). Обход: утечка canary через format string или info leak, затем включение правильного значения в нужную позицию payload. Это уже другой уровень.
PIE включён. Базовый адрес бинарника рандомизируется при каждом запуске. Адрес win() каждый раз разный. Обход: утечка любого адреса из бинарника (через format string или partial overwrite) и вычисление базы. Без дополнительного примитива чтения памяти ret2win с PIE невозможен.
Нет функции win(). Если бинарник не содержит готовой функции с system("/bin/sh") — прыгать некуда. Варианты: ret2libc (прыжок на system() из libc с аргументом "/bin/sh", найденным в памяти), ROP-цепочка (составление нужного вызова из фрагментов кода бинарника), ret2shellcode (если NX выключен). Каждая техника — отдельная большая тема, но все они строятся на том же фундаменте: контроль адреса возврата через переполнение.
NX выключен, но нет win(). Можно разместить шеллкод прямо в буфере и прыгнуть на него. Проблема: нужно знать точный адрес буфера на стеке (ASLR мешает). Классическое решение — NOP-sled: массив инструкций \x90 (NOP) перед шеллкодом, увеличивающий «зону попадания».
ASLR на уровне ОС. Рандомизирует адреса стека, heap и libc. Для ret2win без PIE не критично (адреса самого бинарника фиксированы), но для ret2libc или ret2shellcode — серьёзное препятствие. Отключить ASLR для локальной отладки: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. После отладки включить обратно: echo 2 | sudo tee /proc/sys/kernel/randomize_va_space. Не забывайте — на CTF-сервере ASLR включён всегда.
| Ситуация | Ret2win работает? | Альтернатива |
|---|---|---|
| No Canary, No PIE, NX on | Да | -- |
| Canary enabled | Нет | Утечка canary + ret2win |
| PIE enabled | Нет | Утечка базы + ret2win |
| Нет win(), NX on | Нет | ret2libc, ROP |
| Нет win(), NX off | Нет | ret2shellcode + NOP sled |
Весь процесс от компиляции до флага занимает 15 минут и требует одну Linux-машину.
Шаг 1. Создаём файл vuln.c с кодом уязвимой программы (код показан выше — main() с buffer[64] и функция win()).
Шаг 2. Компилируем с отключёнными защитами: gcc -fno-stack-protector -no-pie -z noexecstack -o vuln vuln.c. Флаг -fno-stack-protector убирает canary, -no-pie фиксирует адреса, -z noexecstack включает NX (для ret2win не помеха, но отражает реалистичную конфигурацию CTF-задач).
Шаг 3. Проверяем защиты: checksec ./vuln — ожидаем Canary: No, PIE: No, NX: Enabled.
Шаг 4. Создаём тестовый флаг: echo "CTF{buffer_overflow_101}" | sudo tee /flag.
Шаг 5. Запускаем pwntools-эксплойт из раздела выше. Видим содержимое /flag — первый ret2win пройден.
Шаг 6. Меняем размер буфера в исходнике (128 или 32 байта), перекомпилируем, заново ищем offset через cyclic. Каждый раз offset будет другим — это убирает иллюзию, что «offset всегда 72».
Шаг 7. Включаем Canary (-fstack-protector) и пробуем запустить тот же эксплойт. Наблюдаем *** stack smashing detected *** — теперь понятно, что именно canary блокирует и как это выглядит в терминале.
Для перехода к готовым задачам — на PicoCTF (picoctf.org) есть категория Binary Exploitation с прогрессией от простого переполнения до ROP.
За три года решения pwn-задач я заметил закономерность: те, кто начинают с ret2win и тратят время на понимание стекового фрейма — потом осваивают ROP и heap exploitation за недели. Те, кто копирует готовые скрипты из writeup'ов без понимания, почему offset именно такой — застревают на каждой следующей задаче. Buffer overflow в CTF — это не про запоминание последовательности «cyclic → offset → p64 → sendline». Это про построение ментальной модели: как данные лежат в памяти, что происходит при вызове функции, почему ret берёт значение именно с RSP. Когда модель есть — любой новый класс уязвимости становится вариацией на знакомую тему.
Подход «сначала прочитаю учебник по системному программированию, потом сяду за GDB» — не работает. Я трижды перечитывал главу про стек и ничего не запоминал. А потом увидел 0x4141414141414141 в RSP после краша — и всё встало на место за один вечер. Стековый фрейм становится понятным, когда ты руками наблюдаешь, как данные затирают saved RBP. Не раньше. Формула в голове и формула в GDB — разные вещи: buffer overflow ощущается по-настоящему, когда в дампе памяти видишь, как твои 0x41 ползут через saved RBP и затирают адрес возврата. Если после этой статьи у вас появилось понимание не только «что», но и «почему» — двигайтесь дальше, к ROP и format string. Если хочешь не просто решать pwn-таски, а пройти всю атаку от initial access до post-exploitation с лабами — на WAPT эту цепочку проходят в модулях с ментором.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...