Главная / Блог / Format string уязвимости в CTF: от %p до shell

13 мин.00

Format string уязвимости в CTF: от %p до shell

Format string уязвимости в CTF: от %p до shell

CWE-134 — Use of Externally-Controlled Format String. Один вызов printf(buf) даёт атакующему сразу три примитива: чтение памяти, запись в память, выполнение произвольного кода. На picoCTF 2024 задача Format String 3 это наглядно показала: offset пользовательского ввода на стеке составлял 38 позиций. Тридцать восемь. Именно эта цифра разделила участников на тех, кто понимает механику printf изнутри, и тех, кто копирует payload из чужих writeup'ов, не понимая, почему он вообще работает. Ниже — полная цепочка эксплуатации format string уязвимости: от поиска offset до GOT overwrite, с разбором архитектурных различий x86/x86-64, защитных механизмов и приёмов, которые реально экономят время на соревнованиях.

Почему printf(buf) превращается в arbitrary read/write

Эксплуатация format string возникает в одном сценарии: программист передаёт пользовательский ввод первым аргументом в printf() или родственную функцию — sprintf, fprintf, snprintf, syslog. Функция ожидает форматную строку с описанием типов и количества аргументов. Когда вместо фиксированной строки приходит контролируемый буфер — атакующий диктует поведение printf. Подробнее — в нашем обзоре бинарный анализ уязвимостей.

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

#include <stdio.h>
int main() {
    char buf[256];
    fgets(buf, 256, stdin);
    printf(buf);
    return 0;
}

Компиляция для экспериментов: gcc -fno-stack-protector -no-pie -Wl,-z,norelro -o vuln vuln.c. Тут -fno-stack-protector убирает stack canary, -no-pie фиксирует адреса секций, -z,norelro оставляет GOT доступным для записи. Такая конфигурация — стандарт для учебных format string CTF задач. В продвинутых задачах PIE и Full RELRO включены, и вектор атаки меняется радикально.

По классификации OWASP — Injection (A03:2021): данные пользователя интерпретируются как управляющие команды без валидации. В терминах MITRE ATT&CK — Exploit Public-Facing Application (T1190, Initial Access). Согласно CWE-134, последствия: Read Memory, Modify Memory, Execute Unauthorized Code or Commands.

Зачем это атакующему

В CTF цель прямолинейна — получить shell и прочитать флаг. В реальных системах format string уязвимость в сетевом сервисе (FTP-демон, веб-приложение, IoT-прошивка) даёт удалённое выполнение кода без аутентификации. Классический пример — WU-FTPD в 2000 году, через который получали root на серверах. Из свежего — format string в Ghostscript, замеченная в реальных атаках в 2024 году.

Стек вызовов printf: различия x86 и x86-64

Прежде чем строить payload, нужно понять, откуда printf берёт аргументы. Механизм принципиально отличается между архитектурами — и это напрямую определяет offset, ключевой параметр всей эксплуатации.

x86 (32 бита): все аргументы на стеке

По конвенции cdecl аргументы кладутся на стек в обратном порядке. Когда printf встречает спецификатор %p, она берёт следующее значение со стека через va_arg. Проблема в том, что va_arg не проверяет, сколько аргументов реально передано — просто сдвигает указатель дальше. Пользовательский буфер лежит близко к точке чтения, обычно через 4–7 позиций. Каждая позиция — 4 байта (размер слова на x86).

x86-64 (64 бита): сначала регистры

По System V AMD64 ABI первый аргумент printf (указатель на форматную строку) уходит в rdi. Следующие пять variadic-аргументов заполняются из rsi, rdx, rcx, r8, r9. Только начиная с шестого variadic-аргумента printf переходит к чтению со стека. Между регистровыми позициями и пользовательским буфером на стеке лежат локальные переменные, сохранённые регистры, фреймы вызовов. Типичный offset на x86-64 — от 6 и выше. В picoCTF 2024 Format String 3 offset составлял 38 позиций. Тридцать восемь слов между точкой чтения printf и началом пользовательского ввода.

