Как закрыть format-string уязвимости в старом ПО: практический план

Format-string уязвимость в старом программном обеспечении опасна не тем, что звучит сложно, а тем, что её часто оставляют «на потом». Особенно если сервис давно работает, исходники потеряны, а обновляться страшно из-за совместимости. На практике такие ошибки встречаются в старом C/C++ коде, сетевых демонах, CLI-утилитах, логировании и встроенных устройствах. Их нужно закрывать системно: найти опасные вызовы, поправить формат, проверить сборку и снизить ущерб там, где быстрый патч невозможен.

Если сервис доступен из интернета, принимает данные от клиентов или уже есть сообщение об уязвимости в вашей версии, сначала ограничьте доступ к нему. Не начинайте с долгого аудита на боевой машине. Изоляция, VPN, firewall-правила или отключение публичного интерфейса могут дать несколько дней или недель на нормальный ремонт.

Почему старые версии чаще становятся проблемой

Сама по себе старая версия не гарантирует наличие format-string уязвимости. Проблема в том, что в старом ПО обычно совпадает сразу несколько рисков:

  • код писали до массового использования строгих предупреждений компилятора;
  • в логах и ошибках часто напрямую печатали пользовательские строки;
  • проект собран без PIE, RELRO, FORTIFY_SOURCE и других современных защит;
  • вендор уже не выпускает обновления или обновляет только новые ветки;
  • команда боится менять бинарник, потому что система годами работает «как есть».

Format-string ошибка появляется, когда строка формата берётся из ненадёжного источника. Например, пользовательский ввод, HTTP-запрос, аргумент командной строки, имя файла или строка из сети передаётся в функцию, которая ожидает шаблон формата.

Где чаще всего искать уязвимый код

В первую очередь смотрите не на «страшные» места, а на обычное логирование и вывод ошибок. Именно там разработчики часто писали так:

syslog(LOG_INFO, user_input);
printf(error_message);
fprintf(stderr, request_line);

Правильно было бы явно сказать, что пользовательская строка — это данные, а не формат:

syslog(LOG_INFO, "%s", user_input);
printf("%s", error_message);
fprintf(stderr, "%s", request_line);

Опасные функции, которые стоит проверить в старом коде:

  • printf(), fprintf(), sprintf(), snprintf();
  • syslog() и обёртки над ним;
  • err(), warn(), setproctitle() и похожие variadic-функции;
  • самописные макросы логирования;
  • старые библиотеки, которые печатают строки, полученные из сети или из конфигурации.

Главный признак проблемы: первый аргумент формата не является строковым литералом или приходит извне. Особенно подозрительны вызовы вида printf(buf), syslog(priority, msg), snprintf(out, len, user_format).

Порядок работ, если нужно закрыть уязвимость без лишней паники

  1. Соберите список старых компонентов. Запишите версии, где они запускаются, кто их обслуживает, есть ли исходники, можно ли собрать заново и кто зависит от этого сервиса.
  2. Определите поверхность атаки. Интернет-доступ, локальная сеть, CLI для обычных пользователей, логирование HTTP-запросов, SNMP, telnet, старые API — всё это влияет на срочность.
  3. Найдите подозрительные вызовы. Начните с поиска printf, fprintf, syslog, sprintf, snprintf и самописных логеров. Поиск не заменяет аудит, но быстро даёт список кандидатов.
  4. Проверьте, кто управляет форматом. Если формат задаётся константой в коде — обычно нормально. Если приходит из аргументов, файла, сети, переменной окружения или пользовательского ввода — это риск.
  5. Исправьте корень проблемы. Замените вызовы так, чтобы пользовательская строка шла через "%s", а не становилась форматом.
  6. Соберите с предупреждениями компилятора. Включите проверки формата и по возможности доведите их до ошибок сборки.
  7. Проверьте на тестовом стенде. Старое ПО часто ломается не от патча, а от новой сборки, новых флагов или другой версии libc.
  8. Добавьте компенсационные меры. Если полный ремонт невозможен сразу, ограничьте доступ, запускайте сервис с минимальными правами и мониторьте падения.

