Десять лет назад в glibc закрыли дыру, через которую через переменные окружения LC_ALL и LANG можно было подсунуть программе чужой файл перевода. Патч вышел под номером CVE-2014-0475 и с тех пор считался закрытым вопросом. 30 октября 2026 года выяснилось, что закрыт он был не полностью: переменная LANGUAGE, которую читает та же подсистема gettext, под фильтр не попала. Запись CVE-2026-84243 описывает, как десятилетней давности проблема вернулась через соседнюю дверь, и почему классический сценарий с ограниченной SSH-сессией снова оказался под ударом.

Как устроена проверка локали в glibc и где именно остался пробел

Подсистема gettext ищет файлы переводов (каталоги сообщений в формате .mo) по правилам, которые собираются из переменных окружения. Если нужный язык не "C", не "C.кодировка" и не "POSIX", библиотека проверяет LC_ALL, LC_MESSAGES и LANG, выбирает по ним имя локали и ищет под этим именем каталог на диске. Именно эту цепочку и чинили в 2014 году: тогда научили отбрасывать компонент пути "..", попавший в имя локали через одну из перечисленных переменных.

У LANGUAGE особый статус. Она задумана как список предпочтений через двоеточие, например ru:en:de, и позволяет пользователю выстроить порядок языков, на которые стоит переключаться, если основной перевод не найден. Библиотека её читает напрямую и подставляет каждую запись в путь к каталогу переводов, не прогоняя через проверку, добавленную для LC_ALL и LANG. При этом задействуется LANGUAGE только тогда, когда локаль сообщений (LC_MESSAGES) не равна "C", "C.кодировка" или "POSIX": значит, нападающему помимо LANGUAGE нужно управлять ещё и LANG или LC_ALL, чтобы включить ветку с альтернативными языками.

Итог простой: десятилетний фильтр закрыл не ту дверь. Он смотрел на LC_ALL, LC_MESSAGES и LANG, а символы "/" и последовательность ".." в значении LANGUAGE по-прежнему проходят без проверки, если процесс не является setuid или setgid, то есть не выставлен флаг AT_SECURE, который включает более строгий режим для привилегированных программ.

Что происходит, когда программа подхватывает поддельный каталог переводов

Каталог сообщений .mo - это не текстовый, а скомпилированный бинарный формат со своей внутренней структурой: заголовком, таблицей смещений и строками. Когда библиотека находит путь, собранный из подставленного значения LANGUAGE, она пытается прочитать файл как настоящий .mo-каталог, откуда бы он фактически ни был взят.

Если нападающий заранее положил в доступном для чтения месте специально собранный файл, события развиваются по одному из двух путей. Файл с некорректными смещениями внутри структуры заставляет библиотеку читать за пределами выделенного буфера, и программа аварийно завершается. Второй путь тоньше: подложенный перевод содержит форматные директивы (вроде %s или %d), которые не совпадают с тем, что реальная программа передаёт в функцию печати. Поскольку сама программа подставляет аргументы, рассчитывая на оригинальную строку формата, а получает чужую, семейство функций printf начинает работать с рассинхронизированными типами данных. Это классическая почва для неопределённого поведения, которое в худшем случае доводит дело до выполнения кода с правами того процесса, что вызвал gettext.

Описание уязвимости прямо называет два условия, без которых цепочка не сработает. Во-первых, у нападающего должна быть возможность поместить подготовленный файл в место, которое целевая программа способна прочитать. Во-вторых, переменные окружения должны пересекать границу доверия, то есть попадать в процесс, который не работает в защищённом режиме AT_SECURE. Это не абстрактная оговорка: именно такие границы доверия десятилетиями возникают там, где внешний пользователь может задать переменные окружения для чужого процесса, а сам процесс этого не ожидает.

Почему классическим вектором называют SSH с AcceptEnv и принудительной командой

Самый наглядный пример из описания проблемы - сервер SSH, который через директиву AcceptEnv в sshd_config разрешает клиенту передавать LANGUAGE и LANG, а учётную запись при этом ограничивают принудительной командой (ForceCommand). Идея такой настройки в том, чтобы пользователь мог запустить только один заранее заданный сценарий и ничего больше. Но если принудительная команда или что-то в её окружении вызывает gettext, то переданные клиентом значения LANGUAGE и LANG долетают прямо до уязвимого кода.

