
На picoCTF 2024 задача Format String 3 решалась одним вызовом fmtstr_payload — если понимаешь механику GOT overwrite. Без этого участники часами собирали payload вручную, и он разваливался при каждом запуске из-за ASLR на libc. Строка fmtstr_payload(38, {elf.got['puts']: libc.sym['system']}) — и puts("/bin/sh") превращается в system("/bin/sh"). Shell получен, флаг прочитан.
Format string уязвимость в CTF — класс багов, который выглядит невинно (подумаешь, printf(buf) вместо printf("%s", buf)), но даёт атакующему полноценные arbitrary read и arbitrary write примитивы. Ниже — полная цепочка: от утечки стека через %p до перезаписи GOT через %n, с пояснением каждого шага и готовым pwntools-скриптом.
Перед практикой — минимальный стек для воспроизведения всех примеров: Подробнее — в нашем обзоре бинарный анализ уязвимостей.
pip install pwntools>=4.13.1 (версии ниже содержат уязвимость SSTI — GHSA-7xc5-ggpp-g249, так что обновляйтесь)git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh; на свежих системах можно через nix profile install github:pwndbg/pwndbg) или peda. Голый GDB тоже работает, но анализ стека в разы медленнее — проверено на собственных нервахchecksec — входит в pwntools, доступна из терминала после установкиВсе бинарники компилируются с учебными флагами — конкретные параметры указаны перед каждым примером.
Суть printf format string bug сводится к одной ошибке: программист передаёт пользовательский ввод первым аргументом в printf(). Функция ожидает форматную строку, описывающую типы и количество последующих аргументов. Если вместо этого приходит контролируемый буфер — атакующий диктует, что printf будет делать с памятью процесса.
Минимальный уязвимый код:
#include <stdio.h>
int main() {
char buf[256];
fgets(buf, 256, stdin);
printf(buf); /* printf уязвимость: ввод как format string */
return 0;
}
Компиляция: gcc -fno-stack-protector -no-pie -Wl,-z,norelro -o vuln vuln.c. Флаги -fno-stack-protector убирают canary, -no-pie фиксируют адреса секций, -z,norelro делают GOT перезаписываемым. Такая конфигурация — стандарт для учебных format string CTF-тасков начального уровня.
Когда пользователь вводит %p %p %p, функция printf видит три спецификатора формата и ожидает три дополнительных аргумента. Программист не передавал никаких аргументов — значит printf читает из регистров и стека то, что там лежит: адреса возврата, указатели, локальные переменные других функций. Это не мусор. Это данные процесса, и утечка памяти через format string превращается в информационный примитив.
Спецификатор %n идёт дальше — записывает количество уже напечатанных символов по адресу, который printf интерпретирует как указатель из «аргументов». Раз наш ввод лежит на стеке — мы контролируем этот адрес. Чтение + запись = полный контроль над потоком исполнения. По классификации OWASP это Injection (A03:2021), в терминах MITRE ATT&CK — Exploit Public-Facing Application (T1190, Initial Access).
Первый практический шаг при эксплуатации format string уязвимости — stack leak. Цель: найти смещение (offset) до нашего ввода на стеке и вытащить адреса libc для обхода ASLR.
Запускаем уязвимый бинарник, вводим AAAAAAAA %p %p %p %p %p %p %p %p. Вывод будет примерно таким:
AAAAAAAA 0x7fffffffe120 (nil) 0x7ffff7f9a2a0 0x1 0x400 0x4141414141414141 0x2520702520702520
Значение 0x4141414141414141 — наши восемь символов A (0x41 = ASCII «A»). Оно появилось на шестой позиции. Число 6 — это offset, ключевой параметр для всех дальнейших операций записи.
Перебирать %p %p %p... можно, но есть путь быстрее. Синтаксис %N$p (direct parameter access) обращается сразу к N-му аргументу printf. Вводим AAAAAAAA %6$p — если на выходе 0x4141414141414141, значит offset = 6. Нет? Пробуем 7, 8 и дальше.
На 32-битных бинарниках offset обычно 4–7: аргументы функций сразу идут на стек, без регистров. На 64-битных — чаще 6–10, потому что форматная строка передаётся в rdi, следующие 5 variadic-аргументов идут в rsi, rdx, rcx, r8, r9, и только начиная с 6-го variadic-аргумента printf читает со стека. В задаче picoCTF 2024 Format String 3, по данным writeup'а с systemweakness.com, offset составлял 38 — буфер на 1024 байта между вводом и точкой printf на стеке. Да, тридцать восемь. Такое бывает.
В pwntools класс FmtStr предоставляет метод find_offset для автоматического определения. На практике ручная проверка занимает 30 секунд и даёт абсолютную уверенность — на соревнованиях я предпочитаю руками, чем потом час отлаживать автоматику.
Direct parameter access %N$p — механика, без которой format string эксплойт pwntools не работал бы на практике. Вместо цепочки %p %p %p %p %p %p обращаемся к конкретной позиции: %6$p читает шестой аргумент, %42$p — сорок второй. По данным курса CS6265 (Georgia Tech), без этого синтаксиса длина payload была бы ограничена размером буфера: десятки %p тупо не помещаются в 64-байтный ввод.
Что можно вытащить через утечку стека:
\x00, для обхода stack protector%6$s: строка флага лежала на стеке как локальная переменная, и %s прочитал её напрямую (по данным writeup'а с corgi.rip)Спецификатор %s особенно интересен: он интерпретирует значение на стеке как указатель и читает строку по этому адресу. Если на стеке лежит указатель на секретные данные — %N$s вернёт их содержимое. Но если значение окажется невалидным адресом — процесс упадёт с segfault, а на удалённом CTF-сервере это означает потерю соединения. Поэтому для разведки используют %p (безопасное чтение числа), а %s — только когда уверены, что по адресу лежит строка.
Семейство спецификаторов %n — то, что превращает format string из утечки информации в полноценный arbitrary write примитив:
| Спецификатор | Размер записи | Тип |
|---|---|---|
%n |
4 байта | int |
%hn |
2 байта | short |
%hhn |
1 байт | char |
%ln |
8 байт (x86_64) / 4 байта (i386) | long |
%lln |
8 байт | long long |
%n всегда записывает 4 байта (sizeof(int)=4 на обеих архитектурах). %ln зависит от модели данных: 8 байт на x86_64 (LP64, long=8), 4 байта на i386 (ILP32, long=4). %lln = 8 байт на обеих.
Механика: %n записывает по адресу, лежащему на стеке в позиции соответствующего аргумента, число символов, которые printf уже напечатал к этому моменту. В сочетании с %Nc (печатает N пробелов) можно набрать любое количество символов и записать произвольное значение.
Для записи полного 8-байтного адреса (например, адреса system() = 0x7ffff7c58750) через %hhn нужно шесть операций побайтовой записи. Каждая записывает один байт по адресу GOT + 0, GOT + 1, ..., GOT + 5.
Алгоритм ручной сборки:
0x50, 0x87, 0xc5, 0xf7, 0xff, 0x7f%Nc%Nc, затем записать через %M$hhnПо данным writeup'а с ian.nl, для подмены printf@got адресом system() потребовался payload из 113 байт: 64 байта форматной строки (%80c%14$lln%47c%15$hhn...) плюс 48 байт адресов (6 указателей по 8 байт). Собирать такое руками на соревнованиях — чистый мазохизм, когда fmtstr_payload из pwntools делает это автоматически.
Global Offset Table (GOT) — таблица в ELF-бинарнике, через которую происходят вызовы функций из динамически подключённых библиотек. Когда программа вызывает puts(), она переходит по адресу из puts@got — ячейки GOT, содержащей реальный адрес puts в libc. Перезаписав эту ячейку адресом system(), получаем: вызов puts("/bin/sh") превращается в system("/bin/sh"). Shell готов.
GOT overwrite возможен не всегда. Ключевой фактор — политика RELRO (Relocation Read-Only):
| Защита | GOT перезаписываем | Где встречается |
|---|---|---|
| No RELRO | Да | Учебные таски, legacy-бинарники |
| Partial RELRO | Да | Большинство CTF-задач среднего уровня |
| Full RELRO | Нет — GOT read-only после загрузки | Hardened-бинарники, сложные CTF-таски |
Проверка через checksec ./binary: строка RELRO: Partial RELRO означает «GOT доступен для записи». Строка PIE: No PIE означает «адреса секций стабильны» — адрес GOT-записи можно получить статически через objdump -R ./binary или elf.got['puts'] в pwntools.
Partial RELRO + No PIE — идеальная конфигурация для GOT overwrite. Именно такой набор защит был в задаче picoCTF 2024 Format String 3, по данным writeup'а с systemweakness.com: Partial RELRO, No PIE (0x3ff000), Canary found, NX enabled.
Разберём полный format string exploit pwntools для задачи, аналогичной picoCTF 2024 Format String 3. Исходный код: программа вызывает printf(buf) с пользовательским вводом, затем puts(normal_string) где normal_string = "/bin/sh". Перед вводом программа выдаёт адрес setvbuf в libc — подсказка авторов для вычисления system().
Цепочка:
leaked_addr - libc.sym['setvbuf']libc.address + libc.sym['system']puts@got → system()puts("/bin/sh") становится system("/bin/sh")from pwn import *
elf = context.binary = ELF('./format-string-3')
libc = ELF('./libc.so.6')
io = remote('host', port)
io.recvuntil(b'setvbuf in libc: ')
leak = int(io.recvline().strip(), 16)
libc.address = leak - libc.sym['setvbuf']
payload = fmtstr_payload(38, {elf.got['puts']: libc.sym['system']})
io.sendline(payload)
io.interactive()
Построчный разбор:
ELF('./format-string-3') загружает бинарник, парсит GOT и таблицу символов. elf.got['puts'] возвращает адрес GOT-записи puts — в этой задаче 0x404018.
ELF('./libc.so.6') загружает предоставленную libc для вычисления смещений. Именно предоставленную — если версия отличается от серверной, смещения не совпадут и эксплойт молча сломается. Для remote-соединения этого достаточно, но для локального тестирования нужно пропатчить бинарник через patchelf --set-interpreter и --replace-needed (или pwninit), иначе он будет использовать системную libc вместо предоставленной. Определить версию сервера можно через libc-database по двум утёкшим адресам.
leak - libc.sym['setvbuf'] — вычисление базы libc: утёкший абсолютный адрес минус смещение setvbuf внутри библиотеки. После присвоения libc.address все символы (включая libc.sym['system']) пересчитываются на реальные адреса.
fmtstr_payload(38, {elf.got['puts']: libc.sym['system']}) — ядро эксплойта. Первый аргумент — offset (38 для этой задачи). Второй — словарь {куда_писать: что_писать}. Функция автоматически генерирует format string с нужными %c, %hhn/%hn, сортирует байты и оптимизирует длину payload'а.
Параметр write_size по умолчанию 'byte' — запись через %hhn, по одному байту за раз. Для 64-bit адреса это 6 операций. Альтернатива 'short' (через %hn, 2 байта) генерирует более длинный вывод, но меньше операций записи.
Offset 38 — не опечатка. В этой конкретной задаче между пользовательским буфером (1024 байта) и позицией, где printf начинает «видеть» наш ввод, лежит куча локальных переменных и фреймов. Offset зависит от конкретного бинарника и находится эмпирически через %N$p.
На реальных pwn CTF заданиях среднего и высокого уровня ASLR для libc включён всегда. Адрес system() меняется при каждом запуске — захардкодить его нельзя. Но format string уязвимость сама даёт инструмент обхода: через утечку стека мы читаем адрес любой libc-функции, вычисляем базу и находим всё остальное.
Три типичных сценария получения libc-адреса в CTF:
Программа выдаёт адрес явно. picoCTF Format String 3 печатает Here's the address of setvbuf in libc: 0x7f.... Берём, вычитаем смещение, получаем базу. Самый простой вариант — авторы задачи упрощают жизнь.
Утечка через GOT. Если PIE отключён, адрес puts@got известен статически (допустим, 0x404018). Подкладываем этот адрес на стек и читаем содержимое ячейки через %s: конструируем ввод <0x404018 в little-endian>%N$s, где N — offset. На выходе получаем байты реального адреса puts в libc. Дальше — стандартная арифметика с libc-database.
Сканирование стека. Перебираем %1$p, %2$p, ... и ищем значения с характерными паттернами libc-адресов: на x86_64 они начинаются с 0x7f. Найдённый адрес сверяем с таблицей символов libc — определяем функцию-владельца и вычисляем базу.
Для задач с PIE на бинарнике нужна двойная утечка: адрес из .text для базы бинарника (чтобы вычислить GOT) + адрес из libc для базы библиотеки. Двойная утечка через format string — стандартная техника в modern binary exploitation CTF.
Универсальная последовательность для большинства format string pwn CTF заданий:
checksec ./binary — смотрим RELRO (Partial = GOT overwrite), PIE (No = стабильные адреса), NX (включён = нет shellcode). Загружаем бинарник в Ghidra, находим printf(user_input) и оцениваем, какой вызов идёт после — puts, exit, returnAAAAAAAA %p %p ... %p (8-15 спецификаторов), ищем 0x4141414141414141. Подтверждаем через %N$pputs("/bin/sh") — цель: puts@got → system(). Если есть exit() — цель: exit@got. Если format string + buffer overflow — цель: canary + return addresslibc.address = leaked - libc.sym['known_func'], затем target = libc.sym['system']fmtstr_payload(offset, {elf.got['victim_func']: libc.sym['system']})io.interactive(), shell, cat flag.txtНеправильный offset. Самая частая причина, почему payload не срабатывает. На 64-bit системах ввод выравнивается по 8 байт — лишний символ в payload сдвигает всё. Маркер AAAAAAAA (ровно 8 байт) устраняет проблемы с выравниванием.
Неправильная libc. Сервер использует одну версию, локальная машина — другую. Смещения между setvbuf и system отличаются, exploit падает. Решение: использовать libc из архива задачи или определить версию через libc-database по двум утёкшим адресам. Я на этом терял минут по 40, пока не выработал привычку первым делом проверять версию.
Full RELRO. GOT overwrite невозможен. Альтернативы для продвинутых задач: перезапись __malloc_hook / __free_hook (работает в glibc < 2.34, в свежих версиях хуки убрали), .fini_array или return address на стеке. Для CTF начального-среднего уровня Full RELRO — редкость.
PIE на бинарнике при попытке записать в GOT. Адрес GOT неизвестен статически — нужна дополнительная утечка базы бинарника. Решается через %N$p по стеку: ищем адрес из .text секции (в стандартной Linux ASLR-конфигурации часто начинается с 0x55 или 0x56 на x86_64 — это эвристика, не гарантия; проверяй смещение до известной функции) и вычисляем базу.
Format string уязвимость linux — не изолированный трюк, а элемент цепочки. В pwn CTF заданиях она занимает одну из двух ролей:
Самостоятельный вектор. Вся эксплуатация — через format string: leak + write + shell. Задачи серии Format String 1/2/3 на picoCTF построены именно так. Format string тут — и разведка, и доставка.
Информационный примитив в multi-stage exploit. Format string используется для утечки canary или libc-адреса, а основная эксплуатация — buffer overflow с ROP-цепочкой. Printf уязвимость эксплойт работает как подготовительный этап: без утечки canary переполнение невозможно, без libc-адреса ROP не соберётся.
Для уверенного решения большинства format string задач хватает 5–7 решённых тасков с нарастающей сложностью. На pwnable.kr задача fsb даёт хорошую базу по записи через %n. На PicoCTF серия Format String 1–3 покрывает прогрессию от простого чтения до GOT overwrite с ASLR. HackTheBox периодически выпускает format string задачи среднего уровня в категории pwn, где нужно чейнить leak + write в одном подключении.
Три года решения format string задач привели к одному выводу: ручная сборка payload'ов — мёртвый навык на соревнованиях. fmtstr_payload делает арифметику за миллисекунды, и время нужно тратить на то, что не автоматизируется: реверс бинарника, поиск нестандартной точки инъекции, анализ кастомного аллокатора. Но вот парадокс — без понимания того, как побайтовая запись через %hhn работает под капотом, невозможно отлаживать ситуации, когда fmtstr_payload молча генерирует кривой payload. Например, когда буфер слишком мал для шести 8-байтных адресов, или когда bad characters в адресе GOT обрезают строку. Автоматизация сняла рутину, но не отменила необходимость понимать стек. Кто использует pwntools как чёрный ящик — решает задачи уровня medium, а на hard упирается в стену, потому что не может объяснить себе, почему offset вдруг не тот. Разрыв между «знаю команду» и «понимаю, что она делает» — это разница между 15 минутами и тремя часами на одном таске. На WAPT эту цепочку проходят в течение двух модулей с лабами.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...