Командная инъекция: не вызывайте shell

Передать пользовательский ввод в системную команду — один из самых опасных приёмов.

Командная инъекция — внедрение команд операционной системы через ввод, попавший в строку, которую исполняет оболочка (shell).

Как возникает

Программа собирает команду как строку и отдаёт её оболочке. Оболочка трактует ;, |, &&, $(...), обратные кавычки как управляющие конструкции — и ввод, содержащий их, превращается в дополнительные команды.

// Уязвимо: ввод склеен в команду для оболочки
cmd = "ping -c 1 " + host;
shell_exec(cmd);
// host = "example.com; rm -rf /tmp/data"  -> оболочка выполнит ОБЕ команды

Последствия максимальны: исполнение произвольных команд с правами процесса — фактически контроль над сервером.

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

Чтобы увидеть механизм, разделим в голове две роли. Ваша программа хочет вызвать одну конкретную утилиту — ping — с одним аргументом-хостом. Но если вы отдаёте оболочке готовую строку, между вами и ping встаёт ещё один интерпретатор — сама оболочка, — и она разбирает эту строку по своим правилам раньше, чем дойдёт до запуска ping. Для оболочки ; — это не часть имени хоста, а конец одной команды и начало другой. Пользователь, подсунувший такой символ, обращается уже не к вашей программе, а напрямую к оболочке, и просит её выполнить что-то своё.

Защита 1: не запускать оболочку, передавать аргументы массивом

Главная идея — обойтись без интерпретатора-оболочки. Большинство языков умеют запускать процесс напрямую, передавая программу и её аргументы отдельными элементами массива. Тогда "; rm -rf" станет одним строковым аргументом, а не новой командой — нет оболочки, нет её спецсимволов.

// Уязвимо: строка уходит в оболочку
exec("ping -c 1 " + host)

// Безопасно: программа и аргументы — отдельно, без shell
exec(["ping", "-c", "1", host])   // host — один аргумент, что бы в нём ни было

В терминах популярных языков: запускайте процесс со списком аргументов и явно отключайте оболочку (например, без shell=True), а не передавайте единую командную строку. Привыкните считать этот способ значением по умолчанию: вариант с оболочкой выбирают сознательно и редко, когда без её возможностей действительно не обойтись, а не наоборот.

Защита 2: предпочесть библиотеку вызову внешней программы

Часто внешняя команда вообще не нужна. Нужно изменить размер картинки — возьмите библиотеку обработки изображений вместо вызова консольной утилиты. Меньше вызовов ОС — меньше поверхность для инъекции.

Эта защита глубже, чем кажется: она устраняет не конкретную брешь, а целый класс рисков сразу. Когда нужная функциональность доступна как библиотека внутри вашего процесса, между пользовательским вводом и операционной системой вообще не возникает текстовой команды — нечему инъектироваться. Заодно вы избавляетесь от сопутствующих хлопот: разбора кодов возврата, парсинга вывода утилиты, зависимости от того, какая версия программы установлена на конкретном сервере. Запуск внешних процессов стоит держать в голове как «последнее средство», к которому прибегают, лишь когда библиотечного пути действительно нет.

Защита 3: валидация значения allowlist'ом

Если без внешней команды не обойтись и аргумент должен иметь строгий формат (например, имя хоста), дополнительно проверьте его белым списком как ещё один рубеж.

Важно понимать роль этой проверки: она дополняет запуск без оболочки, а не заменяет его. Массив аргументов уже делает инъекцию команд невозможной; allowlist-валидация добавляется поверх как второй слой по принципу defense in depth и как защита от смежных проблем — например, от того, что в «имя хоста» прилетит что-то осмысленно неверное и нарушит логику нижележащей утилиты. Порядок приоритетов такой: сначала структурно убираем оболочку, и только потом, для аргументов со строгим форматом, накидываем проверку формата. Обратный порядок — «провалидирую ввод и потом спокойно отдам строку в shell» — это ловушка: любой пропуск в валидации сразу оборачивается исполнением команды.

// Дополнительный рубеж: жёсткий формат хоста
if (!host.matches("^[a-zA-Z0-9.-]{1,253}$")) reject();
exec(["ping", "-c", "1", host]);

Как работает под капотом: где появляется оболочка

Опасность создаёт именно оболочка между вашей программой и целевой командой. Когда вы вызываете system("...") или ставите shell=True, ОС запускает /bin/sh -c "ваша строка" — и sh парсит спецсимволы. Когда вы передаёте массив аргументов напрямую, ОС вызывает execve с уже разобранным списком — парсить нечего, метасимволы оболочки не действуют.

shell=True :  ваша строка -> /bin/sh -c -> разбор ; | && $() -> опасно
массив     :  ["ping","-c","1",host] -> execve напрямую -> без разбора -> безопасно

Ключевое отличие двух путей — есть ли вообще этап «разбора строки». В варианте с массивом аргументов ОС получает уже разобранный список: первый элемент — какую программу запустить, остальные — её аргументы, каждый как отдельная неделимая строка. Парсить нечего, поэтому метасимволы оболочки в аргументе остаются обычными символами внутри значения. В варианте со строкой и оболочкой, наоборот, появляется промежуточный разбор, на котором те же символы обретают управляющий смысл. Вся уязвимость живёт ровно в этом лишнем этапе — убрав его, вы убираете и уязвимость.

Стоит знать и про скрытую форму той же ошибки. Оболочка может появиться неявно: некоторые удобные обёртки и функции «запусти команду» под капотом всё равно зовут /bin/sh, даже если вы передали аргументы по отдельности; иногда оболочка нужна для подстановки переменных окружения или склейки конвейера. Поэтому недостаточно просто «не писать system()» — важно убедиться по документации, что выбранный способ запуска действительно идёт через execve напрямую, а не разворачивает строку в оболочке за вашей спиной.

Частые ошибки

  • Чёрный список символов. Перечислить все метасимволы оболочки и кодировки нереально; убирайте саму оболочку.
  • Кавычки вокруг аргумента «на всякий случай». Экранирование для shell хрупко; массив аргументов надёжнее.
  • Скрытый shell в обёртках. Некоторые API запускают оболочку неявно — проверяйте документацию.

Итоги

  • Командная инъекция возникает, когда ввод попадает в строку, исполняемую оболочкой.
  • Главная защита — не вызывать shell: передавать программу и аргументы массивом.
  • Предпочитайте библиотеку внешней команде; при необходимости добавьте allowlist-валидацию.
Проверьте себя
1. Почему запуск внешней команды через оболочку (shell) опасен?
AОболочка работает медленно
BОболочка интерпретирует метасимволы (; | && $()), и ввод с ними превращается в дополнительные команды
CОболочка не поддерживает аргументы
DЭто нарушает кодировку
2. Какой способ запуска процесса защищает от командной инъекции?
AПередать единую строку в system()
BПередать программу и аргументы отдельными элементами массива, без оболочки
CОбернуть аргумент в кавычки в строке для shell
DЗапустить команду от root
3. Что произойдёт с вводом «; rm -rf /tmp» при вызове exec(["ping","-c","1", host]) с этим host?
AБудут выполнены две команды
BВесь ввод станет одним строковым аргументом ping, и дополнительная команда не выполнится
CПроцесс упадёт с ошибкой синтаксиса оболочки
DАргумент будет проигнорирован