
Мой первый ret2win-таск на CTF занял восемь часов. Шесть из них — на одну ошибку: payload оказался на четыре байта короче нужного. Вместо адреса win() в EIP попадал хвост мусорных 0x41, GDB показывал SIGSEGV без полезной информации, а мотивация стремилась к нулю. Потом товарищ по команде глянул скрипт и спокойно сказал: «saved EBP в offset не учёл». Четыре байта padding — и $ в терминале. Этот разбор pwn-таска buffer overflow — инструкция, которую хотелось иметь перед тем турниром: каждый шаг от checksec до появления shell, включая грабли, на которых теряют часы.
Ret2win — простейший сценарий бинарной эксплуатации на CTF. В бинарнике лежит функция (обычно win(), flag(), shell()), которую штатно никто не вызывает. Задача — переполнить буфер на стеке, перезаписать адрес возврата адресом этой функции и заставить процессор выполнить её вместо нормального return.
Если маппить на MITRE ATT&CK — переполнение буфера с перехватом управления ближе всего к Exploitation for Client Execution (T1203, Execution) или к Exploitation for Privilege Escalation (T1068), когда уязвимый бинарь работает с повышенными привилегиями. Маппинг условный: T1203 про client-side атаки (браузеры, офисные пакеты), а не учебные CTF-бинарники, но сам примитив — перехват потока выполнения через memory corruption — тот же.
Ret2win на CTF срабатывает при трёх условиях:
__stack_chk_fail убьёт процесс до выполнения ret.win() рандомизируется и нужна утечка базы ELF.ELF.symbols в pwntools или через дизассемблирование.NX (неисполняемый стек) для ret2win не помеха: мы не кладём shellcode на стек, а перенаправляем поток выполнения в код, который уже лежит в секции .text бинарника. Принципиальное отличие ret2win от инъекции shellcode — и причина, по которой техника остаётся рабочей даже при NX enabled. Как пишут на HackTricks: «The goal is to exploit a vulnerability in a given binary to execute a specific, uninvoked function within the binary» — NX тут не при чём.
Pwn-таск начинается не с payload, а с разведки. checksec --file=./vuln (утилита входит в pwntools) показывает профиль защит. Четыре строки определяют, какой путь эксплуатации вообще открыт:
No означает, что переполнение доходит до адреса возврата без срабатывания защиты.Enabled запрещает исполнение кода на стеке. Для ret2win не критично, для shellcode — стоп.No фиксирует базу ELF. Адрес win() будет одинаковым между запусками.Partial или Full влияет на перезапись GOT, но для базового ret2win роли не играет.Ещё проверяем архитектуру: file ./vuln. Разрядность определяет размер адреса (4 или 8 байт), функцию упаковки (p32 vs p64) и calling convention. Для первого таска берём 32-bit — меньше подводных камней с выравниванием стека.
Минимальный уязвимый бинарь в стиле ret2win:
#include <stdio.h>
#include <stdlib.h>
void win() { system("/bin/sh"); }
void vuln() {
char buf[64];
gets(buf); // классика жанра — читает до \n без оглядки на размер
}
int main() { vuln(); return 0; }
gets() читает ввод до символа новой строки без проверки длины — каноническая уязвимость переполнения буфера на стеке. Компилируем с отключёнными защитами: gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. Флаг -m32 даёт 32-битную сборку, -fno-stack-protector убирает canary, -no-pie фиксирует адреса. -z execstack делает стек исполняемым и отключает NX — для ret2win это необязательно (NX не мешает, как описано выше), но пригодится при экспериментах с shellcode. Из-за этого флага checksec покажет NX — Disabled; для чистой демонстрации ret2win при NX Enabled флаг можно убрать. После компиляции checksec --file=./vuln должен показать: Canary — No, NX — Disabled (или Enabled без -z execstack), PIE — No. Если хотя бы одна защита включена — перепроверяйте флаги.
Offset — количество байт от начала буфера до адреса возврата на стеке. Если число неверное, адрес win() ляжет не в ту позицию, и вместо shell — SIGSEGV без полезной диагностики. Знакомо, да?
Считать offset по исходному коду — ошибка, на которую наступают все новички. Компилятор добавляет выравнивание (alignment padding), и реальное расстояние от buf[0] до saved EIP почти всегда отличается от наивного sizeof(buf) + 4 (где +4 — saved EBP). Вместо ручного подсчёта используем cyclic pattern — последовательность Де Брёйна, где каждая подстрока длиной 4 байта (для 32-bit) уникальна. По любому 4-байтному фрагменту можно однозначно определить его позицию в исходной строке.
Порядок действий. Открываем бинарь в GDB с pwndbg: gdb ./vuln. Генерируем паттерн командой cyclic 200 прямо внутри отладчика — или в отдельном терминале через python3 -c "from pwn import *; print(cyclic(200).decode())". Запускаем: run, вставляем паттерн на ввод. Бинарь падает с SIGSEGV, потому что EIP получил «адрес», который на самом деле — фрагмент паттерна. pwndbg подсветит значение EIP красным — допустим, 0x6161616c.
Определяем позицию: cyclic -l 0x6161616c. pwntools вернёт число — это и есть offset. Для buf[64] на 32-bit типичное значение в диапазоне 68–80 байт, но конкретная цифра зависит от версии GCC и флагов компиляции. Именно поэтому угадывать бессмысленно — cyclic даёт точный ответ.
Верификация. Перед написанием эксплойта обязательно подтверждаем offset: подаём строку из offset символов 'A' и четырёх символов 'B'. Если EIP стал 0x42424242 ('B' = 0x42 в ASCII) — offset верный. Другое значение — пересчитываем. 30 секунд на проверку экономят часы отладки. Пропуск этого шага — причина номер один затянувшегося разбора у тех, кто только начинает решать CTF pwn для начинающих. Я на своём первом таске именно тут и застрял (см. вступление).
Частая путаница: после crash нужно смотреть именно EIP (на 32-bit) или RIP (на 64-bit) — это регистр, куда попал перезаписанный адрес возврата. ESP/RSP в этот момент указывает на вершину стека и к offset не относится.
Offset найден, адрес win() вытаскиваем: elf = ELF('./vuln'); print(hex(elf.symbols['win'])) или в GDB командой p &win. Допустим, адрес — 0x08049196. PIE отключён — значение стабильно между запусками. Собираем payload:
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
offset = 76 # получен через cyclic, проверен через 0x42424242
payload = b'A' * offset
payload += p32(elf.symbols['win'])
p.sendline(payload)
p.interactive()
Разбор по строкам — для тех, кто пишет эксплойт впервые. ELF('./vuln') парсит ELF-файл, вытаскивает таблицу символов и автоматически выставляет context.binary — после этого p32() знает архитектуру и порядок байт без явного указания context.arch. elf.symbols['win'] возвращает адрес функции без ручного ковыряния через objdump.
process('./vuln') запускает бинарь локально. Для удалённого CTF-сервера — одна замена: remote('ctf.example.com', 1337), остальной скрипт без изменений. В этом сила pwntools: один и тот же эксплойт работает и на локальной машине, и на remote.
b'A' * offset — мусорный padding, заполняющий буфер и saved EBP. Символ 0x41 виден в hex-дампе при отладке — сразу понятно, где наши данные.
p32(elf.symbols['win']) упаковывает адрес в 4 байта little-endian. Адрес 0x08049196 в памяти хранится как \x96\x91\x04\x08 — младший байт первым. Как пишет ir0nstone в ret2win notes: «the bytes have been reversed, and the reason for this reversal is endianness». p32() делает конвертацию автоматически — ручная запись адреса «в человеческом порядке» ломает exploit молча, и вы будете час пялиться в GDB, не понимая, почему всё правильно, но не работает.
p.sendline() отправляет payload с \n — gets() ждёт перенос строки. p.interactive() передаёт ввод-вывод в терминал. Запуск: python3 exploit.py. Появился $ — переполнение буфера на стеке сработало, адрес возврата перезаписан, процессор выполнил system("/bin/sh"). Красота.
Причины: неправильно прочитали hex из EIP, перепутали hex и decimal при cyclic -l, пропустили верификацию через 0x42424242. Бывает и тоньше: на одной машине offset один, на другой — другой, потому что разные версии GCC или glibc. Поэтому cyclic pattern нужно прогонять именно на целевой системе, а не на своём ноутбуке.
С включённым ASLR стек и библиотеки рандомизируются при каждом запуске. Для ret2win с отключённым PIE это не критично — адреса внутри самого ELF фиксированы. Но если PIE тоже включён — адрес win() плывёт и эксплойт ломается. Проверяем: cat /proc/sys/kernel/randomize_va_space. Значение 0 — ASLR выключен. Для учебных задач: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. Вернуть обратно: echo 2 | sudo tee /proc/sys/kernel/randomize_va_space.
На 64-битной архитектуре стек должен быть выровнен на 16 байт перед вызовом функций. Если после перезаписи адреса возврата выравнивание нарушено, инструкция movaps внутри system() из libc генерирует SIGSEGV — хотя адрес правильный и управление формально передано.
Вот это ловушка, на которую попадают все, кто перешёл с 32-bit без подготовки. Эксплойт выглядит корректным, адрес верный, а shell не появляется. Сидишь, смотришь — всё правильно. А оно не работает. Решение: перед адресом win() добавить один gadget ret (одиночную инструкцию ret из бинарника), который сдвинет RSP на 8 байт и восстановит alignment. Найти gadget: ROPgadget --binary ./vuln | grep ": ret$". На 32-bit этой проблемы нет — поэтому первые таски лучше решать именно на 32-битных бинарниках.
Ret2win работает в тепличных условиях — защиты отключены. В реальных бинарниках каждая контрмера блокирует конкретный шаг эксплойта. По классификации MITRE D3FEND:
__stack_chk_fail убивает процесс. Обход: утечка через format string, brute-force на fork-серверах.ret значение сравнивается с shadow stack — подмена детектируется. Обход сильно сложнее и выходит за рамки базовых CTF.На стороне мониторинга: в репозитории SigmaHQ есть правило lnx_buffer_overflows.yml, которое срабатывает на сообщения ядра Linux о переполнении буфера — полезно для blue team в продакшне.
Каждая защита ломает конкретный шаг эксплойта. Canary — перезапись. ASLR — фиксированные адреса. NX — shellcode на стеке. Бинарная эксплуатация — не «знать одну технику», а уметь подобрать подход под конкретный набор включённых защит. Ret2win учит первому звену цепочки: контроль EIP через переполнение стека. Всё остальное — ret2libc, ROP-цепочки, format string leaks — наслаивается поверх.
Два года наблюдаю закономерность: новички в pwn тратят 80% времени не на понимание уязвимости, а на борьбу с окружением. Не та версия libc, забытый флаг компиляции, несовпадение Python 2 и 3 в скриптах из writeup'ов 2018 года. Сам ret2win как примитив — 15 минут работы при настроенной инфраструктуре. Но перескакивать через базу не стоит: пока offset не находится за две минуты, а checksec не читается с первого взгляда — двигаться к ROP-цепочкам рано. Стек нужно чувствовать через отладчик, а не по картинкам. Writeup'ы полезны для идеи, но навык формируется когда сидишь перед терминалом и дебажишь свой сломанный payload без подсказок, наступая на каждый из описанных выше граблей. Если хочешь не просто writeup, а пройти всю цепочку от ret2win до ROP-эксплуатации самостоятельно с лабами на каждый вектор — на WAPT эту прогрессию выстроили модуль за модулем.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...