Практическое следствие: один и тот же бинарник, собранный под 32 и 64 бита, потребует разных offset. В PicoCTF 2022 flag leak флаг на 32-битном бинарнике читался через %6$s, а на 64-битной версии потребовался бы %12$s. Причина — шесть регистровых аргументов, которые printf обрабатывает до перехода к стеку.

Ключевые спецификаторы формата

Для эксплуатации format string хватит нескольких спецификаторов. %p — печатает значение как указатель (hex), безопасен для разведки. %x — шестнадцатеричное число без префикса 0x. %s — интерпретирует значение как указатель и читает строку по этому адресу, опасен: невалидный адрес — SIGSEGV. %n — записывает количество напечатанных символов по адресу аргумента. %hn и %hhn — записывают 2 и 1 байт соответственно. %Nc (например %100c) — печатает один символ с дополнением до ширины N, увеличивая счётчик printf на N.

Поиск offset: первый шаг эксплуатации format string

Offset — позиция на стеке, где printf находит начало пользовательского ввода. Без правильного offset ни утечка адресов, ни запись через %n не сработают. Определение offset — первое действие при эксплуатации.

Метод перебора через %p

Вводим AAAAAAAA %p %p %p %p %p %p %p %p и ищем в выводе 0x4141414141414141 (ASCII A = 0x41, восемь символов A = восемь байт на 64-bit). Позиция этого значения в выводе — искомый offset. Метод рабочий, но при большом offset (30+) цепочка %p не влезет в ограниченный буфер.

Direct parameter access: %N$p

Синтаксис %N$p обращается к N-му аргументу printf напрямую, минуя все предыдущие. Вводим AAAAAAAA %6$p — если на выходе 0x4141414141414141, offset = 6. Нет — пробуем 7, 8, 9 и далее. Как отмечается в курсе Georgia Tech CS6265, без direct parameter access длина payload ограничена размером буфера: десятки %p не влезут в 64-байтный ввод, а %6$p занимает всего 4 символа. Этот же синтаксис работает с любым спецификатором: %6$x, %6$s, %6$n.

Если дошли до позиции 15 без результата — не останавливайтесь. Offset выше 30 — нормальная ситуация для 64-битных бинарников с большим количеством локальных переменных или вложенными вызовами.

Верификация через GDB

Breakpoint на printf, затем x/40gx $rsp (64-bit) или x/40wx $esp (32-bit). Считаем количество слов от $rsp до значения 0x4141414141414141. Каждое 8-байтное слово на x86-64 — одна позиция, каждое 4-байтное на x86 — одна позиция. В pwndbg команда telescope 40 делает то же самое, но с аннотациями: показывает символы и разрешает указатели. Я обычно начинаю именно с telescope — экономит пару минут на ручном подсчёте.

Чтение памяти через printf: утечка стека и обход ASLR

После определения offset начинается разведка. Цель — вытянуть со стека адреса, необходимые для построения payload.

Разведка стека через %p

Спецификатор %p безопасен: печатает значение как указатель без разыменования. При невалидном значении — просто число, без crash. Последовательность %1$p.%2$p.%3$p...%20$p выдаёт содержимое 20 позиций стека. На стеке обычно лежат:

Адреса возврата из libc — return address в __libc_start_main или __libc_start_call_main. Вычитая известный offset функции, получаем base address libc. Это ломает ASLR: libc_base = leaked_addr - known_offset. После этого адреса system(), строки /bin/sh и любых других символов libc вычисляются арифметически.

Stack canary — при включённом -fstack-protector 8-байтное значение canary лежит на стеке между локальными переменными и сохранённым rbp. Характерная примета — байт \x00 на конце. Утечка canary через format string открывает комбинированную атаку: format string для утечки + buffer overflow для перезаписи return address с подстановкой настоящего canary.

Адреса из бинарника — при включённом PIE позволяют вычислить PIE base. Нужен для пересчёта адресов GOT, PLT и внутренних функций.

