Главная / Блог / Format String уязвимость в CTF: разбираем printf-атаку и читаем память процесса шаг за шагом

15 мин.00

Format String уязвимость в CTF: разбираем printf-атаку и читаем память процесса шаг за шагом

Format String уязвимость в CTF: разбираем printf-атаку и читаем память процесса шаг за шагом

Format String уязвимость в CTF: разбираем printf-атаку и читаем память процесса шаг за шагом

На недавнем CTF один pwn-таск решили 12 команд из 200. Бинарник принимал строку через fgets, отправлял её прямиком в printf — и всё. Ни переполнения буфера, ни use-after-free, ни heap-примитивов. Команды, привыкшие к классическому BOF, зависли: переполнять тут нечего. А ведь это и есть format string уязвимость — одна из самых элегантных техник binary exploitation в CTF. Штатный вызов printf превращается в инструмент для чтения стека, утечки адресов и записи в произвольные ячейки памяти. Разберём механику этого format string exploit пошагово: от первого %p до полного эксплойта через класс FmtStr из pwntools.

Почему printf без аргументов — готовая format string уязвимость

Проблема начинается с одной строки кода. Вот минимальный уязвимый бинарник — шаблон, который встречается в каждом втором pwn CTF задании:

#include <stdio.h>
int secret = 0xdeadbeef;
void vuln() {
    char buf[128];
    fgets(buf, sizeof(buf), stdin);
    printf(buf);  // уязвимость: пользовательский ввод = формат-строка
}
int main() { vuln(); return 0; }

Безопасный вариант — printf("%s", buf). Уязвимый — printf(buf). Разница в одном аргументе, а последствия — принципиально разные. По документации OWASP по format string attack, уязвимость возникает когда пользовательский ввод подаётся первым аргументом в функцию форматирования — printf трактует его не как текст, а как инструкцию.

Когда printf получает формат-строку "Hello %s", она ожидает второй аргумент на стеке — указатель на строку. Внутри printf использует макрос va_arg для итерации по аргументам. И вот тут самое вкусное, что описано в исследовании hackinglab.cz: va_arg понятия не имеет, сколько аргументов реально передано. Если в формат-строке три спецификатора %x, printf прочитает три значения со стека — даже при нуле дополнительных аргументов. Макрос тупо сдвигает указатель дальше по стеку, без проверки границы. Это и есть корень printf уязвимости.

Раз пользовательский ввод попадает в printf как формат-строка, атакующий контролирует количество и тип считываемых (или записываемых) значений. Вместо текста подставляются спецификаторы формата — и printf послушно извлекает данные со стека или пишет в память.

Семейство уязвимых функций не ограничивается одной printf. По данным hackinglab.cz и OWASP, весь printf-family подвержен этой проблеме: fprintf, sprintf, snprintf, dprintf и их va_list-варианты (vprintf, vfprintf, vsprintf, vsnprintf). Плюс менее очевидные: syslog, setproctitle, функции семейства err* и warn*. Любая функция, принимающая формат-строку от пользователя, потенциально уязвима.

Анатомия стека при вызове printf: что именно читает format string exploit

Чтобы понять format string эксплуатацию, нужно представлять раскладку стека в момент вызова printf. На архитектуре x86 (32-bit) аргументы лежат на стеке в порядке справа налево. Для нормального вызова printf("Values: %d %d %x", a, b, c) стек выглядит так:

  • Адрес возврата
  • Указатель на формат-строку — printf начинает парсинг отсюда
  • Значение a — подставляется в первый %d
  • Значение b — подставляется во второй %d
  • Значение c — подставляется в %x

Printf проходит формат-строку посимвольно. Встречая %, определяет тип спецификатора и читает следующее значение со стека. Когда спецификаторов больше, чем реальных аргументов, printf продолжает двигаться по стеку и читает чужие данные: локальные переменные вызывающей функции, сохранённые регистры, адреса возврата. Это утечка памяти через printf — printf format specifier abuse в чистом виде.

На x86-64 чуть хитрее: первые шесть аргументов передаются через регистры (rdi, rsi, rdx, rcx, r8, r9), и только остальные уходят на стек. Первые несколько %p прочитают содержимое регистров, а не стека. При переходе от 32-bit к 64-bit смещение меняется, и определять его надо заново — об этом ниже.

