На проверке Разработка эксплойтов с нуля: ROP, shellcode, ASLR

Сергей Попов

Администратор
30.12.2015
6 349
7 023
Специализация
  1. OSINT
  2. Веб-безопасность
Статус верификации
  1. ✓ Verified
Макросъёмка обгоревшей платы: почерневшие дорожки и контакты со следами оплавления, на текстолите выгравированы CVE-2003-0264 и адрес 0x41414141. Резкий свет лампы-лупы выхватывает место повреждени...


На CTF отправляешь 500 байт в бинарный сервис, в GDB видишь 0x41414141 в EIP - адрес возврата перезаписан, выполнение под контролем. Дальше - segfault за segfault: shellcode на стеке не исполняется, адреса libc скачут при каждом запуске. DEP и ASLR превращают «простое» переполнение буфера в задачу, которая без системного подхода не решается. Здесь - полный цикл разработки эксплойта с нуля: от buffer overflow до ROP chain и утечки адресов, с кодом на pwntools и разбором граблей, на которые наступают все.

Переполнение буфера: уязвимость, которая отдаёт контроль​

Переполнение буфера на стеке - отправная точка в написании эксплойта. При вызове функции стек хранит локальные переменные, сохранённый базовый указатель (RBP на x64, EBP на x86) и адрес возврата (RIP/EIP). Локальные переменные лежат ниже по адресам, чем адрес возврата.

Когда strcpy копирует данные в буфер без проверки длины, лишние байты перезаписывают RBP, затем RIP. Контроль над RIP - контроль над тем, куда процессор прыгнет после завершения функции. Вот и всё, собственно.

Зачем это нужно? Buffer overflow эксплуатация - не академическое упражнение. Перезапись RIP позволяет запустить произвольный код в контексте уязвимого процесса: reverse shell, повышение привилегий (T1068 в MITRE ATT&CK), внедрение нагрузки. Для сетевых сервисов это Exploit Public-Facing Application (T1190), для клиентских приложений - Exploitation for Client Execution (T1203). На пентесте такой PoC - разница между «уязвимость найдена» и «уязвимость подтверждена».

Классический учебный пример - CVE-2003-0264: переполнения буфера в SLMail 5.1.0.4420. CVE описывает четыре вектора: длинный аргумент EHLO, длинный аргумент XTRN, длинная строка POPPASSWD и длинный пароль POP3 (команда PASS) - каждый перезаписывает EIP и даёт выполнение произвольного кода. На Exploit-DB для неё три верифицированных PoC (EDB-638, EDB-643, EDB-646). С этой уязвимости обычно начинают изучение exploit development - она же встречается в подготовке к OSCP.

Прежде чем писать эксплойт, запустите checksec ./binary из pwntools - он покажет состояние защит:
  • NX (DEP) - стек неисполняемый. Shellcode на стеке не сработает, нужен ROP.
  • Stack Canary - случайное значение между буфером и RIP. Повредили canary - программа падает через __stack_chk_fail, и до вашего payload дело не доходит.
  • PIE - рандомизация базового адреса бинарника. Адреса функций и гаджетов пляшут при каждом запуске.
  • RELRO - защита GOT от перезаписи. Full RELRO блокирует GOT-overwrite.
Результат checksec определяет стратегию до первой строки кода. NX выключен и PIE выключен - простейший случай: shellcode на стеке с фиксированным адресом. Обе защиты включены - нужна утечка адресов плюс ROP chain. Я всегда начинаю с этого - экономит часы бессмысленной отладки.

Определение offset с помощью cyclic pattern​

Точный offset до регистра возврата - фундамент написания эксплойта. Ошибка в пять байт - и вместо shell получаешь segfault. Pwntools генерирует циклический паттерн - строку, где каждая 4-байтная (x86) или 8-байтная (x64) подпоследовательность уникальна.