Данные на стеке — иногда флаг CTF-задачи лежит прямо тут. В PicoCTF 2022 flag leak функция readflag() загружала содержимое flag.txt в локальный буфер, и %6$s читал его напрямую — без вычислений, без GOT overwrite, одним запросом.

Arbitrary read через %s

Спецификатор %s интерпретирует значение на стеке как указатель и читает строку по этому адресу до первого null byte. Размещаем целевой адрес в своём вводе (а мы знаем его offset) и обращаемся через %N$s — получаем содержимое произвольного адреса памяти. Полноценный arbitrary read примитив. Так можно читать GOT-записи для определения версии libc, содержимое конфигов и любые другие данные в адресном пространстве процесса.

Предупреждение: если по адресу невалидная память — SIGSEGV и crash. На удалённом CTF-сервере это потеря соединения. Для начальной разведки всегда %p. К %s переходим только с проверенными адресами.

Утечка libc base: пошаговый процесс

Практический сценарий обхода ASLR: отправляем %N$p для нескольких позиций вокруг ожидаемого return address. В GDB определяем, какая утечка принадлежит libc (адреса libc в Linux обычно начинаются с 0x7f... на 64-bit). Вычисляем offset: в GDB выполняем p/x leaked_value - libc_base. На удалённом сервере: libc_base = leaked_value - offset. Из базы вычисляем system_addr = libc_base + offset_of_system и binsh_addr = libc_base + offset_of_binsh. Для определения правильных offset удобен libc-database — по утечённым адресам двух-трёх функций он определяет точную версию libc на сервере.

В pwntools метод leak_stack(offset, prefix=b'') класса FmtStr автоматизирует чтение стека. На соревнованиях ручная проверка через %N$p быстрее для начальной разведки, автоматику подключаешь для массовых утечек.

Запись памяти через printf: %n и побайтовый контроль

Спецификатор %n — тот самый механизм, который превращает format string из инструмента чтения в полноценный arbitrary write. Он записывает по адресу аргумента количество символов, которые printf уже напечатал к этому моменту. Как сказано в CWE-134: %n writes the number of characters printed so far to the given argument.

Три варианта по размеру записи

%n записывает 4 байта (int). Для значения 0x0804 нужно напечатать 2052 символа — терпимо. Для 0x7fffffff — более двух миллиардов. Printf честно попытается, и процесс повиснет на минуты. %hn записывает 2 байта (short), максимум 65535 символов на одну операцию — приемлемо для большинства сценариев. %hhn записывает 1 байт, максимум 255 символов — точно, быстро, предсказуемо.

На практике %hhn — рабочая лошадка. Запись полного 8-байтного адреса через 6–8 побайтовых операций, каждая контролируемая.

Управление записываемым значением

Количество напечатанных символов управляется через спецификатор ширины. %100c заставляет printf напечатать один символ с дополнением пробелами до ширины 100 — итого +100 к счётчику. После %100c%N$hhn по адресу N-го аргумента будет записано (текущий_счётчик + 100) mod 256.

Алгоритм побайтовой записи адреса system() = 0x7ffff7c58750 в GOT-entry:

  1. Разбиваем на байты: 0x50, 0x87, 0xc5, 0xf7, 0xff, 0x7f.
  2. Для каждого байта размещаем на стеке адрес GOT_entry + i (i от 0 до 5).
  3. Сортируем байты по возрастанию значений — минимизируем суммарную длину payload.
  4. Для каждого байта вычисляем ширину: (целевой_байт - текущий_счётчик) mod 256. Если результат 0, пишем 256.
  5. Формируем %Xc%N$hhn для каждого байта последовательно.

Ручное построение такого payload — кропотливая и ошибкоёмкая работа. Pwntools автоматизирует её полностью.

GOT overwrite через pwntools: полная цепочка эксплуатации

GOT (Global Offset Table) хранит реальные адреса внешних функций после динамической линковки. При Partial RELRO таблица остаётся доступной для записи. Идея: заменить адрес функции (например puts) на адрес system(). При следующем вызове puts(user_string), если user_string содержит /bin/sh, процесс выполнит system("/bin/sh").

