
Первый segfault при решении pwn-задачи выглядит у всех одинаково: регистр RIP забит значением 0x4141414141414141, GDB сообщает Cannot access memory at address, программа мертва. Пять минут назад ты отправил строку из 200 символов 'A' в бинарник с соревнования — и перезаписал адрес возврата на стеке. Поздравляю, ты только что сломал программу самым тупым способом из возможных. И это правильный первый шаг. Переполнение буфера (buffer overflow) — точка входа в binary exploitation и первая техника, с которой начинается путь в категории pwn на CTF. Ниже разберём этот путь целиком: разведка через checksec, поиск смещения в GDB через cyclic-паттерн, готовый ret2win-эксплойт на pwntools и нюанс с выравниванием стека на x86-64, на котором спотыкаются все и о котором почти нигде не пишут по-русски.
При вызове функции в x86-64 инструкция call кладёт на стек восьмибайтовый адрес возврата (return address) — адрес следующей инструкции в вызывающей функции, куда процессор вернётся после ret. Пролог вызванной функции делает push rbp (сохраняет старый base pointer — ещё 8 байт) и sub rsp, N (сдвигает вершину стека вниз, выделяя место под локальные переменные).
Для функции с объявлением char buf[64] расклад на стеке такой: ближе к вершине (низкие адреса) лежат 64 байта буфера. Над ними — 8 байт saved RBP. Ещё выше — 8 байт return address. Массив buf заполняется от низких адресов к высоким. Если функция читает ввод без проверки длины — через gets(), strcpy() или read() с размером больше буфера — данные за пределами 64 байт продолжают писаться вверх по стеку: сначала затирают saved RBP, а следующие 8 байт ложатся прямо на адрес возврата.
Конкретный пример. Функция vuln() начинает выполнение, на стеке расположены: 64 байта buf (нули), 8 байт saved RBP (0x00007fffffffe3c0), 8 байт return address (0x0000000000401183 — адрес в main() после call vuln). Отправляем 80 символов 'A' (0x41). Первые 64 заполняют buf. Следующие 8 перезаписывают saved RBP на 0x4141414141414141. Оставшиеся 8 — перезаписывают return address тем же значением. При выполнении ret процессор берёт 0x4141414141414141 со стека, загружает в RIP, пытается прочитать инструкцию по этому адресу — и падает с segfault. Но если вместо 'A' подставить адрес функции win() — процессор послушно туда перейдёт. Контроль над адресом возврата — это контроль над потоком выполнения. Всё просто, пока не начнёшь делать.
На x86 (32-бит) механика та же, но указатели занимают 4 байта, и вместо RIP/RBP используются EIP/EBP (так называемый EIP overwrite). Многие учебные writeup'ы — включая материалы ctf101.org и UCSD — показывают 32-битные примеры, но задачи CTF-соревнований 2024–2025 годов (picoCTF, HTB, MetaCTF) практически всегда выдают x86-64 бинарники. Если учишься по 32-битным гайдам — будь готов пересчитывать.
Термин "buffer overflow" объединяет целый класс уязвимостей: heap overflow (переполнение в динамической памяти — куче), integer overflow (целочисленное переполнение, приводящее к некорректному выделению памяти), off-by-one (запись одного лишнего байта, способная сдвинуть base pointer). Stack buffer overflow — самый предсказуемый вариант: буфер на стеке, рядом — адрес возврата, между ними — фиксированное расстояние, вычислимое через отладчик. Поэтому category pwn на CTF начинается со стекового переполнения: механика детерминирована, а результат (перехват RIP) виден в GDB немедленно. Heap exploitation — это уже другой уровень боли, туда потом.
Для воспроизведения всех шагов нужен Linux — Ubuntu 20.04+, Kali или любой дистрибутив с установленными: gcc (компиляция примеров), gdb с плагином pwndbg (клонируется с GitHub, ставится через setup.sh, репозиторий 7k+ звёзд, активно поддерживается), Python 3.8+ и библиотека pwntools версии 4.x (pip3 install pwntools). RAM — минимум 2 ГБ, рекомендуется 4 ГБ при параллельной работе с IDE или Ghidra. Весь процесс работает offline — интернет нужен только для установки пакетов.
Получив бинарник pwn-задачи, начинай с двух команд. file ./vuln покажет архитектуру: ELF 64-bit LSB executable — стандартный x86-64 бинарник без PIE; формулировка ELF 64-bit LSB pie executable сигнализирует о включённом PIE (адреса рандомизируются). Далее — checksec --file=./vuln (или checksec ./vuln из контекста pwntools) выведёт состояние защитных механизмов.
Для учебного бинарника без защит вывод содержит: Arch: amd64-64-little, Stack: No canary found, NX: NX disabled, PIE: No PIE. Каждая строка определяет доступный вектор атаки:
No canary found — отсутствует stack canary (случайное значение между буфером и saved RBP, проверяемое перед ret). Если canary есть — при перезаписи программа вызывает __stack_chk_fail, печатает *** stack smashing detected *** и аварийно завершается. Обход canary требует утечки его значения — например, через format string уязвимость, как в writeup'е MetaCTF "My Little Pwny" (формат %19$p).
NX disabled — стек помечен как исполняемый. Можно разместить шеллкод прямо в буфере и передать туда управление (shellcode injection). При включённом NX (No eXecute) потребуется Return Oriented Programming — цепочка гаджетов из существующего кода.
No PIE — базовый адрес бинарника фиксирован при каждом запуске. Адрес win() одинаков. При включённом PIE адрес придётся сначала утечь через информационную утечку.
Для первого знакомства с buffer overflow exploit выбирай задачи, где все три защиты отключены — они обычно маркированы "baby", "intro" или "warmup". Всё, что сложнее — потом.
[Применимо: учебная среда, CTF-соревнования начального уровня. В реальных условиях пентеста — как внешнего, так и внутреннего — все перечисленные защиты включены по умолчанию в GCC 12+ и ядрах Linux 5.x+. Ret2win в чистом виде неприменим к production-бинарникам.]
Для тренировки без подключения к CTF-серверу компилируем собственный уязвимый бинарник. Классическая задача ret2win: функция win() выводит флаг, но в обычном потоке выполнения не вызывается — нужно перенаправить в неё управление через переполнение.
#include <stdio.h>
#include <stdlib.h>
void win() { system("cat flag.txt"); }
void vuln() {
char buf[64];
gets(buf);
}
int main() { vuln(); return 0; }
Компиляция: gcc -o vuln vuln.c -fno-stack-protector -no-pie -z execstack -O0 -g. Флаг -fno-stack-protector убирает canary, -no-pie фиксирует адреса, -z execstack помечает стек исполняемым, -g добавляет отладочные символы для GDB. При компиляции GCC ругнётся на gets — функция удалена из стандарта C11 как небезопасная, но для учебных целей бинарник соберётся. Перед запуском создай файл flag.txt с произвольным содержимым в той же директории.
Переполнение буфера работает при точном знании расстояния (offset) от начала ввода до адреса возврата. Теоретически: 64 байта buf + 8 байт saved RBP = 72 байта, затем 8 байт целевого адреса. Но компилятор может добавить padding для выравнивания, и реальное смещение будет другим. Доверяй отладчику, не калькулятору. Я на этом обжигался не раз.
Запускаем gdb ./vuln. С установленным pwndbg интерфейс сразу покажет регистры, стек и дизассемблированный код. Генерируем уникальный паттерн командой cyclic 200 (в pwndbg) — строку из 200 символов, где каждая 8-байтовая подстрока встречается ровно один раз. Запускаем программу: run, вставляем сгенерированный паттерн. Программа падает с segfault.
Pwndbg анализирует crash и выводит значение, которое перезаписало адрес возврата. Обычно это выглядит как Invalid address 0x6161616c6161616b или отображается в регистрах. Берём это значение и вычисляем offset: cyclic -l 0x6161616c6161616b — команда вернёт число 72. Это количество байт мусора (padding), которое нужно отправить перед адресом целевой функции.
Альтернативный путь — в Python-консоли: from pwn import *; print(cyclic_find(0x6161616c6161616b)). Результат тот же.
Для полного понимания полезно проверить offset через дизассемблирование. disas vuln в GDB для отладки эксплойта:
pwndbg> disas vuln
0x401156 <vuln>: push rbp
0x401157 <vuln+1>: mov rbp, rsp
0x40115a <vuln+4>: sub rsp, 0x40
0x40115e <vuln+8>: lea rax, [rbp-0x40]
0x401162 <vuln+12>: mov rdi, rax
0x401165 <vuln+15>: call gets
0x40116a <vuln+20>: nop
0x40116b <vuln+21>: leave
0x40116c <vuln+22>: ret
Инструкция sub rsp, 0x40 выделяет 0x40 (64 десятичных) байт под буфер. lea rax, [rbp-0x40] подтверждает: буфер начинается на 0x40 байт ниже RBP. От начала буфера до saved RBP — ровно 0x40 байт, плюс 8 байт самого saved RBP — итого 72 байта (0x48) до return address. Совпадает с cyclic. Хорошо.
Для верификации ставим breakpoint на ret: break *0x40116c, запускаем с паттерном, на breakpoint'е проверяем x/gx $rsp — увидим значение, которое RIP вот-вот загрузит. Вычисляем его offset в паттерне и получаем финальное подтверждение. Три способа — один ответ. Теперь можно быть уверенным.
Архитектура x86-64 использует порядок байтов little-endian: младший байт хранится по младшему адресу. Адрес 0x00401142 в памяти выглядит как последовательность \x42\x11\x40\x00\x00\x00\x00\x00 — задом наперёд. Функция p64() из pwntools автоматически выполняет конвертацию в 8-байтовую little-endian строку. На x86 (32-бит) аналог — p32().
Без pwntools кодирование приходится делать вручную: python3 -c "import sys; sys.stdout.buffer.write(b'A'*72 + b'\x42\x11\x40\x00\x00\x00\x00\x00')". Работает, но читать такое — мучение. Pwntools делает процесс читаемым и масштабируемым.
Offset известен (72 байта), адрес win() получаем через objdump -t ./vuln | grep win или через elf.symbols['win'] в pwntools. Собираем эксплойт:
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
offset = 72
payload = b'A' * offset
payload += p64(elf.symbols['win'])
p.sendline(payload)
p.interactive()
Сохраняем как exploit.py, запускаем python3 exploit.py. Объект ELF('./vuln') парсит бинарник и даёт словарь symbols с адресами функций. Метод p64() упаковывает адрес в little-endian. sendline() отправляет payload с символом новой строки, а interactive() передаёт управление терминалу — если win() вызывает system("/bin/sh"), получишь шелл; если system("cat flag.txt") — содержимое флага.
На соревновании бинарник запущен на удалённом сервере. Заменяем process('./vuln') на remote('ctf.example.com', 31337) — всё остальное без изменений. В реальном writeup'е HTB Cyber Apocalypse CTF подключение выглядит как r = remote(IP, PORT), за которым следуют r.sendline(payload) и r.interactive(). Если сервер нестабилен (а на крупных CTF это норма, особенно в первые часы), оборачиваем отправку в try/except внутри цикла while True — как в решении задачи "Space Pirate: Going Deeper".
Вот тут начинается самое интересное. Распространённая проблема: эксплойт работает в GDB, но при чистом запуске (python3 exploit.py) программа падает с segfault внутри win(). Ты всё сделал правильно, offset верный, адрес верный — а оно не работает. Причина — нарушение 16-байтового выравнивания стека. System V ABI для x86-64 требует, чтобы RSP был кратен 16 перед инструкцией call. Инструкция movaps (часто встречается внутри glibc-функций) проверяет это выравнивание и вызывает crash при нарушении.
Два способа исправить. Первый — перейти на win()+1, пропустив push rbp в прологе, которая сдвигает стек на 8 байт. Второй (надёжнее) — вставить перед адресом win() гаджет ret — одиночную инструкцию ret, которая снимает 8 байт со стека и выравнивает его. Адрес гаджета находится через ROPgadget --binary ./vuln | grep ": ret" или ROP(elf).find_gadget(['ret'])[0] в pwntools. Итоговый payload с выравниванием: b'A' * offset + p64(ret_gadget) + p64(elf.symbols['win']).
Этот нюанс не упоминается почти ни в одном русскоязычном разборе stack buffer overflow, хотя на x86-64 он встречается регулярно. На форумах десятки тредов, где человек бился часами — а нужен был один гаджет ret. Знай об этом заранее — сэкономишь вечер.
Ret2win — учебная техника. Она работает при одновременном отключении нескольких защит. В задачах среднего и высокого уровня (и тем более в реальных пентестах) каждый механизм создаёт дополнительный барьер:
Stack Canary — 8-байтовое случайное значение с нулевым младшим байтом (усложняет утечку через строковые функции). При перезаписи функция __stack_chk_fail завершает процесс. Обход: утечка canary через format string, побочный канал, или brute-force в форк-серверах (где canary не меняется между форками).
NX (No eXecute) — стек неисполняемый. Shellcode injection на стеке невозможен. Обход: Return Oriented Programming (ROP) — цепочка гаджетов (фрагментов существующего кода, заканчивающихся ret), позволяющая вызвать execve("/bin/sh", 0, 0) или system("/bin/sh"). Тут начинается настоящий pwn.
PIE (Position Independent Executable) — базовый адрес бинарника рандомизируется при каждом запуске. Адрес win() неизвестен до утечки. Обход: информационная утечка из .text секции через format string или partial overwrite (перезапись только младших байт адреса).
ASLR — рандомизация стека, кучи и библиотек на уровне ОС. Обход: brute-force (реалистично на 32-бит: 12 бит энтропии = 4096 попыток), утечка адресов, или использование адресов самого бинарника (при отключённом PIE).
После освоения ret2win следующие шаги в прогрессии: ret2libc (вызов system() из libc через ROP), ret2csu (гаджеты из __libc_csu_init), format string exploitation и heap-based техники.
| Защита | Что блокирует | Обход | Уровень задач |
|---|---|---|---|
| No canary | Ничего | Не нужен | Baby/Intro |
| Canary | Прямую перезапись ret addr | Утечка через fmt str | Средний |
| NX | Shellcode на стеке | ROP-цепочки | Средний |
| PIE + ASLR | Фиксированные адреса | Info leak + ROP | Продвинутый |
| Full RELRO + все | GOT-перезапись | Heap exploitation | Сложный |
В контексте MITRE ATT&CK переполнение буфера покрывает несколько тактик. Для CTF-задач с сетевым сервисом (бинарник принимает подключения) — это Exploit Public-Facing Application (T1190, Initial Access). Для локальных бинарников с SUID-битом — Exploitation for Privilege Escalation (T1068, Privilege Escalation). Получив шелл через buffer overflow, атакующий может перейти к Process Injection (T1055) для закрепления или использовать Unix Shell (T1059.004) для выполнения команд.
В реальной цепочке атаки переполнение буфера — первое звено: initial access или privesc, за которым следуют persistence, lateral movement и exfiltration. В CTF путь короче: переполнение → system("cat flag.txt") → флаг. Но понимание полной цепочки — это то, что отличает решателя задачек от человека, который может применить навык на реальном проекте.
Девять из десяти первых попыток написать buffer overflow эксплойт заканчиваются молча — segfault, пустой терминал, ноль обратной связи. Проблема не в теории, а в отладочной дисциплине: вместо методичной проверки через cyclic и breakpoint на ret люди пытаются угадать offset. На форумах десятки обсуждений, где человек бился часами с «правильным» payload'ом, а ошибка была в одном байте — компилятор добавил выравнивание, а offset считали арифметически, не через GDB.
Pwn-категория отпугивает не сложностью концепций — стек, адрес возврата, little-endian укладываются в голову за вечер. Отпугивает молчаливый segfault без объяснения, что именно пошло не так. Если вынести из этой статьи одну мысль — пусть это будет привычка ставить breakpoint на ret и проверять x/gx $rsp перед каждой отправкой payload. Этот навык переживёт любую конкретную технику. Попробуйте скомпилировать бинарник из раздела 3, найти offset и написать эксплойт — без подглядывания в готовый код. Если movaps уронит ваш первый payload — вы на правильном пути.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...