Основные спецификаторы для format string %x %n exploit:

Спецификатор Действие Роль в атаке
%p Выводит указатель (адрес) в hex Чтение стека, утечка адресов для обхода ASLR
%x Выводит 32-bit значение в hex Чтение стека (аналог %p, но без префикса 0x)
%s Читает строку по адресу-указателю Arbitrary memory read — чтение произвольных адресов
%n Записывает количество выведенных символов по адресу Arbitrary write — запись в произвольный адрес (4 байта)
%hn То же, но пишет 2 байта (short) Точная запись без гигабайтного вывода
%hhn То же, но пишет 1 байт Побайтовая запись для максимального контроля

Таблица составлена на основе данных OWASP и hackinglab.cz. Каждый спецификатор — отдельный примитив: %p/%x для stack leak exploitation, %s для чтения по произвольному указателю, %n и производные для записи.

Находим смещение до нашего ввода: первый шаг в pwn задачах

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

  • ОС: Linux (Ubuntu 20.04/22.04 или Kali Linux)
  • RAM: минимум 2 ГБ
  • GCC для компиляции тестовых бинарников. Для 32-bit: sudo apt install gcc-multilib
  • GDB с плагином pwndbg или peda — для инспекции стека
  • Python 3.8+ с pwntools (pip install pwntools>=4.12.0 — версии до 4.12.0 содержат SSTI-уязвимость GHSA-7xc5-ggpp-g249)

Первый вопрос при эксплуатации format string уязвимости в CTF — на каком смещении от текущей позиции printf лежит наш буфер ввода. Это смещение (offset) — ключевой параметр, без которого дальше не двинешься.

[Применимо: CTF-таски x86 и x86-64, internal pentest при эксплуатации сетевых сервисов с format string]

Метод простой: отправляем уникальный маркер в начале строки и серию %p, потом ищем маркер в выводе. Компилируем уязвимый бинарник командой gcc -m32 -no-pie -o vuln vuln.c (32-bit для наглядности, без PIE для фиксированных адресов) и подаём ввод:

AAAA%p.%p.%p.%p.%p.%p.%p.%p.%p.%p

В выводе видим нечто вроде: AAAA0x80.0xf7fc5580.0x0.0xf7d9f519.0x1.0x41414141.0xff89... — шестой %p выдал 0x41414141. Это наши четыре буквы "A" (0x41 = ASCII 'A'). Смещение равно 6.

Почему именно 6? Между указателем на формат-строку (первый аргумент printf) и началом нашего буфера на стеке лежит фиксированное количество данных: сохранённый ebp, локальные переменные функции vuln, выравнивание. Конкретное число зависит от компилятора, опций оптимизации и размера локальных переменных. Поэтому offset определяется эмпирически для каждого бинарника — и это нормально.

Проверка через GDB

Запускаем бинарник в GDB с pwndbg: gdb ./vuln. Через start, затем disassemble vuln — находим адрес инструкции call printf (например, 0x0804xxxx). Ставим брейкпоинт: b *0x0804xxxx. Продолжаем через c, вводим строку AAAABBBB%p%p%p. На брейкпоинте инспектируем стек командой x/20wx $esp. Примечание: b printf может не сработать до первого вызова из-за lazy binding; кроме того, GCC иногда заменяет простые printf-вызовы на puts — имейте в виду. На стеке видны: адрес нашей строки (первый аргумент printf), затем несколько фреймовых значений, а через определённое количество позиций — байты 0x41414141 (AAAA) и 0x42424242 (BBBB). Считаем позицию от первого аргумента printf до AAAA — это и есть offset. Материал из tutorial tc.gts3.org (CS6265) показывает аналогичный подход: запуск crackme0x00 под pwndbg, инспекция стека через stack 30, поиск контролируемых данных в выводе.

Direct parameter access — прямой доступ без цепочки %p

Когда смещение известно, нет смысла каждый раз отправлять десяток спецификаторов. У printf есть синтаксис прямого доступа: %6$p — читает шестой аргумент напрямую. По данным hackinglab.cz, это один из ключевых вспомогательных приёмов (auxiliary techniques) для format string эксплуатации.

