Командная инъекция: не вызывайте 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-валидацию.