Логотип
Главная | Статьи | Эксплуатация формат-строк в современных приложениях
Эксплуатация формат-строк в современных приложениях

Эксплуатация формат-строк в современных приложениях

23 июля, 2025

58

Салют, боец! Сегодня мы вскроем одну из самых красивых и, казалось бы, мертвых уязвимостей – форматные строки (Format String Bug). Многие думают, что это артефакт из 90-х, но я тебя уверяю: в мире IoT, эмбеддеда и просто криво написанного легаси-кода эта старушка до сих пор задает жару.

Почему она так крута? Потому что она дает нам сразу две мощнейшие примитивы: чтение произвольной памяти и запись в произвольную память. Это джекпот. Ты можешь слить пароли, ключи, обойти ASLR, переписать адреса возврата и захватить управление.

Готов? Тогда погнали. Никакой воды, только хардкор.

Часть 1: Теория на пальцах. Что под капотом?

Все дело в функциях семейства printf, таких как printffprintfsprintfsnprintf. Они работают так:

  1. Берут форматную строку (первый аргумент).
  2. Ищут в ней спецификаторы формата: %s%d%x%p, и т.д.
  3. На каждый спецификатор они ожидают увидеть аргумент в стеке. printf("%s %d", "hello", 10) возьмет адрес строки “hello” и число 10 из стека.

Уязвимость возникает, когда программист делает так:

Вместо правильного printf("%s", user_input).

Что происходит? printf теперь считает, что строка юзера — это форматная строка. И если мы передадим ей строку вроде "AAAA %x %x %x", она честно выведет “AAAA”, а потом начнет вытаскивать из стека значения одно за другим и печатать их в hex-виде. Чьи значения? Да чьи попало! Все, что лежало в стеке в момент вызова функции: локальные переменные, аргументы, сохраненный EBP/RBP, адрес возврата… Все наше.

Ключевые спецификаторы для нас:

  • %x — читает значение из стека и выводит как hex. Наш скальпель для чтения.
  • %s — читает адрес из стека и пытается вывести строку по этому адресу. Может уронить прогу, если адрес кривой, но иногда позволяет слить целые куски памяти.
  • %p — указатель. Часто ведет себя как %x, но более канонично для слива адресов.
  • %n — наше главное оружие. Записывает количество символов, уже выведенных этой функцией, в переменную, адрес которой лежит в стеке. Это примитив записи.
  • %<N>$x — лайфхак для современных систем. Прямой доступ к N-ному аргументу. Например, %6$x выведет 6-й аргумент, не трогая первые пять. Это позволяет нам точно бить по цели, а не перебирать стек кучей %x.

Часть 2: Наш полигон. Уязвимый код

Напишем простую прогу, которую будем ломать. В ней будет уязвимость и “секрет”, который мы попытаемся украсть.

vuln.c

Компиляция для атаки:

Чтобы нам не мешали современные защиты, скомпилируем код с флагами, которые их отключат. В реальной жизни их пришлось бы обходить, но для учебы так нагляднее.

  • -fno-stack-protector: отключает “канарейки” (stack canaries), которые защищают от переполнения буфера.
  • -no-pie: отключает Position-Independent Executable. Без него адреса кода будут всегда одинаковыми, что упрощает атаку.
  • -z execstack: делает стек исполняемым (для других типов атак).

Часть 3: Атака! Читаем стек

Наша цель — прочитать значение переменной secret (0xDEADBEEF), не зная его. Мы знаем, что оно где-то на стеке.

Шаг 1: Ищем нашу строку на стеке.

Подадим на вход что-то узнаваемое и посмотрим, где оно появится.

Смотри! 0x41414141 — это “AAAA” в hex. Оно на 6-й позиции после нашей строки. Это значит, что наш ввод находится по смещению 6 от начала аргументов printf.

Шаг 2: Читаем секрет напрямую.

Теперь, зная смещение, мы можем использовать прямой доступ. Нас интересует значение перед нашим буфером. Давай попробуем разные смещения.

В стеке все лежит примерно так (упрощенно):

... | [аргументы printf] | ... | [адрес возврата] | [сохраненный EBP] | secret | buffer | ...

Так как наш буфер на 6-й позиции, secret должен быть где-то рядом. Попробуем 5-ю.

Бинго! Мы только что украли секретное значение из памяти, используя лишь одну форматную строку. Мы не переполняли буфер, не ломали логику. Мы просто попросили printf показать нам то, что лежит у нее под носом.

Часть 4: Запись в память с %n

Чтение — это круто, но настоящая мощь — это запись. Спецификатор %n записывает количество напечатанных байт по адресу, который он берет из стека.

Концепция атаки:

  1. Нам нужно, чтобы адрес, куда мы хотим писать, оказался на стеке.
  2. Мы должны передать его printf как “аргумент”.
  3. Мы должны напечатать ровно столько символов, сколько нужно, чтобы записать желаемое значение.

Представим, что мы хотим перезаписать secret значением 0x1337.

Это уже сложнее и требует точного контроля над стеком. Полный эксплойт для записи выглядит громоздко, но вот его суть:

  1. Формируем payload. Он будет состоять из:
    • Адреса переменной secret. Нам нужно его как-то узнать (например, слить через тот же %p, если ASLR включен). Допустим, мы его знаем: 0xffffd49c.
    • Спецификатора %n с прямым доступом к нашему адресу на стеке.
    • “Набивки” из символов, чтобы printf вывел нужное количество байт. 0x1337 в десятичной системе — это 4919.
  2. Собираем эксплойт.

Наш payload будет выглядеть примерно так:
[адрес_secret_в_бинарном_виде]...%<padding>x%<offset>$n

  • [адрес_secret...]\x9c\xd4\xff\xff (в little-endian)
  • %<offset>$n: Мы уже знаем, что наш ввод на 6-м месте. Значит, %6$n.
  • %<padding>x: Нам нужно напечатать 4919 символов. 4 байта адреса мы уже “напечатали”. Значит, нужно еще 4919 – 4 = 4915 символов. Используем спецификатор %4915x.

Финальный эксплойт (с использованием python для удобства):

После выполнения этой команды, если мы поставим брейкпоинт в GDB, мы увидим, что значение secret изменилось с 0xdeadbeef на 0x1337. Мы только что изменили память процесса. Отсюда один шаг до перезаписи адреса возврата и выполнения своего шеллкода.

Часть 5: Что в современных системах?

Да, сейчас есть ASLR, PIE, RELRO, Stack Canaries. Но:

  • ASLR/PIE обходится утечкой. А форматная строка — идеальный инструмент для утечки адресов из стека (libc, база программы). Слил адрес -> рассчитал смещения -> атаковал.
  • Stack Canaries обходятся. %n позволяет писать точечно. Мы можем перезаписать переменную, указатель на функцию или адрес возврата, не задев канарейку.
  • RELRO (Read-only GOT) — вот это серьезная защита. Она делает таблицу Global Offset Table доступной только для чтения, закрывая популярный вектор атаки. Но остаются другие цели: vtables в C++, хуки, структуры на стеке/куче.

Вывод

Форматные строки — это не просто баг, это швейцарский нож для эксплуатации. Он дает тебе контроль над памятью там, где другие уязвимости бессильны. Понимание этого механизма отличает новичка от профи.

Теперь твоя очередь. Компилируй, ломай, изучай в GDB, как гуляют данные по стеку. Попробуй изменить эксплойт для 64-битной системы (подсказка: адреса станут длиннее, смещения могут поменяться). Только так становятся настоящими специалистами.

Удачи, хакер.