Проверяем: AAAA%6$p. Вывод: AAAA0x41414141. Смещение подтверждено. %N$ — POSIX-расширение, позволяющее обращаться к произвольному аргументу по номеру. Вместо последовательного %p%p%p%p%p%p — один %6$p. Компактно и точно. Это основа для всех дальнейших атак при чтении памяти процесса.

Чтение произвольных адресов: arbitrary memory read через format string

Чтение стека через %p полезно, но ограничено — видны только значения на стеке. Для чтения произвольного адреса (глобальная переменная, GOT-запись, строка в .rodata) используется комбинация размещения адреса на стеке и спецификатора %s.

Логика описана в материале vickieli.dev: мы знаем, что на смещении 6 printf читает начало нашего буфера. Если первые четыре байта буфера — целевой адрес, а вместо %p мы используем %s, то printf интерпретирует эти байты как указатель и прочитает строку по этому адресу.

Допустим, адрес переменной secret — 0x0804a020 (находим через nm ./vuln | grep secret или в GDB командой p &secret). Формируем payload: \x20\xa0\x04\x08%6$s — первые 4 байта в little-endian — адрес secret, затем %6$s для чтения по этому адресу.

Printf при обработке %6$s берёт значение на шестом месте стека (наш адрес 0x0804a020), интерпретирует его как указатель на строку и выводит байты начиная с этого адреса. Получаем содержимое secret в сыром виде — \xef\xbe\xad\xde (little-endian от 0xdeadbeef).

Адреса, содержащие байты 0x00 (null) или 0x0a (newline), не пройдут через fgets — ввод будет обрезан. В таких случаях адрес размещают в конце payload-а (с корректировкой offset) или используют read() вместо fgets для ввода. На практике это встречается часто, и решение зависит от конкретного таска.

Почему это работает? Как объясняет vickieli.dev, формат-строка сама лежит на стеке — это часть пользовательского ввода. Мы одновременно контролируем и данные (адрес в начале строки), и инструкцию для printf (спецификатор %s). Цепочка замыкается: данные на стеке → printf читает их как указатель → чтение по произвольному адресу.

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

В реальных pwn CTF заданиях с включённым ASLR базовый адрес libc рандомизируется при каждом запуске. Чтобы вычислить адрес system или one_gadget, нужно сначала утечь хотя бы один адрес из libc. Format string для этого подходит идеально.

На стеке при вызове printf почти всегда лежат адреса возврата, ведущие внутрь libc — например, адрес __libc_start_main+XXX, через который main была вызвана. Отправляем серию %p и ищем в выводе адреса характерного вида: 0xf7xxxxxx (32-bit) или 0x7fxxxxxxxx (64-bit). Найдя такой адрес, вычитаем известное смещение функции в конкретной версии libc — и получаем libc base. Дальше: system = libc_base + system_offset. Стандартная схема ASLR bypass через format string, которая работает потому, что stack leak exploitation даёт доступ к данным, которые программа сама разместила на стеке.

Запись в память через %n: от утечки к полному контролю

Чтение — это разведка. Но format string exploit даёт и запись — через спецификатор %n. По OWASP, %n не выводит данные, а записывает количество уже выведенных символов по адресу, на который указывает соответствующий аргумент.

Пример: вызов printf("12345%n", &var) запишет число 5 в переменную var, потому что до %n было выведено 5 символов. Контроль над записываемым значением — через спецификатор ширины: printf("%100c%n", <val>, &var) выведет символ с паддингом до 100 символов и запишет 100 в var. В реальном эксплойте аргументы не передаются явно — printf читает значения со стека; %c удобнее %d, так как безопасно интерпретирует любое стековое значение как символ. Подбирая ширину, записываем произвольное число.

Точная запись: %hn и %hhn вместо гигабайтного вывода

Записать 32-битное значение одним %n — скажем, 0xdeadbeef = 3 735 928 559 символов — технически возможно, но бинарник скорее упадёт раньше, чем printf выведет 3.7 гигабайта текста. По данным hackinglab.cz, стандартный подход — разбить запись на части через %hn (2 байта, short) или %hhn (1 байт).