Похожая история уже случалась с другими переменными окружения, которые telnet и SSH передают дочерним процессам: например, переменные OUTPUT_CHARSET и GCONV_PATH позволяли заставить библиотеку искать модуль преобразования кодировки по пути, заданному атакующим. Общий урок прежний: любая переменная окружения, которая способна повлиять на то, какой файл откроет библиотека, требует отдельной проверки, а не доверия по аналогии с соседними переменными.

Дальше достаточно подготовить .mo-файл в каталоге, до которого у ограниченной учётной записи есть доступ на чтение, и составить значение LANGUAGE так, чтобы оно указывало на этот каталог через обход "..". Если принудительная команда упадёт из-за испорченного каталога сообщений, это как минимум отказ в обслуживании для конкретной сессии. Если же удаётся подобрать рассинхронизацию форматных директив так, что чтение памяти откроет полезные данные или собьёт поток выполнения, теоретическим итогом становится выход за пределы того, что должна была разрешать принудительная команда. Это именно тот сценарий, который в своё время пытались закрыть исправлением 2014 года, применённым к LC_ALL и LANG: подмена каталогов сообщений как способ обойти ограничения ForceCommand.

Важно понимать рамки: этот путь не даёт нападающему просто так ввести произвольную команду вместо той, что прописана как принудительная. Нужна уязвимая принудительная команда или сценарий, который использует gettext с аргументами, зависящими от внешнего ввода, и нужно, чтобы атакующий заранее подготовил почву с доступным для чтения .mo-файлом. Но для инфраструктуры, где принудительные команды пишут не глядя на такие тонкости, риск реален.

Как собрать лабораторный стенд и проверить цепочку на собственной машине

Для безопасной проверки в изолированном окружении полезно повторить условия, которые описаны в записи об уязвимости, не трогая боевые системы. Разумный план такой.

Сначала нужна тестовая программа, которая действительно вызывает одну из функций gettext, например простая утилита на C, печатающая строку через gettext() или ngettext(). Чтобы перевод вообще искался, локаль сообщений должна быть отлична от "C" и "POSIX": для теста подойдёт установленная локаль вроде ru_RU.UTF-8 или любая другая, которая реально стоит в системе.

Дальше собирается заведомо неправильный .mo-файл. Можно взять произвольный готовый каталог переводов из системы и повредить в нём поле со смещением строк, выставив значение за пределы размера файла, либо собрать компактный .mo с парой записей, где перевод содержит директиву %s там, где оригинальная строка никаких аргументов не ожидает. Утилита msgfmt из пакета gettext собирает текстовый .po в бинарный .mo, и это стандартный путь подготовки такого файла для тестов.

Файл кладётся в каталог, доступный для чтения тестовому пользователю, а переменная LANGUAGE выставляется так, чтобы обход "/" или ".." указывал именно на этот путь, вместо системного каталога /usr/share/locale. Переменная LANG или LC_ALL при этом должна быть установлена в ту самую нестандартную локаль, иначе ветка с LANGUAGE не включится. После запуска тестовой программы с такими переменными стоит посмотреть на поведение: завершилась ли она аварийно, появилось ли в выводе что-то отличное от ожидаемого текста, и что показывает отладчик, если подключить его к процессу.

Отдельно стоит собрать минимальный стенд SSH с AcceptEnv LANGUAGE и AcceptEnv LANG в sshd_config и учётной записью с принудительной командой, которая внутри себя вызывает ту же тестовую утилиту. Это покажет именно тот путь, который описан как классический, от значения, пришедшего по SSH, до поведения серверного процесса.