Workflow простой: генерируешь паттерн через cyclic(500), отправляешь в бинарник, при крэше смотришь значение EIP в GDB командой info registers, вычисляешь offset через cyclic_find().
Python:
from pwn import *

io = process('./vulnerable')
io.sendline(cyclic(500))
io.wait()
# В GDB: info registers → EIP = 0x61616174
offset = cyclic_find(0x61616174)
log.info(f"Offset до EIP: {offset}")
Три ошибки, которые крадут часы на CTF:

Забытый n=8 на x64. По умолчанию cyclic генерирует 4-байтные подстроки. На 64-битных бинарниках нужно cyclic(500, n=8) и cyclic_find(value, n=8) - иначе offset вычисляется неверно, cyclic_find возвращает -1, и начинается бессмысленное хождение по кругу. Сам на этом сидел минут сорок, пока не дошло.

Coredump не создаётся. Перед запуском: ulimit -c unlimited и проверка /proc/sys/kernel/core_pattern. Без coredump анализ регистров после крэша - гадание.

Stack canary искажает результат. Если canary включён, паттерн затирает его значение, программа падает через __stack_chk_fail, и в EIP оказывается адрес обработчика, а не ваш паттерн. Canary обходится отдельно - через утечку его значения (format string, side-channel, brute по байтам в fork-серверах).

В GDB с pwndbg или GEF offset находится быстрее: cyclic 500 генерирует паттерн, cyclic -l <значение> вычисляет offset прямо в отладчике без скрипта.

Написание shellcode: от msfvenom до ассемблера​

📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме

* N) перед shellcode - страховка от неточного адреса возврата. Если RIP попадает не в начало shellcode, а в NOP-зону, процессор «скользит» до полезной нагрузки. Старая техника, но работает.

Когда ввод ограничен 50–100 байтами (часть pwn-тасков), msfvenom не подходит - его shellcode занимает 70+ байт. Приходится писать вручную на ассемблере: xor для обнуления регистров, push для строки /bin/sh на стеке, int 0x80 с eax=0xb. Минимальный execve("/bin/sh") для x86 - около 25 байт. Ручное написание shellcode - отдельная дисциплина, и именно она отличает тех, кто решает pwn-таски высокого уровня на HackTheBox, от тех, кто застревает на medium.

Обход DEP: Return Oriented Programming​

DEP (NX bit) помечает стек как неисполняемый. Если checksec показывает NX: Enabled - любой shellcode на стеке вызовет segfault. Return Oriented Programming решает проблему: вместо собственного кода переиспользуем фрагменты уже загруженного в память.

Гаджеты и сборка ROP chain​

Гаджет - короткая последовательность инструкций, заканчивающаяся ret. Инструкция ret снимает адрес со стека и прыгает туда. Размещая на стеке последовательность адресов гаджетов, мы выстраиваем цепочку произвольных действий из существующего кода. По сути - программирование без собственного кода, только из кусочков чужого.

Поиск гаджетов: ROPgadget --binary ./vulnerable выводит полный список. Для целевого поиска: ropper -f ./vulnerable --search "pop rdi".

Типичная ROP-цепочка для system("/bin/sh") на x64 Linux:
Python:
from pwn import *
io = process('./vulnerable')
pop_rdi = 0x401234   # pop rdi; ret (из ROPgadget)
ret = 0x401235       # ret (выравнивание стека x64)
payload = b'A' * offset
payload += p64(ret) + p64(pop_rdi)
payload += p64(bin_sh_addr)  # "/bin/sh" в libc
payload += p64(system_addr)  # system() в libc
io.sendline(payload)
io.interactive()
Критический момент на x64 - выравнивание стека. System V ABI требует, чтобы стек был выровнен на 16 байт при вызове функции. Один лишний ret-гаджет перед pop rdi решает проблему. На CTF это одна из самых частых причин, почему «правильная» цепочка падает - эксплойт логически верный, но system() ловит segfault из-за невыровненного стека. Я видел, как люди тратили на это часы, не понимая, почему всё «правильно, но не работает».