Для записи 0xdeadbeef по адресу 0x0804a020 через %hn алгоритм такой:

  1. Пишем 0xbeef (48879 десятичных) по адресу 0x0804a020 — настраиваем padding так, чтобы к моменту %hn было выведено ровно 48879 символов
  2. Пишем 0xdead (57005 десятичных) по адресу 0x0804a022 — учитываем уже выведенные символы, добавляем разницу

Арифметика быстро становится нетривиальной: нужно учитывать длину самого payload-а, порядок записей, модулярную арифметику (значения «оборачиваются» через 0xffff для %hn). Именно поэтому ручная эксплуатация format string %n — упражнение для понимания механики, а в реальных CTF используют автоматизацию. Я обычно один раз прохожу руками, чтобы убедиться, что понимаю offset и порядок записей, а потом переключаюсь на pwntools.

Цели записи: GOT overwrite и не только

[Применимо: CTF-таски с Partial RELRO, x86/x86-64. Не работает при Full RELRO.]

Самая распространённая цель записи в CTF — подмена записи в Global Offset Table (GOT). При Partial RELRO (проверяем через checksec --file=./binary) GOT доступна на запись. Заменяем адрес printf в GOT на адрес system — и следующий вызов printf(buf) с буфером "/bin/sh" превращается в system("/bin/sh"). Шелл получен.

Механика: при Partial RELRO первый вызов printf проходит через PLT → GOT → dynamic linker, который записывает реальный адрес printf в GOT. При следующих вызовах PLT сразу прыгает по адресу из GOT. Перезаписываем этот адрес на system — и любой последующий вызов printf(buf) становится system(buf).

При Full RELRO секция GOT помечена как read-only после загрузки. Тут нужны альтернативные цели: .fini_array, __malloc_hook/__free_hook (работает в libc до glibc 2.34 — в более новых версиях хуки удалены), или адрес возврата на стеке.

Автоматизация: pwntools FmtStr для решения pwn задач

Ручной расчёт padding-ов и сборка payload-а для %hn-записей — процесс, на котором легко ошибиться на один байт и потерять час на отладку. Класс FmtStr из pwntools автоматизирует всё это. Его API подтверждён локальной интроспекцией библиотеки: конструктор принимает execute_fmt (функцию, выполняющую format string и возвращающую вывод), offset, padlen, numbwritten, badbytes.

from pwn import *
elf = ELF('./vuln')

def exec_fmt(payload):
    p = process('./vuln')
    p.sendline(payload)
    return p.recvall(timeout=1)

# автоматически находит offset
autofmt = FmtStr(execute_fmt=exec_fmt)
log.info("Offset: %d" % autofmt.offset)

# регистрируем запись и отправляем
autofmt.write(elf.symbols['secret'], 0x41414141)
autofmt.execute_writes()

Что здесь происходит:

FmtStr(execute_fmt=exec_fmt) — создаёт объект и через внутренний метод find_offset автоматически определяет смещение. Он отправляет тестовые payload-ы и ищет контролируемый маркер в выводе — ровно то, что мы делали вручную с AAAA%p%p%p, только без ручного подсчёта.

autofmt.write(addr, data) — регистрирует запись значения data по адресу addr. Не отправляет payload сразу — добавляет запись в очередь. Можно вызвать write несколько раз для разных адресов.

autofmt.execute_writes() — формирует оптимальный payload с %hn/%hhn-записями и отправляет его через exec_fmt. Pwntools сам рассчитывает padding, порядок записей и модулярную арифметику. Для утечки данных есть метод leak_stack(offset, prefix) — он читает значение на указанном смещении стека, что удобно для автоматизированного сбора адресов при ASLR bypass format string.

Для полной цепочки с обходом ASLR скрипт расширяется: сначала через leak_stack утекаем адрес из libc, вычисляем базу, затем через write перезаписываем GOT-запись на system. Весь binary pwn writeup укладывается в 20-30 строк Python. После нескольких тасков начинаешь писать эти скрипты на автомате — меняется только offset и адреса.

Место format string в цепочке атаки и ограничения техники

Format string уязвимость не живёт в вакууме. В контексте MITRE ATT&CK она покрывает несколько тактик в зависимости от этапа:

  • System Information Discovery (T1082, Discovery) — утечка стека и адресов libc
  • Exploitation for Client Execution (T1203, Execution) — когда эксплуатация ведёт к исполнению произвольного кода
  • Exploitation for Privilege Escalation (T1068, Privilege Escalation) — в сетевых сервисах, работающих с повышенными привилегиями