Порядок шагов для такой проверки удобно свести в короткий план:

  1. Поднять изолированную виртуальную машину или контейнер отдельно от любых рабочих систем и установить на неё стандартный пакет gettext;
  2. Написать и собрать тестовую программу на C, которая вызывает gettext() и печатает полученную строку на экран;
  3. Собрать через msgfmt заведомо повреждённый .mo-файл, в котором смещение строк выходит за пределы файла либо формат перевода не совпадает с форматом оригинала;
  4. Разместить этот файл в каталоге, доступном тестовому пользователю на чтение, и запустить программу с переменными LANG, указывающей на нестандартную локаль, и LANGUAGE, обходящей системный путь через "..";
  5. Повторить то же самое через ограниченную SSH-сессию с AcceptEnv и принудительной командой, чтобы увидеть цепочку целиком, от клиента до поведения сервера.

Как закрыть путь атаки через настройки sshd и программный код

Поскольку официальная правка в glibc на момент публикации записи ещё не привязана к конкретному фиксированному номеру версии во всех источниках, администраторам стоит в первую очередь опираться на изменение конфигурации, которое не зависит от обновления библиотеки.

Самый прямой шаг - убрать LANGUAGE из списка переменных, которые sshd готов принимать от клиента. Если в sshd_config есть строка вида AcceptEnv LANG LANGUAGE LC_*, стоит сократить её минимум до LANG, а лучше вообще отказаться от приёма локализационных переменных на серверах с принудительными командами, для которых перевод интерфейса не нужен. После правки конфигурацию проверяют командой sshd -t и перечитывают через systemctl reload sshd.

Второй слой защиты не зависит от SSH. Любая программа, которая работает как сервис и принимает переменные окружения от внешнего источника, может на старте явно очистить LANGUAGE вызовом unsetenv("LANGUAGE") или полностью обнулить окружение перед запуском дочерних процессов. Это особенно уместно для принудительных команд и обёрточных сценариев, которые сами решают, какие переменные передавать дальше.

Третий пункт - обновление glibc до версии с исправлением, как только пакет появится у вашего дистрибутива. Патч для LC_ALL и LANG, принятый в 2014 году, показывает направление: вероятнее всего, похожую проверку на "/" и ".." распространят и на записи LANGUAGE. До выхода пакета первые два пункта остаются рабочей защитой, а после обновления стоит повторно прогнать собранный лабораторный стенд и убедиться, что подложенный .mo-файл больше не подхватывается.

Четвёртый момент касается привилегированных программ. Для setuid и setgid-процессов, где действует режим AT_SECURE, библиотека по описанию уязвимости и так отбрасывает такие значения LANGUAGE, так что классический риск повышения привилегий через suid-бинарники здесь не главный. Куда важнее сервисы, которые работают с правами обычного пользователя, но обрабатывают ввод от другого, менее доверенного пользователя, именно так, как это происходит при ограниченной SSH-сессии.

Стоит также проверить веб-приложения и очереди задач, которые запускают внешние утилиты через системные вызовы вроде system() или popen(), передавая туда переменные окружения из запроса пользователя напрямую. Если среди передаваемых значений встречается LANGUAGE, тот же путь атаки сработает и без всякого SSH, просто через HTTP-запрос, который в итоге запускает локальный процесс с унаследованным окружением.

Почему эта история интересна не только патчем, но и самим паттерном

Ценность CVE-2026-84243 не в сложности эксплуатации, а в том, что она наглядно показывает типичную ошибку защиты: закрыть один из нескольких эквивалентных входов и посчитать проблему решённой. LC_ALL, LC_MESSAGES, LANG и LANGUAGE образуют одну логическую систему выбора языка, но патч десятилетней давности пришёлся только на часть из них. Для специалиста по безопасности это удобный учебный пример того, как искать "братьев и сестёр" исправленной уязвимости: если переменная A прогонялась через фильтр, стоит проверить, нет ли рядом переменной B, которая ведёт к тому же коду, но читается отдельной веткой.

Для администратора вывод практичнее. Там, где принудительные команды и ограниченные SSH-сессии существуют специально для того, чтобы сузить возможности пользователя, стоит регулярно пересматривать список переменных окружения, которые разрешено передавать снаружи, а не доверять однажды выставленной настройке AcceptEnv. Старый сценарий с подменой каталогов локали вернулся спустя десять лет именно потому, что одну строку конфигурации один раз сочли безопасной и больше к ней не возвращались.