Главная / Блог / Разбор pwn-таска buffer overflow с нуля

10 мин.00

Разбор pwn-таска buffer overflow с нуля

Разбор pwn-таска buffer overflow с нуля

Мой первый ret2win-таск на CTF занял восемь часов. Шесть из них — на одну ошибку: payload оказался на четыре байта короче нужного. Вместо адреса win() в EIP попадал хвост мусорных 0x41, GDB показывал SIGSEGV без полезной информации, а мотивация стремилась к нулю. Потом товарищ по команде глянул скрипт и спокойно сказал: «saved EBP в offset не учёл». Четыре байта padding — и $ в терминале. Этот разбор pwn-таска buffer overflow — инструкция, которую хотелось иметь перед тем турниром: каждый шаг от checksec до появления shell, включая грабли, на которых теряют часы.

Ret2win: базовая техника эксплуатации buffer overflow

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 canary отключён — иначе перезапись за границей буфера изменит canary-значение, __stack_chk_fail убьёт процесс до выполнения ret.
  • PIE отключён — адреса внутри бинарника фиксированы при каждом запуске. С PIE адрес win() рандомизируется и нужна утечка базы ELF.
  • Адрес win-функции известен — вытаскивается из таблицы символов через 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 тут не при чём.

Checksec: анализ бинарника перед написанием эксплойта

Pwn-таск начинается не с payload, а с разведки. checksec --file=./vuln (утилита входит в pwntools) показывает профиль защит. Четыре строки определяют, какой путь эксплуатации вообще открыт:

  • CanaryNo означает, что переполнение доходит до адреса возврата без срабатывания защиты.
  • NXEnabled запрещает исполнение кода на стеке. Для ret2win не критично, для shellcode — стоп.
  • PIENo фиксирует базу ELF. Адрес win() будет одинаковым между запусками.
  • RELROPartial или 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. Если хотя бы одна защита включена — перепроверяйте флаги.

GDB отладка: поиск offset через cyclic pattern

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 не относится.

Написание эксплойта buffer overflow на Python pwntools

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 с \ngets() ждёт перенос строки. p.interactive() передаёт ввод-вывод в терминал. Запуск: python3 exploit.py. Появился $ — переполнение буфера на стеке сработало, адрес возврата перезаписан, процессор выполнил system("/bin/sh"). Красота.

Типичные ошибки при отладке эксплойта переполнения буфера

Неверный offset — самая частая проблема

Причины: неправильно прочитали hex из EIP, перепутали hex и decimal при cyclic -l, пропустили верификацию через 0x42424242. Бывает и тоньше: на одной машине offset один, на другой — другой, потому что разные версии GCC или glibc. Поэтому cyclic pattern нужно прогонять именно на целевой системе, а не на своём ноутбуке.

ASLR и PIE включены одновременно

С включённым 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.

Stack alignment на x86-64

На 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-битных бинарниках.

ASLR и NX: защиты стека против buffer overflow по MITRE D3FEND

Ret2win работает в тепличных условиях — защиты отключены. В реальных бинарниках каждая контрмера блокирует конкретный шаг эксплойта. По классификации MITRE D3FEND:

  • Stack Frame Canary Validation (D3-SFCV) — canary-значение между буфером и адресом возврата. Перезапись буфера меняет canary, __stack_chk_fail убивает процесс. Обход: утечка через format string, brute-force на fork-серверах.
  • Segment Address Offset Randomization (D3-SAOR) — ASLR рандомизирует адреса библиотек и стека. С PIE рандомизируется и сам ELF. Обход: утечка адреса через PLT/GOT и пересчёт базы libc (это уже ret2libc).
  • Shadow Stack Comparisons (D3-SSC) — аппаратная теневая копия адресов возврата. При ret значение сравнивается с shadow stack — подмена детектируется. Обход сильно сложнее и выходит за рамки базовых CTF.
  • Memory Boundary Tracking (D3-MBT) — детектирование out-of-bounds записи через AddressSanitizer или аппаратные расширения.

На стороне мониторинга: в репозитории 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 комментариев

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

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