
На picoCTF 2022 задача Buffer Overflow 1 собрала сотни решений при стоимости 200 очков — 32 байта буфера, один вызов gets() без проверки длины и функция win(), которую нужно вызвать, перезаписав адрес возврата. На свой первый рабочий эксплойт я потратил полтора часа — не потому что задача сложная, а потому что не понимал, что происходит на стеке между моим вводом и тем моментом, когда процессор решает, куда прыгнуть дальше. Этот разбор — тот гайд по buffer overflow CTF, который я хотел бы прочитать до первой pwn-задачи: от устройства стека до рабочего скрипта на pwntools, с честным списком ошибок, которые стабильно стоят новичкам по часу каждая.
Прежде чем писать эксплойт для stack buffer overflow exploit, нужно разобраться, что именно мы переполняем и зачем. Стек — область памяти, где хранятся локальные переменные функций, аргументы вызовов и адреса возврата. Ключевое свойство: стек растёт вниз, от старших адресов к младшим. Каждый раз при вызове функции на вершине стека создаётся стековый кадр (stack frame) — блок памяти с тремя элементами, без понимания которых binary exploitation не зайдёт. Подробнее — в нашем статье о бинарный анализ уязвимостей.
Буфер (Buffer Space) — область для локальных переменных. Если в коде написано char buf[32], компилятор выделяет на стеке минимум 32 байта под этот массив. Данные записываются в буфер от младших адресов к старшим — снизу вверх, если смотреть на стек.
Сохранённый EBP (Saved Base Pointer) — значение регистра EBP вызывающей функции. На 32-битной архитектуре x86 это 4 байта, на x86-64 — 8 байт (регистр называется RBP). Процессор сохраняет его, чтобы после возврата из функции восстановить стековый кадр вызывающей стороны.
Адрес возврата (Return Address / Saved EIP) — адрес инструкции, к которой процессор перейдёт при выполнении ret. На x86 — 4 байта, на x86-64 — 8. Это главная цель при переполнении стека: записал сюда адрес нужной функции — процессор прыгнет туда вместо штатного возврата.
Расположение в памяти (младшие адреса внизу): сначала буфер, затем сохранённый EBP, затем адрес возврата. Когда gets() записывает входные данные в буфер без проверки длины, данные идут от начала буфера вверх по адресам. Входных данных больше, чем размер буфера — они последовательно перезаписывают сохранённый EBP и адрес возврата. Это и есть переполнение буфера на стеке — stack buffer overflow.
Перезаписав адрес возврата адресом функции win(), мы заставляем программу выполнить win() вместо нормального возврата. В CTF эту технику называют ret2win — return to win function. Простейший тип pwn-задач CTF и отправная точка для binary exploitation.
Функция gets() читает строку из stdin до символа новой строки (\n) и записывает её в указанный буфер. Проблема: gets() не принимает параметр длины — она пишет столько байт, сколько получит на вход. Буфер рассчитан на 32 байта, а мы отправили 100 — остальные 68 байт перезапишут всё, что лежит выше по стеку: EBP, return address и далее.
В реальных проектах gets() давно помечена как deprecated — GCC при компиляции выводит warning: the 'gets' function is dangerous. Но в CTF-задачах для начинающих она встречается постоянно как наглядная дыра. Аналогичный эффект дают strcpy() без проверки длины и read() с размером, превышающим буфер. В задаче Space Pirate: Going Deeper с Cyber Apocalypse CTF 2022, разобранной в блоге HackTheBox, уязвимость была в read(0, local_38, 0x39) — буфер на 40 байт, чтение до 57 (0x39) байт.
Разберём классическую задачу picoCTF «Buffer Overflow 1». Исходный код предоставлен организаторами — в реальных pwn-задачах среднего уровня исходников обычно нет, и приходится ковыряться в дизассемблере. Но для первого переполнения стека наличие исходника — подарок: можно видеть уязвимость глазами, а не гадать по ассемблеру.
sudo apt install gdb python3-pip && pip install 'pwntools>=4.12.0'. Плагин pwndbg — клонирование репозитория и запуск setup.shncКлючевые фрагменты исходного кода задачи — тут нужно увидеть три вещи: функцию win(), которая выводит флаг, уязвимый gets() в vuln() и буфер размером 32 байта:
void win() {
char buf[64];
FILE *f = fopen("flag.txt","r");
fgets(buf, 64, f);
printf(buf);
}
void vuln(){
char buf[32];
gets(buf);
}
int main(int argc, char **argv){
puts("Please enter your string: ");
vuln();
return 0;
}
Функция win() нигде не вызывается из main() или vuln() — нормальный поток выполнения до неё никогда не дойдёт. Задача: перенаправить выполнение на win() через перезапись адреса возврата в vuln().
Первый шаг с любым бинарником в pwn — checksec. Утилита из набора pwntools показывает, какие защиты вкомпилированы в бинарник. Запускаем checksec ./vuln и смотрим. Строка Arch: i386-32-little определяет, p32() или p64() использовать при упаковке адресов. Остальные параметры влияют на подход к эксплуатации:
Canary: No canary found. Стековая канарейка — случайное значение между локальными переменными и адресом возврата. Перед возвратом из функции программа проверяет, не изменилось ли оно. Канарейка перезаписана — программа падает с *** stack smashing detected ***, не дожидаясь ret. Нет канарейки — переполняем буфер свободно.
NX: NX enabled. Стек помечен как неисполняемый. Шелл-код в буфере разместить и прыгнуть на него не выйдет. Но для ret2win это неважно — мы прыгаем на существующую функцию, а не на свой код.
PIE: No PIE. Адреса функций в бинарнике фиксированные. Адрес win() будет одинаковым при каждом запуске. С включённым PIE базовый адрес бинарника рандомизировался бы, и пришлось бы сначала «слить» (leak) реальный адрес какой-либо функции, чтобы вычислить остальные.
Комбинация «нет canary + нет PIE» — визитная карточка ret2win задач для начинающих. Увидел такой вывод checksec — перезаписывай return address фиксированным адресом win(). Самый простой сценарий в binary exploitation, и именно с него стоит начинать.
Теперь нужно определить два числа: offset — количество байт от начала буфера до начала адреса возврата, и адрес функции win().
Загружаем бинарник: gdb ./vuln. Для нахождения offset используем циклический паттерн — последовательность символов, в которой любые 4 подряд идущих байта уникальны. В pwndbg это cyclic 100, в Python — from pwn import *; print(cyclic(100)). Запускаем программу командой run, вставляем паттерн на запрос ввода.
Программа падает с Segmentation fault. Регистр EIP содержит 4 байта из нашего паттерна — ту часть, которая перезаписала адрес возврата. По данным из writeup на CTFtime, при подаче паттерна длиной 100 символов программа показывает Jumping to 0x35624134, и в pwndbg регистр EIP отображает 0x35624134.
Чтобы узнать offset, выполняем в pwndbg: cyclic -l 0x35624134. Результат — 44. Первые 44 байта заполняют буфер, выровненный компилятором до 40 байт (вместо запрошенных 32), плюс сохранённый EBP (4 байта). Padding добавляется внутри области буфера, а не после EBP. Следующие 4 байта попадают ровно в адрес возврата.
Тут ключевой момент, на котором я сам зависал. Буфер 32 байта, EBP 4 байта — казалось бы, offset должен быть 36. Но GCC добавляет выравнивание (alignment padding) между переменными на стеке. Точный offset из исходного кода не вычислить — только через cyclic-паттерн или ручной перебор. Именно поэтому gdb отладка эксплойта — обязательный этап, а не «ну если хочется».
Адрес win() находим командой info functions в GDB. В списке видим 0x080491f6 win — фиксированный адрес, на который нужно перенаправить выполнение. ASLR в задаче отключен, адрес не меняется между запусками — проверяется двумя подключениями к удалённому сервису через nc.
Все компоненты готовы: offset = 44, адрес win = 0x080491f6. Собираем эксплойт:
from pwn import *
elf = ELF('./vuln')
p = remote('saturn.picoctf.net', PORT) # PORT указан на странице задачи в личном кабинете picoCTF
payload = b'A' * 44
payload += p32(elf.symbols['win'])
p.sendline(payload)
p.interactive()
Разберём построчно. ELF('./vuln') загружает бинарник и парсит его структуру — имена функций, секции, символы. Через elf.symbols['win'] получаем адрес win() без хардкода: пересобрали бинарник с другим адресом — скрипт подхватит автоматически. Можно и напрямую p32(0x080491f6), но через символы надёжнее.
p32() упаковывает 4-байтовый адрес в little-endian. На x86 младший байт идёт первым: 0x080491f6 превращается в \xf6\x91\x04\x08. Попытка записать адрес «как есть» — гарантированный провал.
remote() — TCP-соединение с сервером задачи (аналог nc). Для локального тестирования меняем на process('./vuln') — pwntools запустит бинарник как дочерний процесс. sendline() отправляет payload с \n в конце, interactive() переключает терминал в интерактивный режим. Переполнение стека сработало — в выводе появляется picoCTF{...}.
Запускаем python3 exploit.py, забираем флаг. Первый CTF pwn writeup готов.
За пятьдесят решённых ret2win-задач у меня накопилась коллекция ошибок, которые стабильно крадут часы у каждого начинающего. Делюсь.
Ручной расчёт offset по исходному коду. Буфер 32 байта, EBP 4 байта — offset = 36? Почти никогда. Компилятор выравнивает область буфера в большую сторону (до 40, 44, 48 байт), и реальный offset может быть совсем другим. В picoCTF Buffer Overflow 1 offset = 44 при буфере в 32 байта — компилятор расширил буфер до 40 байт + 4 байта saved EBP. Offset определяется через cyclic-паттерн, и только через него.
Запись адреса в big-endian. x86 хранит данные в little-endian: младший байт по младшему адресу. Адрес 0x080491f6 записывается как \xf6\x91\x04\x08, а не \x08\x04\x91\xf6. Функция p32() из pwntools решает это автоматически. На x86-64 — p64() для 8-байтовых адресов.
Пропуск checksec. Если в бинарнике включён stack canary, простое переполнение вызовет *** stack smashing detected *** — программа упадёт до ret, и адрес возврата никогда не будет использован. Включён PIE — адрес win() из info functions окажется смещением относительно базы, а не абсолютным адресом. checksec перед началом работы — без исключений.
Путаница между 32-bit и 64-bit. На x86 адрес — 4 байта, на x86-64 — 8. Отправил 4-байтовый адрес в 64-битный бинарник — ret прочитает 8 байт и прыгнет на мусор. Архитектуру определяет checksec (строка Arch: i386-32-little или amd64-64-little) и команда file vuln.
Тестирование только локально. Эксплойт работает на локальной машине, но молча ломается на сервере. Причина: разные версии libc могут дать разное выравнивание стека. Особенно критично для 64-битных ret2libc-задач, где system() использует инструкцию movaps, требующую 16-байтового выравнивания. Для простого ret2win обычно не проблема (функция win() делает собственный пролог), но если win() внутри вызывает system()/printf() — может понадобиться дополнительный ret-гаджет перед адресом win().
Отправка payload через echo/printf в терминале. Новички пытаются echo -e 'AAAA...\xf6\x91\x04\x08' | nc ... вместо pwntools. Шелл может интерпретировать спецсимволы, обрезать нулевые байты или добавить лишний перевод строки. Pwntools отправляет ровно те байты, которые указаны — без сюрпризов.
CTF-задачи — контролируемая среда с отключёнными защитами. Но переполнение буфера на стеке — один из старейших и до сих пор эксплуатируемых классов уязвимостей. По данным Mandiant M-Trends 2025, exploits составляют 33–38% всех случаев initial access — первое место среди всех векторов, опережая фишинг. Значительная часть этих эксплойтов использует именно повреждение памяти: переполнение буфера, use-after-free, целочисленное переполнение.
В терминологии MITRE ATT&CK переполнение буфера встраивается в несколько тактик:
Initial Access — Exploit Public-Facing Application (T1190). Эксплуатация уязвимости в сетевом сервисе: веб-сервер, FTP-демон, почтовый шлюз. Атакующий отправляет специально сформированный запрос, вызывающий переполнение, и получает shell. Ближайший аналог того, что мы делаем в CTF через remote().
Execution — Exploitation for Client Execution (T1203). Уязвимость в клиентском приложении: PDF-просмотрщик, медиаплеер, офисный редактор. Жертва открывает вредоносный файл, парсер переполняется, код атакующего выполняется в контексте приложения.
Privilege Escalation — Exploitation for Privilege Escalation (T1068). Локальное повышение привилегий через уязвимый suid-бинарник или драйвер ядра. Атакующий уже на машине с низкими привилегиями и использует переполнение в привилегированном процессе, чтобы получить root.
Техника ret2win работает исключительно в учебных условиях. Применимость: CTF-задачи для начинающих, legacy-бинарники без защит (да, такие до сих пор встречаются в embedded-системах и промышленных контроллерах). Для реального пентеста и продвинутых CTF прямая перезапись return address фиксированным адресом бесполезна из-за четырёх уровней защиты.
ASLR (Address Space Layout Randomization) — рандомизация адресов стека, кучи и библиотек при каждом запуске. Включён по умолчанию везде. Обход: info leak — утечка адреса через format string или частичную перезапись.
Stack Canary — случайное значение между буфером и return address, проверяемое перед ret. GCC включает по умолчанию с -fstack-protector. Обход: утечка канарейки через побочный канал, brute-force на форках без rerandomization.
NX/DEP (Non-Executable Stack) — запрет исполнения кода на стеке. Включён по умолчанию. Обход: ROP (Return-Oriented Programming) — цепочки коротких фрагментов существующего кода, заканчивающихся на ret.
PIE (Position Independent Executable) — рандомизация базового адреса самого бинарника. Обход: info leak базового адреса через уязвимость чтения.
В реальной эксплуатации все четыре защиты обычно включены одновременно, и для arbitrary code execution нужно обойти каждую. Но каждый ROP-гаджет строится на том же принципе, который мы разобрали: контроль над тем, куда прыгает ret. Ret2win — фундамент, на котором стоят все продвинутые техники binary pwn exploitation.
Из пяти десятков решённых ret2win-задач и последующего перехода к ROP-цепочкам у меня сложилась чёткая позиция: переполнение буфера — не «одна техника» и не археология 2003 года, а действующий фундамент binary exploitation. Каждый следующий уровень — ROP, heap exploitation, format string — использует один и тот же принцип: запись данных туда, где программа ожидает управляющие структуры. Но я раз за разом наблюдаю, как новички пытаются перескочить ret2win и сразу браться за heap-задачи, потому что «стек — это банально». Результат предсказуем: через неделю человек бросает binary exploitation со словами «слишком сложно». Мой подход другой — десять решённых ret2win-задач до первого прикосновения к ROP. Первые пять уходят по часу, следующие пять — по пятнадцать минут. После двадцатой начинаешь видеть стековый кадр в голове, без GDB. Именно в этот момент переход к Return-Oriented Programming перестаёт казаться пропастью, потому что каждый ROP-гаджет — это тот самый ret2win, только адресов в payload не один, а двадцать. Для тех, кому writeup-ов недостаточно и нужна прогрессия с лабами под ментором — WAPT покрывает эту цепочку от базовых переполнений до OSCP-стандарта.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...