Для продвинутых сценариев - вызов mprotect для снятия NX со страницы или использование WriteProcessMemory на Windows (техника, описанная исследователями NCC Group для обхода DEP в реальных эксплойтах) - цепочка удлиняется до десятков гаджетов, но принцип тот же: каждый гаджет выполняет одну операцию и передаёт управление следующему через ret.

Обход ASLR через утечку адресов​

ASLR рандомизирует базовые адреса модулей при каждом запуске. Адреса libc, стека и кучи сдвигаются на случайную величину. Без актуальных адресов ROP chain бесполезен - адреса system() и /bin/sh каждый раз другие.

Ключевое свойство ASLR (описано в ASLR Bypass Lab MIT): рандомизация применяется на уровне страниц (4 КБ на x86_64). Нижние 12 бит адреса не меняются. Смещения внутри одного модуля сохраняются - утечка одного адреса из libc раскрывает положение всех её функций. Одна дырка - и вся рандомизация рассыпается.

Техники получения утечки​

Format string. Если программа передаёт ввод напрямую в printf - printf(input) вместо printf("%s", input) - отправка %p.%p.%p выводит содержимое стека, включая адреса возврата внутрь libc. Классическая ошибка разработчика, подарок для атакующего.

GOT leak через ret2plt. Если PIE выключен, адреса PLT-записей фиксированы. ROP-цепочка вызывает puts@plt с аргументом puts@got. На выходе - реальный адрес puts в libc. Зная offset puts в конкретной версии libc (определяется через libc-database), вычисляешь базу libc и адреса system(), /bin/sh. Это рабочая лошадка большинства CTF-тасков с ASLR.

Partial overwrite. Нижние 12 бит стабильны - иногда достаточно перезаписать 1–2 младших байта адреса возврата, чтобы перенаправить выполнение на нужную функцию без полной утечки. Элегантно, но требует знания layout'а бинарника.

Brute force. На 32-битных системах энтропия ASLR невелика. При 256 возможных позициях эксплойт срабатывает за минуты при многократном запуске. На 64-битных - забудьте, не тот масштаб.

Для автоматизации утечек pwntools предоставляет класс DynELF: по функции leak(addr) он находит базу libc и резолвит символы. На практике ручной GOT leak через ret2plt проще и предсказуемее, когда PIE выключен - DynELF хорош для сложных случаев, но в простых только добавляет абстракций.

Точка зрения​

Полный цикл - от переполнения буфера до работающего PoC с обходом DEP и ASLR - на бумаге выглядит линейным. На практике 80% времени уходит на отладку в GDB: неверный offset, забытое выравнивание стека, не та версия libc. Каждый из этих факторов превращает «готовый» эксплойт в segfault без внятных объяснений.

Не начинайте с чтения writeups. Возьмите pwn-таск начального уровня на picoCTF или среднего на HackTheBox, потратьте 2–3 часа на самостоятельные попытки - и только потом открывайте разбор. Иначе иллюзия понимания заменяет реальный навык, и на CTF это выясняется в первые минуты.

Из практики: затык в exploit development чаще связан не с ROP или ASLR, а с отладкой. Не умеют быстро ставить breakpoint в нужную точку, не читают дизассемблер, не проверяют содержимое стека командой x/20wx $esp перед ret. Pwndbg или GEF - не украшения для GDB, а необходимость: без них отладка бинарников превращается в гадание на hex-дампах. На WAPT эту цепочку - от buffer overflow до ROP с обходом ASLR - проходят в 30 уровнях прогрессии с лабами.
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🧭 Навигатор · ИБ 2026
Не знаешь, какой трек твой?
5 направлений ИБ, реальные зарплаты и точка входа для каждого — в одном треде.
JuniorSenior+
100K → 600K+ ₽ /мес
Открыть навигатор →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab