Engineering · Aug 15, 2026 · 7 min read

OS command injection: the oracle that confirms execution without the payload

A sleep-based timing oracle confirms that user input reached a shell interpreter - without running a real command or touching a production resource.

OS command injection hands the attacker a shell on the host the application runs on. It is the vulnerability class where severity has only one ceiling: full system compromise. Confirming it safely requires an oracle that proves user input was evaluated by a shell interpreter - without running any destructive operation or touching a production resource.

01How command injection surfaces

The root cause is user-controlled input concatenated into a string that a shell interpreter evaluates. Every language has the relevant API: Python's subprocess.call(..., shell=True), PHP's shell_exec() and backtick operator, Ruby's backtick and %x{...}, Node.js child_process.exec(), and the direct C-family system(). The vulnerability is not in the function - it is in what the caller passes to it: an unsanitized string that an attacker can append separator characters to, chaining an arbitrary second command onto the intended one.

HTTP request ?host=192.168.1.1; sleep 5 | v Application code cmd = "ping -c 1 " + request.args["host"] # no sanitization subprocess.call(cmd, shell=True) | v Shell interprets: ping -c 1 192.168.1.1; sleep 5 ^^^^^^^^^^ second command injected after semicolon | v OS executes both: ping runs, then sleep runs response arrives 5 s late ──► execution confirmed
A semicolon separates the intended command from the injected one. The shell evaluates both before returning to the application.

02The timing oracle: confirming without a destructive payload

Probing for command injection with a payload like ; rm -rf /tmp is never acceptable against a production host - that is an attack, not a scan. The safe confirming oracle is a time-delay: sleep 5 on Unix or ping -n 6 127.0.0.1 on Windows introduces a predictable 5-second pause with zero side effects. If the HTTP response for the injected probe arrives roughly 5 seconds late and the baseline for the same endpoint returns in under 500 ms, the shell evaluated the separator and command. A second cross-check with a benign arithmetic expression - $(expr 62615533 - 0) reflected in the response body via command substitution - confirms the shell context independently. Neither oracle writes files, modifies state, or exfiltrates data.

cmd-injection-probe.httphttp
# Baseline: measure normal response time for the endpoint
GET /api/diagnostics?host=127.0.0.1 HTTP/1.1
HTTP/1.1 200 OK  elapsed: 138 ms   # well under 500 ms

# Timing oracle: semicolon separator + sleep (Unix)
GET /api/diagnostics?host=127.0.0.1%3Bsleep+5 HTTP/1.1
HTTP/1.1 200 OK  elapsed: 5 143 ms   # 5 s delay - sleep command ran

# Arithmetic cross-check: command substitution via $(...)
GET /api/diagnostics?host=$(expr+62615533+-0) HTTP/1.1
HTTP/1.1 200 OK  body: PING 62615533 ...   # expr result passed as the host argument

Two independent oracles - the 5-second delay and the reflected arithmetic nonce - agree before a finding is raised. Neither is sufficient alone: the timing oracle can be confused by transient load spikes; the arithmetic oracle can be confused by a coincidental reflection. Together they establish confirmed shell evaluation.

Proven - CriticalOS command injection - timing + arithmetic oracle - /api/diagnostics?host=
GET /api/diagnostics?host=127.0.0.1%3Bsleep+5 ──► HTTP 200 elapsed: 5 143 ms (baseline: 138 ms) GET /api/diagnostics?host=$(expr+62615533+-0) ──► HTTP 200 body: "PING 62615533 (62615533) ..." both oracles repeated 3 x, consistent delay and reflected value each time
Oracles: timing (sleep 5) and arithmetic (expr). No writes, no reads, no outbound calls. Severity critical - confirmed shell evaluation on the application host is the impact floor, not the ceiling.

03Separator syntax differs by platform and shell

The separator that chains a second command is not universal. Bash and sh accept the semicolon (;), logical OR (||), logical AND (&&), and the pipe (|). Windows cmd.exe uses & for unconditional chaining and &&/|| for conditional chaining; the Unix semicolon has no effect there. Command substitution - $(command) on Bash, backticks on most shells - adds a third axis. A probe that sends only ; sleep 5 silently misses every Windows target and every environment where a WAF or input filter strips semicolons before the string reaches the OS call. Probing per detected platform, with at least two separator types per platform, is the only approach that avoids that silent gap.

Platform / shellSeparatorsTiming oracleSubstitution
Unix - bash/sh; || && |sleep 5$(expr 62615533 -0)
Unix - semicolon filtered%0a (newline) | %26 (&)sleep%0a5backtick or $(...)
Windows - cmd.exe& && ||ping -n 6 127.0.0.1not available
Windows - PowerShell; &Start-Sleep 5$(Start-Sleep 5)
Blind - opaque response bodyany abovetiming delta only vs baselineOAST callback (off by default)

When the response body is opaque - the application never reflects the ping target or command output - the timing oracle is the primary evidence. Three consistent measurements with the injected delay and three consistent baseline measurements rule out load-spike coincidence. OAST (out-of-band application security testing) callbacks are available as a fallback for fully blind endpoints but are disabled by default; the timing oracle is the safe-first path.

04Severity grading and the fix

A confirmed timing oracle is the lower bound on severity - it proves shell access exists. The actual impact ceiling is higher: read arbitrary files, write backdoors, pivot to adjacent internal services, or exfiltrate credentials from the environment. Command injection on an internet-reachable endpoint confirmed by a timing oracle is rated critical with a 7-day SLA. The same injection reachable only from an authenticated, internal endpoint is rated high - the shell access is identical; the attacker's starting position is harder to reach. Under-rating a confirmed shell evaluation to medium because the endpoint requires authentication is wrong; the finding carries the probe, the elapsed times, and the arithmetic nonce so the engineering team can see exactly what the evidence is.

0
files written or read during the probe
2
independent oracles required per finding
5 s
timing delta that confirms execution

The correct fix is not sanitizing the injected separator characters - no denylist covers every separator, encoding variant, or shell built-in that bypasses it. The fix is eliminating the shell context entirely. Replace subprocess.call(cmd, shell=True) with a list-form call: subprocess.call(["ping", "-c", "1", host]). The OS executes the binary directly with arguments passed as separate tokens; no shell interpreter ever sees the input, so no separator syntax can chain a second command. Where a shell is genuinely required, validate the controllable portion against an explicit allowlist of safe literal values before constructing the command string. Re-run the timing probe after the fix to confirm the 5-second delay is gone.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →