Главная / Блог / Format String уязвимость в CTF: читаем память процесса и перезаписываем GOT через pwntools и %n

14 мин.00

Format String уязвимость в CTF: читаем память процесса и перезаписываем GOT через pwntools и %n

Format String уязвимость в CTF: читаем память процесса и перезаписываем GOT через pwntools и %n

Format String уязвимость в CTF: читаем память процесса и перезаписываем GOT через pwntools и %n

На 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-скриптом.

Требования к окружению

Перед практикой — минимальный стек для воспроизведения всех примеров: Подробнее — в нашем обзоре бинарный анализ уязвимостей.

  • ОС: Linux (Ubuntu 20.04+ или Kali Linux 2024+). На macOS pwntools заведётся, но компиляция уязвимых ELF-бинарников требует Linux
  • Python: 3.8+ с pwntools — pip install pwntools>=4.13.1 (версии ниже содержат уязвимость SSTI — GHSA-7xc5-ggpp-g249, так что обновляйтесь)
  • Отладчик: GDB с плагином pwndbg (git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh; на свежих системах можно через nix profile install github:pwndbg/pwndbg) или peda. Голый GDB тоже работает, но анализ стека в разы медленнее — проверено на собственных нервах
  • Компилятор: gcc для сборки уязвимых бинарников с ослабленными защитами
  • Утилита: checksec — входит в pwntools, доступна из терминала после установки
  • Архитектура: примеры для x86_64 (64-bit), с пояснениями отличий для i386 (32-bit)
  • RAM: от 2 ГБ (GDB + pwntools + один процесс)

Все бинарники компилируются с учебными флагами — конкретные параметры указаны перед каждым примером.

Почему printf(buf) — это arbitrary read и write: механика format string уязвимости

Суть 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).

Чтение памяти процесса в CTF через утечку стека

Первый практический шаг при эксплуатации 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, ключевой параметр для всех дальнейших операций записи.

Поиск смещения (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 секунд и даёт абсолютную уверенность — на соревнованиях я предпочитаю руками, чем потом час отлаживать автоматику.

Позиционные аргументы и спецификатор %s в stack leak эксплойте

Direct parameter access %N$p — механика, без которой format string эксплойт pwntools не работал бы на практике. Вместо цепочки %p %p %p %p %p %p обращаемся к конкретной позиции: %6$p читает шестой аргумент, %42$p — сорок второй. По данным курса CS6265 (Georgia Tech), без этого синтаксиса длина payload была бы ограничена размером буфера: десятки %p тупо не помещаются в 64-байтный ввод.

Что можно вытащить через утечку стека:

  • Адреса libc-функций — для вычисления базы libc и обхода ASLR
  • Stack canary — 8-байтное значение, обычно заканчивается на \x00, для обхода stack protector
  • Адреса возврата — для определения базы бинарника при включённом PIE
  • Данные из переменных — на PicoCTF 2022 задача flag leak решалась через %6$s: строка флага лежала на стеке как локальная переменная, и %s прочитал её напрямую (по данным writeup'а с corgi.rip)

Спецификатор %s особенно интересен: он интерпретирует значение на стеке как указатель и читает строку по этому адресу. Если на стеке лежит указатель на секретные данные — %N$s вернёт их содержимое. Но если значение окажется невалидным адресом — процесс упадёт с segfault, а на удалённом CTF-сервере это означает потерю соединения. Поэтому для разведки используют %p (безопасное чтение числа), а %s — только когда уверены, что по адресу лежит строка.

%n запись в память: arbitrary write примитив через format string

Семейство спецификаторов %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 пробелов) можно набрать любое количество символов и записать произвольное значение.

Семейство %n и побайтовая запись

Для записи полного 8-байтного адреса (например, адреса system() = 0x7ffff7c58750) через %hhn нужно шесть операций побайтовой записи. Каждая записывает один байт по адресу GOT + 0, GOT + 1, ..., GOT + 5.

Алгоритм ручной сборки:

  1. Разбить целевое значение на отдельные байты: 0x50, 0x87, 0xc5, 0xf7, 0xff, 0x7f
  2. Отсортировать байты по значению (от меньшего к большему) — так получается минимальная дельта для %Nc
  3. Для каждого байта: вычислить сколько символов нужно допечатать через %Nc, затем записать через %M$hhn
  4. В конец payload'а добавить целевые адреса (GOT + offset), упакованные в little-endian

По данным writeup'а с ian.nl, для подмены printf@got адресом system() потребовался payload из 113 байт: 64 байта форматной строки (%80c%14$lln%47c%15$hhn...) плюс 48 байт адресов (6 указателей по 8 байт). Собирать такое руками на соревнованиях — чистый мазохизм, когда fmtstr_payload из pwntools делает это автоматически.

GOT overwrite через format string: подменяем puts на system

Global Offset Table (GOT) — таблица в ELF-бинарнике, через которую происходят вызовы функций из динамически подключённых библиотек. Когда программа вызывает puts(), она переходит по адресу из puts@got — ячейки GOT, содержащей реальный адрес puts в libc. Перезаписав эту ячейку адресом system(), получаем: вызов puts("/bin/sh") превращается в system("/bin/sh"). Shell готов.

Partial RELRO и условия для GOT overwrite format string

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.

Автоматизация через fmtstr_payload в pwntools

Разберём полный format string exploit pwntools для задачи, аналогичной picoCTF 2024 Format String 3. Исходный код: программа вызывает printf(buf) с пользовательским вводом, затем puts(normal_string) где normal_string = "/bin/sh". Перед вводом программа выдаёт адрес setvbuf в libc — подсказка авторов для вычисления system().

Цепочка:

  1. Подключиться, прочитать утёкший адрес setvbuf
  2. Вычислить базу libc: leaked_addr - libc.sym['setvbuf']
  3. Вычислить адрес system: libc.address + libc.sym['system']
  4. Сгенерировать payload для перезаписи puts@gotsystem()
  5. Отправить — 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.

Format string ASLR bypass через утечку адресов libc

На реальных 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.

Пошаговый алгоритм: от printf(buf) до shell за 7 действий

Универсальная последовательность для большинства format string pwn CTF заданий:

  1. Reconnaissance. checksec ./binary — смотрим RELRO (Partial = GOT overwrite), PIE (No = стабильные адреса), NX (включён = нет shellcode). Загружаем бинарник в Ghidra, находим printf(user_input) и оцениваем, какой вызов идёт после — puts, exit, return
  2. Offset. Вводим AAAAAAAA %p %p ... %p (8-15 спецификаторов), ищем 0x4141414141414141. Подтверждаем через %N$p
  3. Цель перезаписи. Если после printf есть puts("/bin/sh") — цель: puts@gotsystem(). Если есть exit() — цель: exit@got. Если format string + buffer overflow — цель: canary + return address
  4. Утечка libc. Из вывода программы, через GOT-чтение, или через стековый скан — получаем адрес libc-функции
  5. Вычисление адресов. libc.address = leaked - libc.sym['known_func'], затем target = libc.sym['system']
  6. Payload. fmtstr_payload(offset, {elf.got['victim_func']: libc.sym['system']})
  7. Shell. Отправляем payload, io.interactive(), shell, cat flag.txt

Типичные ошибки при решении format string задач

Неправильный 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 в цепочке атаки binary exploitation CTF

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 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...