Как одна опечатка в printf сливает секретные токены из памяти: разбираем Format String ...
Как одна опечатка в printf сливает секретные токены из памяти: разбираем Format String bug на ARM64 В прошлом посте мы разбирали, как современные компиляторы блокируют классическое переполнение буфера. Но что, если для чтения чужих секретов вообще не нужно ничего переполнять или ломать стек? Сегодня я протестировал в Parrot Security OS еще один фундаментальный класс уязвимостей - Format String Vulnerability (уязвимость строки формата). Делюсь экспериментом, где одной короткой строкой из терминала удалось вытащить закрытый токен прямо из оперативной памяти процесса. 1. В чем ошибка разработчика? В языке Си классический вывод строки на экран должен выглядеть так: printf("%s", user_input); Здесь первый аргумент - это жестко заданный шаблон формата (%s), а второй - пользовательские данные. Но разработчики нередко сокращают код до опасной конструкции: printf(user_input); На первый взгляд всё работает штатно: передал фразу «Hello Parrot» - программа напечатала «Hello Parrot». Но если пользователь передаст специальные спецификаторы вроде %p (вывод указателя), функция printf посчитает их служебными командами и начнет доставать значения из регистров и стека процессора! 2. Эксперимент: сканируем память аргументами Я написал микросервис, где внутри функции на стеке объявляется локальная переменная с секретным ключом: char secret_key[] = "SUPER_SECRET_TOKEN_2026"; Запускаем бинарник с серией спецификаторов: ./format "%p.%p.%p.%p.%p.%p.%p.%p" И программа послушно выводит сырые адреса из регистров: 0xfffff7ffdb60.(nil).(nil).0xffffffff.0xfbad2a84...0xffffffffeda0 Почему выводятся разные значения? В архитектуре ARM64 первые семь аргументов передаются через физические регистры процессора x1–x7, а всё, что идет дальше, printf начинает читать напрямую со стека. Мы получили чистый примитив Information Disclosure (утечка памяти). 3. Точечный удар: декодируем токен из шестнадцатеричных байт В синтаксисе printf можно обращаться к конкретным позициям стека через конструкцию вида %N$p. Я запросил срез данных со стека (обязательно в одинарных кавычках, чтобы Bash не перехватил знак доллара): ./format '%8$p %9$p %10$p %11$p %12$p %13$p %14$p %15$p' И в позициях 13, 14 и 15 терминал выдал три 64-битных блока данных: - 0x45535f5245505553 - 0x4b4f545f54455243 - 0x363230325f4e45 Поскольку архитектура ARM64 хранит байты в формате Little-Endian (младший байт первым), переводим шестнадцатеричные пары в символы ASCII справа налево: - 0x45... -> 53 (S), 55 (U), 50 (P), 45 (E), 52 (R), 5F (_), 53 (S), 45 (E) = SUPER_SE - 0x4B... -> 43 (C), 52 (R), 45 (E), 54 (T), 5F (_), 54 (T), 4F (O), 4B (K) = CRET_TOK - 0x36... -> 45 (E), 4E (N), 5F (_), 32 (2), 30 (0), 32 (2), 36 (6) = EN_2026 Складываем строки вместе - и получаем исходный токен: SUPER_SECRET_TOKEN_2026! Без отладчика, без декомпилятора и без прав администратора - секрет извлечен обычной строкой параметров. 4. Почему этот баг критичен для безопасности? В реальных пентестах и CTF уязвимости формата строки используют не только для чтения: 1. Обход ASLR и канареек: Атакующий сливает реальный адрес стека и системных библиотек, нивелируя рандомизацию адресного пространства. 2. Запись в произвольную память (Arbitrary Write): Спецификатор %n позволяет не читать, а записывать количество напечатанных байт по любому переданному адресу памяти, что позволяет перехватывать поток управления программы без переполнения буфера. 5. Как защитить свой код? Исправление элементарно и не требует переписывания архитектуры: - Всегда передавайте строку формата жестко заданным литералом: printf("%s", user_input); или используйте fputs(user_input, stdout);. - Включайте флаги строгого контроля компилятора: -Wformat -Wformat-security -Werror=format-security. При таких флагах gcc просто откажется компилировать небезопасный вызов.