Главная / Блог / Buffer Overflow в CTF: от переполнения стека до захвата флага с pwntools

14 мин.00

Buffer Overflow в CTF: от переполнения стека до захвата флага с pwntools

Buffer Overflow в CTF: от переполнения стека до захвата флага с pwntools

Buffer Overflow в CTF: от переполнения стека до захвата флага с pwntools

Первая pwn-задача на CTF обычно заканчивается одинаково: бинарник падает с Segmentation fault, терминал молчит, а ты сидишь и не понимаешь, что вообще произошло. Мой первый ret2win-таск занял четыре часа — три из которых ушли на выяснение, почему offset оказался 72, а не 64. Ответ: saved RBP занимает 8 байт на x86-64, и ни один русскоязычный гайд мне об этом явно не сказал. Эта статья — тот разбор, который я хотел прочитать в тот момент: от устройства стека до работающего эксплойта на pwntools, без пропущенных шагов и без «это очевидно».

Что происходит в памяти при stack buffer overflow

Когда функция вызывается в 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.

Разведка бинарника: checksec и защиты

Получив бинарник с 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 через cyclic pattern в GDB

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-скрипте, что и эксплойт, не надо прыгать между инструментами.

Пишем ret2win эксплойт на pwntools

Два числа — 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") — увидите флаг прямо в терминале.

Stack alignment на x86-64: ловушка для новичков

На 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 и когда техника не работает

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 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...