Back to Blog
critical SEVERITY4 min read

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.

O
By Orbis AppSec
•Published October 1, 2026•Reviewed October 1, 2026

Answer Summary

The webapp-testing server testing script in `with_server.py` accepted arbitrary command arguments and executed them via `subprocess.Popen()` with `shell=True`, allowing an attacker to inject shell metacharacters such as `; id` or `$(whoami)` and achieve arbitrary command execution on the system. The fix replaces shell invocation with direct process execution using `shlex.split()` to safely tokenize arguments, and explicitly handles the documented `cd DIR && CMD` pattern via the `cwd` parameter instead of relying on shell interpretation. The vulnerability is assigned CWE-78 (Improper Neutralization of Special Elements used in an OS Command).

Vulnerability at a Glance

cweCWE-78
fixRemove shell=True, tokenize arguments with shlex.split(), and handle cd workflows via cwd parameter
riskArbitrary command execution with the privileges of the script process
languagePython
root causeUser-supplied command arguments passed directly to subprocess.Popen() with shell=True, allowing metacharacter interpretation
vulnerabilityOS Command Injection via Shell Metacharacter Injection

The Vulnerability Explained

A critical command injection vulnerability existed in the web server testing utility script, which accepted arbitrary server startup commands via command-line arguments and executed them using Python's subprocess.Popen() with shell=True. This combination created a direct path for shell metacharacter injection.

The vulnerable code pattern looked like this:

process = subprocess.Popen(
    server['cmd'],
    shell=True,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE
)

The server['cmd'] string originated from user-controlled input (command-line arguments parsed by argparse.REMAINDER), and was passed directly to a shell for interpretation. An attacker who could influence the command string—for example, by crafting a malicious argument like echo test; id—would have both commands executed:

# User supplies:
--server "echo test; id"

# Shell interprets as:
echo test
id

The shell metacharacters are evaluated before the process runs, meaning the attacker's injected command executes with the full privileges of the script process. More sophisticated payloads using $() syntax or backticks allow arbitrary command substitution:

# Attacker provides:
--server "backup.sh $(rm -rf /important/data)"

# The substitution happens in the shell, before backup.sh runs

For a web server testing context, this is particularly dangerous because the script typically runs in an environment with elevated privileges or privileged file access, making it an attractive target for privilege escalation or lateral movement.

Affected Versions

Affected N/A (first-party code)
Fixed in N/A (first-party code; fix applied to testing utility)
Ecosystem N/A
CVE / GHSA Not assigned
CWE CWE-78: Improper Neutralization of Special Elements used in an OS Command

The Fix

The fix removes shell interpretation entirely and replaces it with safe argument tokenization using Python's shlex module. Here's the before-and-after:

Before (vulnerable):

process = subprocess.Popen(
    server['cmd'],
    shell=True,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE
)

After (fixed):

import shlex

cmd = server['cmd']
cwd = None
if cmd.startswith('cd ') and ' && ' in cmd:
    cd_part, _, cmd = cmd.partition(' && ')
    cwd = cd_part[3:].strip()

process = subprocess.Popen(
    shlex.split(cmd),
    cwd=cwd,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE
)

Three key changes work together to eliminate the vulnerability:

  1. Remove shell=True: The process is now executed directly (via execve() on Unix systems) without a shell intermediary, so metacharacters like ; and $() are treated as literal argument characters, not shell syntax.

  2. Tokenize with shlex.split(): This safely breaks the command string into individual arguments respecting shell quoting rules. If a user legitimately needs an argument containing spaces, they can quote it in the command line, and shlex.split() will preserve it as a single token—without shell interpretation of special characters.

  3. Preserve cd DIR && CMD via cwd: The documented workflow of changing directories before running a command is explicitly supported by detecting the pattern and using subprocess's cwd parameter. This maintains backward compatibility without requiring shell involvement.

With these changes, an attacker's attempt to inject echo test; id results in shlex.split() producing a single argument ["echo", "test;", "id"], which the subprocess receives literally. The semicolon is not special—it's just a character in the third argument.

How Orbis AppSec Detected This

Source: Command arguments accepted via argparse.REMAINDER and passed to the server command field.

Sink: subprocess.Popen() invoked with the user-supplied command string and shell=True.

Missing control: No validation, escaping, or sanitization of the command string before passing it to the subprocess module. The use of shell=True meant the string was always interpreted by a shell, which is inherently unsafe for untrusted input.

CWE: CWE-78 – Improper Neutralization of Special Elements used in an OS Command.

Fix: Replace shell-based execution with direct process invocation via shlex.split() for tokenization, and explicit handling of directory-change workflows via the cwd parameter.

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.

Key Takeaways

  • Never use shell=True with user-controlled input. The shell is a separate interpreter with its own syntax for special characters; passing untrusted data to it is equivalent to passing code to eval(). Even if you think you've sanitized the input, the shell's parsing rules may surprise you.

  • shlex.split() is safe only when shell=False. The module tokenizes according to shell quoting rules but does not invoke a shell. Pairing it with shell=False (the default) keeps the benefits of user-friendly quoting without the code execution risk.

  • Legitimate shell features (cd, &&, pipes) should be explicit, not implicit. If your application needs to support directory changes, pass cwd as a parameter. If it needs conditional execution, implement that in Python logic—not by concatenating strings and hoping they survive shell parsing.

  • argparse.REMAINDER captures raw user input. Any value it provides should be treated as untrusted, even if it comes from the command line of a "trusted" administrator. Privilege escalation often starts with an admin script that accepts arguments without proper validation.

Conclusion

This vulnerability demonstrates a common but critical mistake: assuming that because a script is run locally or by an administrator, its inputs are somehow safe. The reality is that shell metacharacter injection is trivial to exploit, and the fix—removing the shell layer and tokenizing safely—is simple and adds no complexity to legitimate use cases.

By switching to direct process execution with shlex.split() and handling special workflows (like directory changes) through subprocess parameters rather than shell syntax, the script is now resilient to injection attacks while remaining fully functional for all documented workflows.

Prevention and further reading

Frequently Asked Questions

What shell metacharacters could an attacker inject through the command-line interface?

Any shell special character, including `;` (command chaining), `$()` (command substitution), `|` (piping), `&&` (conditional execution), and backticks, because the entire command string was passed to a shell for interpretation before execution.

Does the fix break the documented `cd && CMD` syntax that users may already depend on?

No. The fix explicitly detects and handles the `cd DIR && CMD` pattern by parsing out the directory path, passing it via the `cwd` parameter, and executing only the command portion—preserving backward compatibility without shell involvement.

Why is shlex.split() the right choice here instead of simply removing shell=True?

`shlex.split()` safely tokenizes the command string into individual arguments according to shell quoting rules, so users can still pass arguments containing spaces if they quote them properly, while preventing metacharacters from being interpreted as shell syntax.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #34

Related Articles

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.

critical

Aardvark.Cef.Process.Core Shared Memory Handler: Unvalidated memcpy

The shared memory handler in Aardvark.Cef.Process.Core failed to validate that the `length` parameter passed from JavaScript did not exceed the destination buffer size before calling `memcpy`. This allowed a compromised Chromium renderer process to corrupt heap memory by supplying an oversized `handle->length` value to the `openMapping` function. The fix adds explicit bounds checking using `GetArrayBufferByteLength()` before the copy operation.