Skip to content

34 critical shell injection

Part VII: Ecosystem, Runtime & Agent-Specific | Challenge §34

34. Shell Injection via Agent-Constructed Commands

Severity: Critical | Frequency: Common | Detectability: Hard | Token Spend: High | Time: High | Context: Medium

The Problem

When an AI agent constructs CLI invocations — either as shell strings or by assembling argument arrays from LLM-generated values — it can inadvertently (or maliciously, via a compromised tool result) inject shell metacharacters that alter command semantics. This is distinct from prompt injection (challenge #25), which concerns LLM context corruption. Shell injection corrupts the subprocess being executed and can trigger unintended commands.

The attack surface is widest when agents: 1. Build command strings by interpolating variables: f"git commit -m '{message}'" where message comes from LLM output or user input 2. Pass agent-constructed values to subprocess.run(..., shell=True) 3. Call external subcommands (Commander.js git-style subcommand delegation) that the agent cannot inspect

# Dangerous: agent fills in commit_message from LLM output
message = "fix bug'; rm -rf /tmp/work; echo '"
subprocess.run(f"git commit -m '{message}'", shell=True)
# Executes: git commit -m 'fix bug'; rm -rf /tmp/work; echo ''

The jpoehnelt rubric (Axis 5 level 2–3) explicitly names this as an agent-specific hardening concern, noting that CLIs must reject path traversals (../), percent-encoded segments (%2e), and embedded query params (?, # in resource IDs) — all hallucination patterns that agents produce that humans rarely do. A CLI that accepts these naively amplifies agent errors into security events.

The MCP-wrapped CLI pattern specifically calls out that the wrapper layer is the correct place to implement shell-quoting using shlex (Python) or shell-escape (Node), receiving typed arguments from JSON and constructing commands safely — but most wrappers do not implement this.

Impact

  • An agent with write access to a system can accidentally or intentionally execute unintended shell commands, including file deletion, network exfiltration, or privilege escalation
  • Path traversal arguments (../../etc/passwd) passed to file-operating commands can escape intended working directories
  • Hallucinated arguments containing ?, #, ;, &&, || can be silently interpreted as shell operators or URL fragments
  • The failure is typically silent: the command exits non-zero or produces unexpected output with no indication that injection occurred
  • Hard to detect because the injected commands may succeed (exit 0) with outputs that appear plausible

Solutions

For CLI consumers (agents):

import shlex

# Safe: never interpolate into shell strings
subprocess.run(["git", "commit", "-m", message])  # ✓ list form

# Validate before passing: reject traversal and metacharacter patterns
import re
SAFE_VALUE_RE = re.compile(r'^[^;&|<>`$\\\n\r]+$')
if not SAFE_VALUE_RE.match(message):
    raise ValueError(f"Unsafe value for --message: {message!r}")

For CLI authors / MCP wrapper authors:

import shellEscape from 'shell-escape';

// In MCP tool handler: receive typed args from JSON, construct safely
const args = ["git", "commit", "-m", request.params.arguments.message];
const result = await execFile(args[0], args.slice(1));  // ✓ never shell=True

For framework design: - Reject arguments containing ../, ./, percent-encoded characters (%[0-9a-fA-F]{2}), embedded query string markers (?, #), and shell metacharacters (;, &&, ||, backtick, $()) by default - Provide a whitelist-based argument sanitizer as a framework primitive: @arg(pattern=r'^[\w\-\.]+$') - Default to subprocess.run(args_list) (never shell=True) in all generated subprocess calls - Apply jpoehnelt Axis 5 level 2 checks at argument parsing time, before any execution - MCP wrappers: always receive arguments as typed JSON objects, never concatenate into shell strings

Evaluation

Score Condition
0 Framework uses shell=True in generated subprocess calls; no metacharacter validation; path traversal accepted silently
1 shell=True avoided in most cases; no rejection of ../, %XX, or embedded ?/#; validation is type-only
2 Exec-array used throughout; framework rejects path traversal; structured error with suggestion on rejection
3 Axis 5 level 2 full check set: rejects ../, %XX, ?, #, null bytes, string literals "null"/"undefined"; agent_hardening=True flag on argument declarations

Check: Pass --name "acme%2Fwidgets" and --output "../../etc/test" to any command — verify both are rejected with a structured VALIDATION_ERROR and a suggestion showing the corrected form.


Agent Workaround

Always use exec-array (list form) for subprocess calls; validate LLM-generated values before passing them:

import subprocess, re, urllib.parse

# Patterns that indicate agent hallucination
PATH_TRAVERSAL_RE = re.compile(r'(^|/)\.\.(/|$)')
PERCENT_ENCODED_RE = re.compile(r'%[0-9a-fA-F]{2}')
URL_METACHAR_RE = re.compile(r'[?#]')
SHELL_METACHAR_RE = re.compile(r'[;&|<>`$()\n\r\x00]')
LITERAL_NULL_RE = re.compile(r'^(null|undefined|None|NaN|Infinity)$')

def validate_cli_value(name: str, value: str) -> str:
    if PATH_TRAVERSAL_RE.search(value):
        raise ValueError(f"Path traversal in --{name}: {value!r}")
    if PERCENT_ENCODED_RE.search(value):
        decoded = urllib.parse.unquote(value)
        raise ValueError(f"Percent-encoded in --{name}: {value!r} (decoded: {decoded!r})")
    if URL_METACHAR_RE.search(value):
        raise ValueError(f"URL metacharacter in --{name}: {value!r}")
    if LITERAL_NULL_RE.match(value):
        raise ValueError(f"Literal null-like value in --{name}: {value!r}")
    return value

# Always use list form — never shell=True
result = subprocess.run(
    ["tool", "create", "--name", validate_cli_value("name", name)],
    capture_output=True, text=True,
    # never: shell=True
)

Limitation: Validation catches common hallucination patterns but cannot enumerate all possible injection sequences — the definitive fix is exec-array subprocess calls (list form), which makes shell injection structurally impossible regardless of argument content