
«Кто я?» — вопрос, над которым люди размышляют веками. Но whoami для админа — обычная команда. А для атакующего, который только что нашел OS сommand injection, это буквально первая фраза при знакомстве с сервером. В статье покажем, как из одного слова можно получить 37 196 синтаксически разных вариантов одной и той же команды, а также рассмотрим свежие CVE, связанные с этой уязвимостью.
Введение
OS command injection — уязвимость, при которой внешние данные становятся частью системной команды и позволяют повлиять на ее выполнение.
Казалось бы, это должно было остаться в папке «PHP-2008» рядом с «Анапа-2007», но OS command injection продолжает набирать обороты по получению CVE. По данным CVE Find, к 17 августа 2026 года в этой категории было 6377 записей. За неполный 2026 год показатель вырос на 1468 — это на 76,9% больше прироста за весь 2025 год.

Свежачок из мира CVE
Так как OS command injection не привязана к одному языку или старым веб-приложениям, возьмем несколько относительно свежих CVE 2026 года.
PHP: FreePBX и команда из GraphQL
FreePBX — веб-интерфейс для управления SIP softswitch / PBX системой Asterisk. Через него настраивают внутренние номера, маршрутизацию звонков, модули и другие компоненты АТС.
В CVE-2026-41410, ранее независимо зарегистрированной также как CVE-2026-40520, проблема находилась в API-модуле FreePBX. Пользователь с действующим bearer-токеном мог отправить GraphQL-мутацию moduleOperations. Ее аргументы доходили до функции initiateGqlAPIProcess(), которая собирала команду для fwconsole обычной конкатенацией строк:
public function initiateGqlAPIProcess($args){ $bin = $this->freepbx->Config()->get('AMPSBIN'); shell_exec( $bin . '/fwconsole api gql ' . $args[0] . ' ' . $args[1] . ' ' . $args[2] . ' ' . $args[3] . ' >/dev/null 2>/dev/null &' ); }
В этом фрагменте нет границы между данными и shell-синтаксисом. Если один из элементов $args содержит специальную конструкцию, ее разбирает уже не PHP, а командная оболочка.
Похожая проблема была и во вспомогательной функции, которая запускала операции над модулями:
shell_exec( $bin . '/fwconsole ma ' . $action . ' ' . $module . ' --' . $track );
Разработчики исправили несколько вызовов shell_exec() в двух функциях: полный путь к fwconsole и внешние значения стали обрабатываться через escapeshellarg(). Патч FreePBX хорошо показывает, что экранировать надо не только очевидный параметр module, а все значения, из которых собирается итоговая команда.
JavaScript: 9router принял команду за пароль
9router — локальный прокси и панель управления для AI-инструментов и LLM-провайдеров. Проект работает на Node.js, а веб-интерфейс и серверные API построены на Next.js.
В CVE-2026-59800 уязвимость находилась в серверном API-маршруте src/app/api/tunnel/tailscale-install/route.js, который отвечал за установку Tailscale:
POST /api/tunnel/tailscale-install Content-Type: application/json
Маршрут не был включен в middleware, который отвечал за авторизацию. Поэтому запрос доходил до обработчика без cookie, JWT или другого подтверждения доступа.
Обработчик забирал sudoPassword из JSON и передавал его в функцию установки:
export async function POST(request) { const body = await request.json().catch(() => ({})); const sudoPassword = body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || ""; return installTailscale(sudoPassword); }
Дальше значение записывалось в стандартный ввод процесса sudo -S sh:
const child = spawn("sudo", ["-S", "sh"], { stdio: ["pipe", "pipe", "pipe"], }); child.stdin.write(`${sudoPassword}\n`); child.stdin.write(scriptContent); child.stdin.end();
Код рассчитывал, что первую строку sudo прочитает как пароль. Но, если 9router был запущен от root, для его системного пользователя действовало правило NOPASSWD либо сохранялся действующий sudo timestamp cache, sudo не запрашивал пароль. В таком случае sh наследовал стандартный ввод, и значение sudoPassword становилось первой строкой shell-скрипта.
Получилась необычная инъекция: данные не склеили с командой через шаблонную строку, а отправили в stdin. При определенной конфигурации его наследовал sh и начинал читать как скрипт.
TypeScript: Deno дважды собрал безопасную строку
Deno — среда выполнения JavaScript и TypeScript. В ней есть слой совместимости с Node.js, включая модуль node:child_process.
CVE-2026-32260 стала обходом предыдущего исправления CVE-2026-27190. Уязвимость проявлялась при использовании spawn() или spawnSync() с shell: true. Для эксплуатации атакующий должен был контролировать один из передаваемых аргументов.
Сначала аргумент заключался в одинарные кавычки. Затем команда разбиралась еще раз, кавычки снимались, а аргументы повторно подготавливались для shell. Если внутри находилась конструкция вида $VAR, значение оборачивалось уже в двойные кавычки.
Если убрать служебные детали, опасная логика выглядела так:
if (containsEnvironmentVariable(arg)) { return `"${arg}"`; } return `'${arg}'`;
Для обычной подстановки переменной двойные кавычки выглядели правильным решением, но POSIX shell продолжает выполнять внутри них подстановку команд через обратные кавычки. В результате аргумент, который на первом этапе был безопасным, становился опасным после повторного разбора.
Это хороший пример ошибки на границе нескольких парсеров:

Чем больше раз строку разбирают и собирают заново, тем сложнее проследить, сохранилось ли первоначальное экранирование до самого выполнения.
Go: Kubernetes YAML превратился в bash -c
KubeAI запускает модели машинного обучения в Kubernetes. Пользователь описывает модель через custom resource Model, а контроллер создает необходимые workload и проверки готовности.
В CVE-2026-34940 источником данных стало поле spec.url. Для эксплуатации атакующий должен был иметь возможность создавать или изменять ресурсы Model в кластере. Контроллер разбирал URL модели на части ref и modelParam, после чего собирал startup probe:
func ollamaStartupProbeScript(m *kubeaiv1.Model, u modelURL) string { startupScript := "" if u.scheme == "pvc" { startupScript = fmt.Sprintf( "/bin/ollama cp %s %s", u.modelParam, m.Name, ) } else if u.pull { startupScript = fmt.Sprintf( "/bin/ollama pull %s && /bin/ollama cp %s %s", u.ref, u.ref, m.Name, ) } return startupScript }
Получившуюся строку Kubernetes запускал через Bash:
Command: []string{"bash", "-c", startupProbeScript},
Регулярное выражение отделяло схему и ссылку на модель, но разрешало внутри ref почти любые символы, кроме ?. URL-парсер считал строку корректной, тогда как Bash мог интерпретировать те же символы как разделители команд или конструкции подстановки.
Python: PraisonAI разрешил выбирать исполняемый файл
PraisonAI — фреймворк для создания AI-агентов и команд агентов. Он поддерживает MCP-серверы, которые могут запускаться как отдельные процессы.
В CVE-2026-41497 функция parse_mcp_command() разбирала строку конфигурации на исполняемый файл, аргументы и переменные окружения. При этом она не ограничивала сам исполняемый файл и опасные режимы его запуска. Для эксплуатации атакующий должен был иметь возможность повлиять на конфигурацию MCP-сервера.
Ключевая логика сводилась к разбору строки и передаче результата дальше:
parts = shlex.split(command_string) command = parts[0] args = parts[1:] return command, args, env
Затем полученные command и args использовались для запуска подпроцесса. Вызов без shell=True обычно считается безопаснее, но в этом случае атакующий контролировал саму программу. Если разрешить выбрать интерпретатор и передать ему флаг выполнения кода, shell для атаки уже не требуется.
Это не классическая конструкция subprocess.run(user_input, shell=True). Уязвимость находилась уровнем выше: приложение принимало решение пользователя о том, какой бинарный файл запускать и с какими аргументами.
Эта ошибка была особенно интересна тем, что стала неполным исправлением CVE-2026-34935. Первоначальный патч изменил обработку MCP-команд, но не добавил строгий allowlist исполняемых файлов и проверку аргументов.
Обход сигнатур: почему черные списки не работают
Сигнатурные правила WAF анализируют метод, путь, параметры, заголовки и тело HTTP-запроса, пытаясь найти признаки известных атак. Его задача — найти знакомые признаки атаки:
разделители команд (
;,|,&&,\n);конструкции подстановки (
$(), обратные кавычки, переменные);названия утилит (whoami, id, cat, bash);
подозрительные сочетания аргументов и операторов.
Некоторые WAF также умеют анализировать HTTP-ответ, пытаясь найти признаки успешного выполнения команды: содержимое системных файлов (/etc/passwd), вывод id, типичные сообщения оболочки. Но и здесь есть ограничения:
Blind-инъекции — атакующий судит о выполнении по задержке, DNS-запросу или другому побочному эффекту. В ответе может не быть ничего подозрительного.
Закодированный вывод — результат можно отправить в Base64, hex, разбить на части или сжать. Сигнатура на чистый текст не сработает.
Внешняя передача — данные могут уйти не в HTTP-ответ, а через DNS, ICMP или TCP-соединение на внешний сервер.
Поэтому анализ ответа — полезный, но не основной инструмент и точно не панацея.
Для демонстрации силы обфускации мы взяли команду whoami как короткий и понятный маркер выполнения команды в shell-контексте. Для нее был собран статический корпус вариантов, где изменяется только текстовое представление команды.
В корпусе используются несколько классов shell-level-модификаций: ведущий command separator ;, разбиение токена на фрагменты, смешивание литеральных и закодированных частей, вставка одинарных и двойных кавычек, ANSI-C quoting $'...', а также byte-level-представления символов через hex-escape (\xNN), Unicode-escape (\uNNNN) и octal-escape (\NNN).
Важно, что этот корпус не является исчерпывающим, так как в нем отсутствуют другие классы модификаций.
Несколько строк из корпуса:
whoami w\h\o\a\m\i w'h'o"a"m"i" $'\x77\x68\x6f\x61\x6d\x69' $'\167\150\157\141\155\151' wh${u}oami w${*}h${u}o""a${u}m${*}i
С полной версией вы можете ознакомиться в одном из наших телеграм-каналов.
RCE получили. Что дальше?
RCE позволяет выполнять команды с правами уязвимого приложения. Это не всегда означает мгновенный доступ к root, но даже обычный системный пользователь может читать конфигурацию, работать с пользовательскими данными и подключаться к другим сервисам. Поэтому последствия зависят прежде всего от окружения и прав процесса.
-
Кража секретов и данных.
Первой целью становятся переменные окружения, конфигурационные файлы, API-ключи, токены, пароли от баз и ключи шифрования. Вместе с ними могут быть доступны загруженные файлы, документы, журналы и история операций.
Для такой утечки не требуется полный контроль над сервером. Достаточно прав на чтение рабочего каталога или подключение к базе данных. Если на сервере доступны резервные копии, их также можно скопировать, изменить или удалить.
Исправление RCE не делает похищенные ключи безопасными: их придется отдельно отозвать и выпустить заново.
-
Изменение приложения и закрепление.
Если процесс может изменять файлы приложения, атакующий способен вмешаться в серверный код, шаблоны или клиентский JavaScript. Это позволяет сохранять учетные данные, копировать запросы, перенаправлять информацию или отдавать пользователям измененный код.
Уязвимый эндпоинт при этом остается только точкой входа. После закрепления обновление компонента уже не гарантирует очистку системы: внесенные изменения могут сохраниться в коде, зависимостях или механизме запуска.
-
Продвижение во внутреннюю сеть.
Публичный сервер обычно подключается к базам данных, очередям сообщений, файловым хранилищам, внутренним API и административным интерфейсам.
После компрометации запросы к этим системам идут уже с доверенного адреса внутри инфраструктуры. В результате RCE на одной машине может стать начальной точкой для атаки на соседние серверы, особенно если между ними используются общие учетные данные и широкие сетевые правила.
-
От контейнера к хосту.
Если приложение работает в Docker, команды сначала выполняются внутри контейнера. Там могут быть доступны переменные окружения, конфигурация, сетевые соединения и подключенные тома.
Root внутри контейнера сам по себе еще не означает root на хосте. Но привилегированный режим, лишние Linux capabilities, Docker socket или каталоги хоста с правом записи резко расширяют последствия. В такой конфигурации компрометация приложения может привести к управлению другими контейнерами и самим хостом.
-
Использование ресурсов сервера.
Скомпрометированный хост можно использовать для майнинга, проксирования трафика, сканирования сети или атак на другие системы. Нагрузка и расходы останутся на владельце, а нелегитимный трафик будет идти с его IP-адреса/домена.
Об инциденте он может узнать после блокировки адреса или жалобы от хостинг-провайдера. IP-адрес и домен могут попасть в списки спамеров и источников атак, из-за чего легитимные письма, запросы и интеграции начнут блокироваться.
Если инцидент затронет клиентов или станет публичным, к техническим последствиям добавятся репутационные: потеря доверия пользователей, вопросы со стороны партнеров и необходимость объяснять, почему инфраструктура компании использовалась для атак. Сервер можно восстановить за несколько часов, а репутацию — далеко не всегда.
-
Нарушение работы и уничтожение данных.
В пределах доступных прав можно останавливать процессы, изменять конфигурацию, заполнять диск и удалять файлы. При повышенных привилегиях под угрозой оказываются базы данных, журналы и подключенные резервные копии.
Данные также могут сначала скопировать, а затем зашифровать или удалить. После этого владельцу предлагают заплатить за восстановление системы или отказ от публикации похищенной информации. Выплата выкупа при этом не гарантирует ни возврат данных, ни удаление их копий.
Даже без полного уничтожения данных атака может вызвать длительный простой: перезапуска приложения будет недостаточно, поскольку систему придется восстанавливать и проверять на оставленные изменения.
Масштаб последствий
Одна и та же RCE может иметь разный импакт. В изолированном сервисе без секретов и сетевого доступа ущерб будет ограничен. На сервере, где приложение работает от root, хранит ключи и имеет доступ к Docker socket, она может закончиться компрометацией всей системы.
После подтвержденного RCE недостаточно исправить уязвимый код. Необходимо провести внутренний аудит: определить доступные процессу ресурсы, проверить журналы, запущенные процессы, механизмы закрепления и соседние системы. Данные следует считать потенциально украденными, ключи — отозвать, а скомпрометированное окружение — пересобрать из доверенного образа.
Собственно, как защититься?
Рекомендации ниже соответствуют подходам OWASP, MITRE и PortSwigger.
1. Уберите shell из цепочки.
Возьмем FreePBX. В исходном варианте обработчик склеивал команду строкой и передавал ее в shell_exec(). На практике безопаснее передать программу и аргументы через API, который не запускает shell:
use Symfony\Component\Process\Process; use Symfony\Component\Process\Exception\ProcessFailedException; function runModuleAction(string $action, string $module): void { $allowedActions = ['install', 'remove', 'enable', 'disable']; $allowedModules = ['core', 'voicemail', 'queues']; if ( !in_array($action, $allowedActions, true) || !in_array($module, $allowedModules, true) ) { throw new InvalidArgumentException('Недопустимая операция'); } $process = new Process([ '/usr/sbin/fwconsole', 'ma', $action, $module, ]); $process->setTimeout(30); $process->run(); if (!$process->isSuccessful()) { throw new ProcessFailedException($process); } }
Здесь нет shell, нет конкатенации строки и нет возможности вместо имени модуля подсунуть другой бинарник или неожиданный флаг. Если утилита поддерживает маркер конца опций, перед пользовательским операндом дополнительно используйте --.
Экранирование через escapeshellarg() может быть временной страховкой для легаси-кода, но это не главный фикс. Как только в архитектуре остается shell, остается и пространство для ошибок при следующем рефакторинге.
2. Проверяйте смысл данных и аргументов.
Конфигурация может передать приложению не просто данные, а решение о том, какую программу запускать и с какими аргументами. Массив аргументов не поможет, если атакующий уже выбрал исполняемый файл.
В PraisonAI вместо строки конфигурации вроде command = "..." лучше разрешить пользователю выбирать только профиль из заранее заданного списка:
import subprocess MCP_SERVERS = { "internal-docs": { "program": "/opt/mcp/bin/docs-server", "args": ["--read-only"], }, "issue-tracker": { "program": "/opt/mcp/bin/issues-server", "args": ["--project", "security"], }, } def start_mcp_server(server_name: str) -> None: server = MCP_SERVERS.get(server_name) if server is None: raise ValueError("Unknown MCP server") subprocess.run( [server["program"], *server["args"]], shell=False, env={"PATH": "/usr/bin:/bin"}, timeout=30, check=True, )
Пользователь выбирает профиль, например internal-docs, а не передает произвольную команду.
То же правило работает и для остальных типов данных: IP-адрес должен пройти IP-парсер, порт — проверку диапазона, имя модуля — allowlist. Если утилита поддерживает маркер конца опций, перед пользовательским операндом используйте --.
Черные списки «запрещенных слов» не являются валидацией. У вас уже есть 37 196 причин не делать на них ставку.
3. Считайте экранирование запасной мерой.
CVE в Deno хорошо показывает, почему экранирование нельзя считать фундаментом защиты. Аргумент сначала считался безопасным, затем строку повторно разбирали и собирали для shell — и первоначальные кавычки переставали что-либо гарантировать.
Вместо того чтобы писать собственную логику для одинарных, двойных и обратных кавычек, в TypeScript для Deno нужно передавать аргументы напрямую:
const command = new Deno.Command("/usr/bin/ollama", { args: ["pull", "--", modelRef], clearEnv: true, env: { PATH: "/usr/bin:/bin" }, }); await command.output();
Deno передает /usr/bin/ollama, pull и modelRef операционной системе как отдельные элементы. Для запускаемой программы modelRef остается обычным текстовым аргументом, а не частью команды с собственным синтаксисом.
Если shell все же нужен, безопасная последовательность должна быть такой: сначала проверить исходное значение, затем экранировать его для конкретной оболочки и вставить в команду ровно один раз. После этого строку нельзя повторно собирать, форматировать или разбирать через другой shell.
Иначе защита относится к уже старой версии строки. Например, первый слой кода поставил кавычки, а второй собрал новую команду и передал ее shell заново. Для shell это уже другой контекст — он видит новую строку и снова пытается интерпретировать ее как команды. Именно на таком повторном разборе и сломалось исправление Deno.
4. Ограничивайте последствия.
Не запускайте приложение от root, выдавайте минимальные права, передавайте дочернему процессу только необходимые переменные окружения и ограничивайте исходящие соединения.
Уязвимый процесс не должен иметь возможности получить доступ ко всей инфраструктуре.
Для Kubernetes-подобного сценария из KubeAI минимально привилегированная конфигурация выглядит так:
securityContext: runAsNonRoot: true runAsUser: 10001 allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL
Это не исправляет инъекцию, но усложняет дальнейшую эскалацию.
Дочернему процессу также не нужно наследовать все окружение приложения, особенно токены, ключи и пароли:
subprocess.run( ["/usr/bin/some-tool", "--fixed-option", validated_value], shell=False, env={ "PATH": "/usr/bin:/bin", "LANG": "C", }, cwd="/var/empty", timeout=5, check=True, )
5. Считайте WAF дополнительным слоем.
WAF не исправляет OS command injection, но снижает риск до выхода патча. Он стоит перед приложением и может заблокировать известные payload’ы, массовые сканеры и попытки обратиться к уже известному уязвимому маршруту. Например, OWASP Core Rule Set для ModSecurity содержит правила для обнаружения Unix command injection.
Еще одна полезная роль WAF — виртуальный патч. Если команда узнала об уязвимом маршруте, его можно временно отключить до выхода исправления:
SecRule REQUEST_METHOD "@streq POST" \ "id:100110,phase:1,deny,status:403,log,t:none,msg:'Virtual patch: endpoint disabled',chain" SecRule REQUEST_FILENAME "@streq /api/tunnel/tailscale-install" "t:none"
Такое правило полностью останавливает запросы к уязвимой функции, пока команда готовит и выкатывает исправление.
WAF снижает шанс эксплуатации и помогает выиграть время, код устраняет причину уязвимости.

Авторы: специалисты по исследованию веб-угроз команды BI.ZONE WAF Анастасия Кабирова и Никита Проказов