Практический exploit на pwntools

from pwn import *

elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = process('./vuln')

# Утекаем адрес libc через format string
p.sendline(b'%11$p')
leak = int(p.recvline(), 16)
libc.address = leak - libc.symbols['__libc_start_main'] - 243

# GOT overwrite: puts -> system
writes = {elf.got['puts']: libc.symbols['system']}
payload = fmtstr_payload(6, writes)
p.sendline(payload)
p.interactive()

Функция fmtstr_payload(offset, writes) принимает offset ввода на стеке и словарь {адрес: значение}. Она автоматически генерирует побайтовую запись через %hhn, рассчитывает все значения ширины и размещает адреса на правильных позициях. Конкретный offset (6 в примере) — значение, найденное на этапе разведки. Словарь writes может содержать несколько пар для одновременной перезаписи нескольких GOT-записей.

Для задач с несколькими раундами взаимодействия (утечка → вычисление → запись) удобнее класс FmtStr. Конструктор FmtStr(execute_fmt, offset=N) принимает функцию для отправки payload. write(addr, data) задаёт адрес и данные. execute_writes() собирает итоговый payload и отправляет. find_offset() автоматически определяет offset — отправляет серию %N$p и ищет маркер.

Защиты и их влияние на эксплуатацию format string

checksec ./binary — первое, что запускаем после скачивания таска. Каждая защита меняет тактику.

Full RELRO: GOT закрыт

При Full RELRO секция GOT становится read-only после загрузки бинарника. GOT overwrite невозможен. Куда писать вместо GOT:

Return address на стеке — перезаписываем адрес возврата на one_gadget или начало ROP-цепочки. Требует точного знания расположения return address.

__malloc_hook / __free_hook — функции-хуки glibc, вызываемые при malloc()/free(). Рабочий вектор до glibc 2.34. Начиная с 2.34 хуки удалены из кодовой базы — так что проверяйте версию.

.fini_array — массив указателей на деструкторы, вызываемые при exit(). Перезаписываем указатель на system() или one_gadget.

PIE: адреса бинарника рандомизированы

Base address бинарника меняется при каждом запуске. Адреса GOT, PLT, секций неизвестны. Решение: через format string утекаем адрес из бинарника (return address в main или указатель на функцию), вычисляем PIE base и пересчитываем всё остальное. На стеке обычно лежат return address не только из libc, но и из самого бинарника.

FORTIFY_SOURCE: printf заменяется на __printf_chk

Компиляция с -D_FORTIFY_SOURCE=2 подставляет __printf_chk вместо printf. Обёртка запрещает %n в форматных строках, не соответствующих количеству переданных аргументов. На практике блокирует большинство format string атак. В CTF обычно отключён, но в production — первая линия защиты.

Stack canary

Canary не защищает от format string напрямую — атака не перезаписывает стек последовательно как при buffer overflow. Зато canary можно утечь через %p и использовать в комбинированной атаке: format string для утечки canary → buffer overflow для перезаписи return address с подстановкой настоящего значения canary.

Blind format string: эксплуатация без вывода

В продвинутых задачах и реальных уязвимостях вывод printf недоступен атакующему. Исследователи Synacktiv описывали эксплуатацию камеры Synology TC500 на Pwn2Own Ireland 2024: payload ограничен 128 символами, null bytes и символы 0x00–0x1F запрещены, все защиты (ASLR, PIE, NX, Full RELRO) включены, а формат строки обрабатывается внутри vsnprintf без видимого вывода клиенту.

Ключевая техника — двойной указатель на стеке (looping pointer). Атакующий находит на стеке указатель, через цепочку ссылок ведущий к другому стековому указателю. Побайтовым изменением младшего байта (LSB) первого указателя перенаправляет второй, создавая управляемую запись в произвольную область стека. ROP-цепочка строится через %*X$c (читает значение с позиции X и добавляет к счётчику символов) с последующей записью через %Z$n. Процесс повторяется для каждого гаджета.

Это уровень Pwn2Own и финалов крупных CTF. Фундамент для перехода к такой эксплуатации закладывается именно в понимании базовой механики format string из предыдущих разделов.

Типичные ошибки при format string эксплуатации на CTF

Неверный offset. Offset зависит от конкретного бинарника: компилятора, уровня оптимизации, количества локальных переменных. Два 64-битных бинарника с похожим исходным кодом могут дать offset 6 и 38. Проверяйте каждый раз на конкретном файле.

Путаница с endianness. Адреса в payload размещаются в little-endian: 0x08049724 пишется как \x24\x97\x04\x08. В pwntools p32() и p64() обрабатывают порядок байт автоматически — используйте их вместо ручной упаковки.

Null bytes в 64-битных адресах. Адреса вида 0x00007ffff7... содержат ведущие \x00. При передаче через fgets() null byte обрежет строку. Решение: размещайте адреса в конце payload, после всех спецификаторов формата. fmtstr_payload делает это автоматически.

Сбой счётчика при ручном построении. Каждый %Xc добавляет X к счётчику printf, но и другие элементы payload вносят вклад: сами адреса занимают байты, спецификаторы печатают символы. При ручном расчёте легко ошибиться на пару байт — и записать 0x87 вместо 0x85. Автоматизация через fmtstr_payload исключает арифметические ошибки.

Таймаут при крупных записях. При записи значения 0x7fffffff через %n printf пытается напечатать более двух миллиардов символов. На удалённом CTF-сервере с таймаутом 30 секунд — гарантированный обрыв соединения. Побайтовая запись через %hhn решает проблему: максимум 255 символов на одну операцию.

За три года решения pwn-задач от Protostar до DEF CON Quals я заметил один устойчивый паттерн: формально правильный exploit разваливается не из-за ошибки в payload, а из-за непонимания, что именно лежит на стеке. Участник копирует fmtstr_payload(6, writes) из writeup, получает segfault — и не знает, куда смотреть. Потому что никогда не открывал GDB, не считал позиции руками, не проверял, какие значения printf реально видит на каждой позиции. Format string — не отдельная техника, это инструмент для понимания работы стека в целом: calling conventions, frame layout, взаимодействие компилятора с runtime. Тот, кто разбирает механику printf до уровня «могу объяснить каждый байт payload», решает и heap exploitation, и kernel pwn быстрее. Большинство CTF-игроков останавливаются на уровне шаблонов и упираются в потолок при первом нестандартном layout. Full RELRO стал дефолтом, PIE включён повсеместно, классический GOT overwrite работает только на учебных уровнях — а дальше нужна именно глубина. Если writeup'ов уже недостаточно и хочется пройти от базового format string до сложных цепочек с прогрессией — на WAPT эту траекторию выстроили в модули с лабами на каждый кейс.

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

Поделиться

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

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

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

Читайте также

Крипто CTF для начинающих: от Цезаря до RSA

13 мин.

11

Крипто CTF для начинающих: от Цезаря до RSA

Пошаговый разбор crypto CTF: от Цезаря и XOR до RSA с малой экспонентой. Три типичные ошибки новичков, рабочий скрипт gmpy2 и чек-лист факторизации.

6 ОКТЯБРЬ, 2026

Path traversal уязвимость в CTF: от LFI до RCE

14 мин.

9

Path traversal уязвимость в CTF: от LFI до RCE

Пошаговый разбор path traversal и LFI для CTF: bypass фильтров, PHP wrappers, log poisoning, чек-лист файлов Linux/Windows. Реальный CVE-2024-40348.

6 ОКТЯБРЬ, 2026

Анализ PCAP файлов: пароли и файлы из дампа

9 мин.

10

Анализ PCAP файлов: пароли и файлы из дампа

Пошаговый разбор извлечения паролей и файлов из PCAP-дампов: Wireshark, tshark, BruteShark. Готовые команды и фильтры для forensics-тасков CTF.

5 ОКТЯБРЬ, 2026