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.
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.
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.
# 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.
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 / shell | Separators | Timing oracle | Substitution |
|---|---|---|---|
| Unix - bash/sh | ; || && | | sleep 5 | $(expr 62615533 -0) |
| Unix - semicolon filtered | %0a (newline) | %26 (&) | sleep%0a5 | backtick or $(...) |
| Windows - cmd.exe | & && || | ping -n 6 127.0.0.1 | not available |
| Windows - PowerShell | ; & | Start-Sleep 5 | $(Start-Sleep 5) |
| Blind - opaque response body | any above | timing delta only vs baseline | OAST 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.
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.
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.