Полная цепочка format string эксплуатации в CTF:

  1. Обнаружение — находим вызов printf(user_input) в исходниках или через реверс (Initial Access / Exploit Public-Facing Application, T1190, если это сетевой сервис)
  2. Определение смещения — серия %p для нахождения offset
  3. Утечка адресов — stack leak exploitation для получения libc base, обход ASLR
  4. Произвольная запись — GOT overwrite: подменяем printf@GOT на system
  5. Триггер — отправляем "/bin/sh" как ввод, printf(buf)system("/bin/sh") → шелл

Когда техника не работает

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

FORTIFY_SOURCE — GCC-флаг, включённый по умолчанию в большинстве дистрибутивов. При -D_FORTIFY_SOURCE=2 компилятор заменяет printf на __printf_chk, которая блокирует %n, если формат-строка лежит в writable-памяти (стек, heap, .data), и требует последовательного использования всех позиционных аргументов %N$ — нельзя написать %7$n, не указав %1$..%6$. Это ломает стандартный direct parameter access exploit. Если CTF-таск скомпилирован с этим флагом, запись через %n в большинстве сценариев не пройдёт. Чтение через %p/%x/%s при этом работает — FORTIFY_SOURCE ограничивает только запись.

Full RELRO — делает GOT read-only после загрузки. Проверка: checksec --file=./binary. При Full RELRO GOT overwrite невозможен, нужны альтернативные цели.

Compiler warnings — GCC и Clang выдают -Wformat-security при вызове printf с нефиксированной формат-строкой. В production-коде с CI/CD и -Werror такие баги почти не проходят. Но в CTF, embedded-прошивках и legacy-коде — встречаются регулярно.

64-bit с PIE — на x86-64 с Position Independent Executable адреса самого бинарника рандомизируются. Для записи в GOT нужно сначала утечь базу бинарника, а не только libc — двойной leak, двойная работа.

[Применимо: CTF-таски, embedded/IoT-прошивки, legacy UNIX-сервисы. В modern production с -Werror=format-security и FORTIFY_SOURCE=2 уязвимость практически не встречается.]

Format string — одна из самых мощных примитивных уязвимостей в binary exploitation CTF: из единственного printf-вызова получаем и чтение (arbitrary memory read), и запись (arbitrary write), и в итоге исполнение кода. В CTF она чаще попадается на уровне medium-hard, потому что требует точного понимания стека и арифметики padding-ов — навыков, которые чистое переполнение буфера не тренирует.

Я вижу закономерность: команды, которые стабильно решают format string таски, обычно лучше работают и с heap exploitation. Не потому что техники похожи — а потому что обе требуют точного понимания layout-а памяти и умения конструировать примитивы "read/write anywhere" из ограниченного набора операций. BOF даёт контроль через перезапись — грубо и линейно. Format string exploit учит работать со стеком как с API: ты не ломаешь границы буфера, а используешь штатный механизм printf не по назначению. Это ближе к тому, как работают реальные атакующие — не через дыры, а через легитимные интерфейсы с нелегитимными параметрами.

Большинство CTF-игроков, с которыми сталкиваюсь на тренировках, пропускают format string в пользу «привычного» buffer overflow — и это стратегическая ошибка. Если вы умеете переполнить буфер, но не можете эксплуатировать printf, вы не понимаете стек — вы понимаете один конкретный паттерн атаки. Format string заставляет разобраться, как на самом деле работают вызовы функций, va_arg, раскладка фреймов. Этот фундамент потом экономит часы при разборе heap-тасков и kernel-эксплуатации.

Кто освоил format string до уровня «пишу эксплойт на бумаге без GDB» — попробуйте следующий шаг: 64-bit бинарник с PIE и Full RELRO. Там offset другой (шесть аргументов уходят в регистры), GOT перезаписать нельзя, адреса меняются при каждом запуске. Именно на этом уровне отсеиваются 80 процентов CTF-игроков в категории pwn — и именно там начинается настоящее понимание binary exploitation. На WAPT эту цепочку проходят в течение двух модулей с лабами.

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

0 комментариев

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

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