OS command injection: when your input reaches a shell
OS command injection is SQL injection's sibling one layer down: instead of the database executing your input as code, the operating system does. It sits inside OWASP Top 10:2025 A05 — Injection as CWE-78, and its impact is usually worse than SQLi's — a compromised database is bad; a compromised server is the whole machine. You will meet it in legacy integrations (a script shelling out to a reporting binary), in network-device web interfaces, in "convenience" wrappers around system utilities, and — from the other side — in EDR process-creation telemetry, where a web worker spawning a shell is one of the highest-fidelity alerts in the building.
Level 3 · Apply — do it unaided on an unfamiliar authorized target, and explain why it worked and what would have stopped it
Walkthrough run 2026-10-09 · local demo app (no Docker on the build machine)
| Stage | Stage B · Practitioner |
|---|---|
| Level | Level 3 · Apply — do it unaided on an unfamiliar authorized target, and explain why it worked and what would have stopped it |
| Prerequisites | a-http-depth, a-linux-cli, b-sqli (full list in §2) |
| Map references | OWASP Top 10:2025 A05 Injection · WSTG-INPV-13 · CWE-78 |
1. Where this shows up
OS command injection is SQL injection's sibling one layer down: instead of the database executing your input as code, the operating system does. It sits inside OWASP Top 10:2025 A05 — Injection as CWE-78, and its impact is usually worse than SQLi's — a compromised database is bad; a compromised server is the whole machine. You will meet it in legacy integrations (a script shelling out to a reporting binary), in network-device web interfaces, in "convenience" wrappers around system utilities, and — from the other side — in EDR process-creation telemetry, where a web worker spawning a shell is one of the highest-fidelity alerts in the building.
2. Before you start
- You can read HTTP requests and responses cold (
a-http-depth). - You know what a shell is and what it does with
;,&,|(a-linux-cli) — this lesson is what happens when a stranger gets to type into one. - Your target is written down and authorizes your testing: your own local lab (this lesson's demo app), a PortSwigger Academy command-injection lab, DVWA's Command Injection module, or a bounty program whose scope explicitly includes the asset. Never test outside authorization; re-read
b-authorization-scopeif this sentence surprised you. - Tooling: curl is enough. Keep your proofs to
whoami/id/echo— the finding is proven the moment injected output renders. Do not pivot, persist, or touch other systems.
3. How it works
Some applications call out to the operating system: a diagnostic page that pings a host, a feedback form that invokes the mail program, a report generator that shells out to a converter. The dangerous pattern, in Python-flavored pseudocode:
os.system("ping -c 1 " + user_input)
os.system hands the string to a shell, and the shell parses the whole thing as a command line. If user_input is 127.0.0.1, the command is what the developer meant. If user_input is 127.0.0.1; whoami, the shell runs ping -c 1 127.0.0.1, then runs whoami. The input stopped being an argument and became code — the same sentence as SQLi, one layer down. The vulnerability is not the ping; it is the shell sitting between the application and the operating system, interpreting metacharacters the developer never intended the user to supply.
The metacharacter classes, in probe order. A metacharacter is a character the shell treats as an instruction rather than literal text — ; means "run the next command," not "the character semicolon." These are what the shell understands, so these are what you test:
- Command separators (Windows + Unix):
&,&&,|,||— chain a new command after the original. - Separators (Unix only):
;, newline — end the original command entirely. - Inline execution (Unix): backticks and
$(...)— the injected command runs inside the original command's arguments. - Quoted contexts: if your input lands inside quotes in the original command, terminate the quote first (
"or'), then use a separator.
Blind command injection. Often the application's response doesn't include the command output — the feedback form sends mail and shows "thanks." The finding is still provable, through side channels: time delays (; sleep 10 — the response takes 10 seconds), output redirection (write to a file under the web root, then fetch it), or out-of-band (cause a DNS lookup to infrastructure you control and watch for it). A blind injection is a finding, not a failed test — you just need a different channel.
Why it arises in real code. Legacy integrations nobody dares rewrite, convenience wrappers ("it's just a ping"), and the false belief that a constrained input can't contain metacharacters. The DVWA case is canonical: an IP address passed to ping via shell_exec().
Why allow-lists beat blocklists. The instinct is to block the metacharacters — strip ;, &, |, backticks. This loses, every time: shells have more metacharacters than you can enumerate (newlines, $(), ${IFS}, encoded forms), and one missed character reopens everything. An allow-list inverts the problem: instead of naming what's forbidden (infinite), you name what's permitted (finite) — "this field is an IPv4 address," "this field is one of these five report names" — and reject everything else. You can't bypass a check that never lets your input reach a shell in the first place.
How defenders see it. Process-creation telemetry is the crown jewel: Sysmon Event ID 1 on Windows, auditd execve on Linux. The hunt is structural — a web-server worker (w3wp.exe, httpd, php-fpm, node) as the parent process spawning cmd.exe, powershell.exe, sh, or bash. A web process has no legitimate reason to spawn shells; that parent-child pair is high-fidelity. Command lines containing separators or whoami/curl/wget from that parent confirm it. WAF coverage exists (ModSecurity CRS 932xxx family, e.g. REQUEST-932-APPLICATION-ATTACK-RCE.conf) but it's a tripwire, not a barrier. Application logs show the probing phase: distinctive 500s and timeouts from malformed separator payloads.
Worked example: the diagnostic demo
A minimal diagnostic app (stdlib Python, local only — the walkthrough runs it). GET /diag?host=127.0.0.1 builds ping -c 1 <host> and runs it through a shell, returning the output. The app also simulates a mail-style blind endpoint at /feedback?email= that invokes a shell command and returns only "thanks."
Baseline: host=127.0.0.1 returns ping output. Probe: host=127.0.0.1; echo PROBE-XYZ — the response contains PROBE-XYZ. That string in the output is the vulnerability: your input was executed as a shell command. Confirm with whoami — the response now tells you which OS user the web app runs as (impact evidence, read-only). Blind channel: email=x; sleep 5 at /feedback — the "thanks" takes 5 seconds. The delay is the proof, no output needed.
The fix, in the same app: stop calling the shell. The ping becomes a subprocess call with an argument list and no shell (["ping", "-c", "1", host] — the host can never become a command), plus an allow-list check that the host is a valid IP or hostname. The reason this is structural and not cosmetic: with an argument list and no shell, there is no parsing phase at all — the operating system hands the string to ping directly as one argument. A ; in the input never gets interpreted, because nothing interprets it. The shell was the parser; removing the shell removes the parse. Re-test: ; echo PROBE-XYZ now fails the allow-list (or is passed as a literal argument to ping, which errors harmlessly); sleep 5 changes nothing; legitimate 127.0.0.1 still works. Then the rule, stated once and kept: the single most effective defense is to never call out to OS commands from application code — almost every use case has a safer platform API. If you must, allow-list; never escape metacharacters.
4. Walkthrough
Run by us on this machine — transcript: runs/b-command-injection.log. Clean environment: stock Python 3.12, curl 8.5.0, loopback only, 2026-10-09. The demo app stands in for the PortSwigger/DVWA targets the reader uses (no Docker on this machine — see gaps).
- Start the vulnerable app. Save the
app.pyfrom this lesson's companion (diagnostic page buildingping -c 1 <host>through a shell;/feedbackblind endpoint) and run it on port 8324. Confirm the baseline:curl 'localhost:8324/diag?host=127.0.0.1'returns ping output. - Separator probe.
curl 'localhost:8324/diag?host=127.0.0.1; echo PROBE-XYZ'— observePROBE-XYZin the response. Write down, verbatim, the request and the response — this pair is the finding. - Confirm impact, read-only.
host=127.0.0.1 && whoami— the response shows the OS user the app runs as. Note what that user could reach; do not reach for it. The finding is proven; stop. - Blind channel.
curl 'localhost:8324/feedback?email=x; sleep 5'— time the response (~5s) versusemail=x(~instant). The delay is the proof where there is no output. - Apply the fix. Patch the handler: no shell (argument-list subprocess), plus an allow-list that the host matches an IP/hostname pattern. Restart the app.
- Verify the fix — four cases.
; echo PROBE-XYZ→ rejected by the allow-list (or passed inertly to ping);&& whoami→ same;sleep 5on/feedback→ no delay;host=127.0.0.1→ ping output unchanged (legitimate function unbroken). - Read the defender view. The companion logs every spawned command. Find your probe in the log: the parent process (the Python app) spawning
/bin/shwith your metacharacters in the command line. Write the detection rule: alert on any web-worker process spawning a shell, with the command line attached.
No step above is marked "not run by us" — in-band, blind, the fix, and all four verification cases ran locally.
5. On your own
Task: Pick one authorized target you have not touched in this lesson — a PortSwigger Academy OS command injection lab (the simple in-band one first), or DVWA's Command Injection module. Find the injection point with the echo probe, then determine whether it is in-band or blind (if blind, prove it with the time-delay channel). Then check the quoted-context case: does the input land inside quotes in the original command, and if so, what terminates it?
Success test: A one-page report: the vulnerable parameter, the metacharacter class that worked, in-band vs blind with evidence, the read-only impact confirmation (whoami output), the fix (no-shell API + allow-list, stated as code-level remediation), and the defender detection (which telemetry, which parent-child pattern). If you cannot determine the quoting context, say so and name the experiment that would settle it.
6. Where it goes wrong
- Blocklist "sanitization." Stripping
;and&while leaving newlines,$(), backticks, or${IFS}— the shell has more metacharacters than your list. Allow-list or no-shell; there is no third option that holds. - "The input is numeric." Validation that isn't actually enforced, or enforced client-side — the server is the only place a check counts.
- Fixing the sink but not the pattern. (The sink — the exact code location where untrusted input reaches the dangerous operation: here, the
os.systemcall.) You patched the ping page; the feedback form still shells out. Everysystem()/shell_exec()/backtick call in the codebase is the same bug wearing a different feature. - Blind = not vulnerable. The absence of output is a channel problem, not a verdict. Time delays and OAST exist precisely for this case.
- Over-proving. Running
whoamiproves the finding. Pivoting, reading/etc/passwd, or establishing persistence on an authorized target is outside every scope — the impact statement says what the access could reach, and stops. - Forgetting the parent process. Defenders don't hunt your payload string; they hunt the web-worker-spawns-shell pair. Your report's detection section should say so.
7. Read the source
Primary documents first:
- PortSwigger Web Security Academy — OS command injection (portswigger.net/web-security/os-command-injection) — read fully; the canonical treatment (metacharacter classes, blind techniques: time/redirect/OAST, prevention). Note the prevention order: never shell out first, allow-list second, never escape.
- CWE-78 (cwe.mitre.org/data/definitions/78.html) — skim the definition and its parent CWE-77 (command injection); cite the ID in findings.
Then at most three free resources:
- PortSwigger Academy — OS command injection labs (portswigger.net/web-security) — free, hosted, authorized; in-band plus the three blind techniques from this lesson.
- DVWA — Command Injection module (github.com/digininja/DVWA) — free; run locally via Docker: the Low→High ladder shows naive escaping failing, and Impossible shows the allow-list fix.
- OWASP Command Injection Defense Cheat Sheet (cheatsheetseries.owasp.org) — free; the fix side: safe APIs per language, allow-list patterns.
8. Check yourself
- Explain why
os.system("ping -c 1 " + user_input)is vulnerable butsubprocess.run(["ping", "-c", "1", user_input])is not, in terms of who parses the input. - A feedback form returns only "thanks" no matter what you submit, but
; sleep 8makes the response take 8 seconds. Explain why this proves command injection and which metacharacter class did the work. - Predict the process tree a defender sees during active command-injection probing, and explain why the parent-child relationship is a better detector than the payload string.
- A developer proposes stripping
;,&,|, and backticks from the input as the fix. Name two shell features this misses and explain why allow-listing is structurally stronger. - You confirm injection with
whoamion an authorized target. Explain what you write in the impact section of the report, and what you must not do next.
9. Prove it
Artifact: the one-page report from "On your own": vulnerable parameter, working metacharacter class, in-band vs blind evidence, read-only impact, code-level fix, defender detection.
Honest claim: "I can find OS command injection with metacharacter probes, prove blind cases through side channels, demonstrate read-only impact, and verify the no-shell/allow-list fix."
Can't claim yet: "I can assess the full blast radius" — post-exploitation impact assessment is a Stage C/D skill; this lesson proves the hole, not the house.
10. Leads to
b-ssrf— the server makes requests instead of running commands; compare which trust relationship each one abuses.- Defensive basics (
b-security-headers§3) — sit on the other side: write the Sysmon/auditd rule for your own attack traffic. - D red-team concepts (Stage D) — why this class is the first thing an adversary wants, taught as defender telemetry.
- Map refs: OWASP Top 10:2025 A05, WSTG-INPV-13, CWE-78.
This lesson is one of one hundred ten in the security guidebook. Part of Stage B · Practitioner. See all one hundred ten lessons →