Какой вариант защиты выбрать

Ситуация Что делать Что это даёт Ограничения
Есть исходники и известна уязвимая функция Исправить вызовы формата и пересобрать Самый чистый вариант: убирается причина ошибки Нужно тестирование и аккуратный релиз
Есть исходники, но нельзя обновлять всю версию Перенести минимальный патч в старую ветку Меньше риска, чем полный апгрейд Нужно следить, чтобы патч не потерялся при следующей сборке
Исходников нет, но есть вендорская версия Запросить исправленную сборку или firmware Ответственность за совместимость остаётся у поставщика Если вендор не поддерживает версию, придётся изолировать или менять продукт
Сервис доступен извне, а патча пока нет Ограничить доступ, закрыть публичный порт, включить VPN или ACL Быстро снижает шанс эксплуатации Не устраняет уязвимость внутри программы
Код старый, но сервис внутренний Запланировать патч, включить аудит и hardened-сборку Позволяет закрыть риск без аварийной остановки Внутренний доступ тоже может быть опасен при компрометации сети
Патч невозможен, а замена займёт месяцы Изолировать, запускать без root-прав, мониторить падения, готовить замену Снижает ущерб и даёт время Это временная мера, а не нормальное состояние

Если исходники есть: не обновляйте всё сразу без необходимости

В старом ПО полный апгрейд часто опаснее точечного патча. Если старая версия работает на специфичной libc, старом ядре или с нестандартной конфигурацией, перенос на новую ветку может сломать больше, чем исправить.

Нормальный путь выглядит так:

  • найти конкретный опасный вызов;
  • понять, откуда берётся строка формата;
  • заменить её на литерал формата;
  • добавить тест с процентами во входных данных;
  • собрать с предупреждениями;
  • проверить на стенде, максимально похожем на production.

Например, если старый демон пишет в лог путь из HTTP-запроса, опасный вариант:

syslog(LOG_INFO, http_request_path);

Исправление простое, но его часто забывают сделать во всех местах:

syslog(LOG_INFO, "%s", http_request_path);

Для поиска кандидатов можно начать с команд вроде:

grep -RInE 'printf|fprintf|sprintf|snprintf|syslog' src/

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

Если исходников нет или версия закрытая

С закрытым старым ПО задача становится не «починить код», а «снизить риск до приемлемого уровня и подготовить замену». Здесь лучше работать по слоям:

  • узнать, есть ли у вендора исправленная версия или advisory;
  • если есть — обновить хотя бы компонент, а не всю систему;
  • если нет — закрыть внешний доступ к уязвимому интерфейсу;
  • разрешить подключение только с доверенных адресов;
  • запустить сервис с минимальными правами;
  • отключить неиспользуемые функции;
  • настроить мониторинг падений, core dump и необычных перезапусков;
  • заложить замену продукта в план модернизации.

Бинарный патч «на коленке» имеет смысл только если у вас есть специалисты, тестовый стенд и понимание ABI. Иначе можно получить ситуацию, когда format-string закрыли, но сервис начал падать от изменения памяти, версий библиотек или поведения libc.

Экранирование процентов — временная мера, не лечение

Иногда команда быстро меняет входные данные так, чтобы проценты превращались в безопасную строку. Это может помочь как аварийный костыль, но только если сделано везде и одинаково. Если хотя бы один путь обходит фильтр, риск остаётся.

Лучше не превращать пользовательскую строку в безопасный формат, а вообще не передавать её как формат. То есть не «чистить %», а писать "%s" и передавать строку аргументом.

Если другой дороги нет, экранируйте на границе входа и сразу ставьте задачу на нормальный патч. Такой обход нельзя считать закрытием уязвимости.

Сборка старого ПО с защитой от последствий

Hardened-сборка не исправляет format-string ошибку. Она снижает шанс, что атака приведёт к полноценному зах

Оцените статью
PEFile — Безопасность и технологии простым языком