OS command injection is the textbook RCE primitive and the one I still find first when I review a small PHP or Node service that talks to the operating system. The shape is always the same: the application builds a shell command by concatenating user input, hands the whole string to /bin/sh -c, and the shell parses the attacker's metacharacters before the target binary ever sees them. Everything that follows in this article is a variation on that one mistake.
This is the variant deep dive that sits under the remote code execution practitioner guide. I cover the canonical shell_exec sink, the full shell-metacharacter catalogue, the same exploit run four different ways against the rce-basic lab, why escapeshellarg is only partial protection, the argv-array pattern that actually closes the class across PHP, Python, Node, and Go, and the blast-radius controls that limit damage when the prevention layer fails.
TL;DR
OS command injection happens when an application builds a shell command from user input and passes the resulting string to a shell. Shell metacharacters in the user value (;, |, &&, `, $(), newline, redirection) are parsed by the shell as syntax, not data, and run whatever the attacker chose alongside the intended command. The textbook sink is shell_exec('ping -c 1 ' . $_GET['host']) and a ?host=localhost;id request runs both ping and id on the server. escapeshellarg blocks the metacharacter escape but leaves an argument injection hole through flags that the called binary itself parses. The real fix is to never invoke a shell at all: pass arguments as an argv array (execFile, subprocess.run with a list, proc_open with an array, exec.Command), validate each value against a strict allowlist, and prefer a library call over a subprocess when one exists. Containment defences (unprivileged user, dropped capabilities, seccomp, read-only root, egress filtering) limit how far an exploit travels once the bug is reached.
The textbook sink
The canonical vulnerable PHP fits on one line:
$host = $_GET['host'];
$output = shell_exec('ping -c 1 ' . $host);
echo "<pre>$output</pre>";A well-behaved request ?host=example.com runs ping -c 1 example.com. The bug is what happens when host contains anything other than a hostname. shell_exec does not call ping directly: it forks /bin/sh -c "ping -c 1 example.com;id" and the shell parses that string first. The semicolon is shell syntax for "end of one command, start of another". So the shell runs ping, then runs id, and the application echoes both outputs.
The mistake is treating the user value as if it were a single argument to ping. It is not. It is a substring of a shell program, and the shell looks for its own metacharacters before any binary is invoked.
Every language ships the same family of footguns: PHP shell_exec/exec/system/backticks, Python os.system and subprocess.run(..., shell=True), Node child_process.exec, Ruby backticks and Kernel#system with a single string, Java Runtime.exec(String). The common factor is "single string handed to a shell".
The shell metacharacter catalogue
The shell has a small but rich vocabulary for combining commands. Every one of these is a working injection vector when user input lands inside a shell string:
;ends one command, starts the next.ping foo;idruns both unconditionally.|pipes the first command's stdout into the second's stdin. The side effect is thatidruns.||runs the right-hand command only if the left one fails (ping invalid||id).&&runs the right-hand command only if the left one succeeds.` `is backtick command substitution.ping `id`runsidfirst and substitutes its output into the ping arguments.$( )is the modern syntax for the same substitution. Nests cleanly, no quoting trips.>,>>,<redirect stdout/stdin.ping foo > /tmp/pwnwrites attacker output to an attacker-named path, useful for dropping webshells when the path is web-accessible.&runs the command in the background.ping foo & idruns both, ping detached.- Newline (
%0aURL-encoded) acts like;in most shells. Useful when a filter strips semicolons but not newlines.
Most payload lists fixate on ;, |, and $(). Every one of the above is a fully functional injection point. Filtering a subset and forgetting the rest is one of the most common partial-fixes I see in code review.
Walking the lab
The rce-basic lab in the techearl-labs repo ships the exact shell_exec('ping -c 1 -W 1 ' . $host) sink at /ping.php. Boot it:
docker compose up rce-basicIt listens on http://localhost:8085. The four payloads below all run id as the web user, each through a different metacharacter, each ending with uid=33(www-data) gid=33(www-data) groups=33(www-data) appended to the ping output:
GET /ping.php?host=localhost;id # command separator
GET /ping.php?host=localhost|id # pipe
GET /ping.php?host=`id` # backtick substitution
GET /ping.php?host=$(id) # dollar-paren substitution
All four converge on the same outcome through different metacharacters. A filter that blocks ; alone (an actual fix I have seen shipped) leaves three working bypasses. A filter that blocks ;|& still misses command substitution. A filter that blocks all of those still misses newline injection through %0a. Filtering metacharacters is fighting the symptom; the disease is the shell sitting between the application and the binary.
Why escapeshellarg is not enough alone
The natural next reach in PHP is escapeshellarg. It wraps the value in single quotes and escapes any embedded single quotes, so the shell sees one quoted argument. The metacharacter exploits above all stop working because the metacharacters are now inside a quoted string.
$output = shell_exec('ping -c 1 ' . escapeshellarg($host));A ?host=localhost;id request now runs ping -c 1 'localhost;id', which ping rejects with "unknown host". The semicolon does nothing because the shell never sees it as syntax.
What escapeshellarg does not solve is the called binary's own argument parser. The shell hands one argument to the binary; that argument starts with a dash; the binary interprets the dash as a flag. The canonical case in the lab is /lookup.php:
$output = shell_exec('dig ' . escapeshellarg($domain));A request ?domain=-f /etc/passwd runs dig '-f /etc/passwd'. Shell-wise that is correct: one quoted argument. dig then sees the leading -f, interprets it as the batch-file flag, opens /etc/passwd, fails to parse the lines as DNS queries, and dumps the file contents through its error output.
This is the argument injection variant, with its own deep dive. Any tool that takes flags is a candidate: curl -K/-o, find -exec, tar --use-compress-program, git --upload-pack, wget --use-askpass, ssh -oProxyCommand. The fix at the call site is -- before the user argument so the binary stops looking for flags, or refuse any value starting with -. The real fix is to stop using a shell at all.
The argv-array pattern that actually works
The pattern that closes the whole class is the same across every modern language: bypass the shell entirely, pass the command name and each argument as separate elements of an array. The OS executes the binary directly via execve; no shell parses anything.
PHP, using proc_open with an argv array (PHP 7.4+):
$process = proc_open(
['ping', '-c', '1', '-W', '1', $host],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes
);
$output = stream_get_contents($pipes[1]);
proc_close($process);Python, using subprocess.run with a list (shell=False is the default):
result = subprocess.run(
['ping', '-c', '1', '-W', '1', host],
capture_output=True, text=True, timeout=5,
)Node, using child_process.execFile:
const { execFile } = require('child_process');
execFile('ping', ['-c', '1', '-W', '1', host], (err, stdout) => {
if (err) return res.status(500).send('ping failed');
res.type('text/plain').send(stdout);
});Go, using exec.Command:
out, err := exec.Command("ping", "-c", "1", "-W", "1", host).Output()The pattern is identical across all four because the OS primitive is identical. execve takes a binary path and a char *argv[]. A shell is one program among many that you can invoke through execve; the bug class only exists when you choose to invoke that program with a string assembled from user input. Pair the argv form with strict per-field allowlists (re.fullmatch(r"[a-zA-Z0-9.-]+", host) and similar) so even argument-injection style flag values get rejected before the call.
The safer pattern: do not shell out at all
Argv arrays are an improvement on the vulnerable baseline. The ideal is to not call an external binary in the first place. The reflex to shell out usually comes from familiarity with the command-line tool, not from any actual requirement that the work happen in a subprocess.
For DNS lookups, every language has a resolver in the standard library: dns_get_record in PHP, dns.resolver in Python via dnspython, dns.resolve in Node, net.LookupHost in Go. For HTTP requests, every language ships an HTTP client. For ICMP ping checks, raw ICMP sockets are available with a small library (icmplib in Python, golang.org/x/net/icmp in Go). For file operations, image processing, compression, encryption, the standard library or a binding is almost always better than a subprocess.
The library version is faster (no fork/exec), produces structured output instead of stdout to scrape, has no shell to misparse anything, and runs in the application's own privilege boundary. When I review a service and find five different shell_exec calls, four of them collapse to a single library call. The fifth is usually a niche binary with no library equivalent, and that is the one that ends up wrapped in proc_open with an argv array and a strict allowlist.
Blast-radius mitigation
Argv arrays prevent the bug. Containment controls limit what the bug does when the prevention layer fails. The two layers compose:
- Run as a non-root user. The web process runs as
www-dataor an app-specific UID. An RCE that lands as a low-privilege user has limited reach. - Drop Linux capabilities.
--cap-drop=ALLin Docker, then add back only what the app needs (usually nothing). - Read-only root filesystem.
--read-onlyplus writabletmpfsfor the few paths the app legitimately writes to. A webshell needs to write itself somewhere. seccomp,AppArmor, orSELinux. A seccomp profile that blocksexecvefrom the web process is a remarkable defence: even if the attacker reaches code execution inside the language runtime, they cannot fork a shell.- Separate user per service. Database, web, workers each run as a different UID. Lateral movement needs another privilege boundary to cross.
- Network segmentation. The web container does not need to reach the database admin port, the secrets manager unencrypted, or the cloud metadata service. IMDSv2 with required hop-limit closes the SSRF-into-credentials chain on AWS.
None of these prevent the bug. They make the bug less useful when it happens.
Real-world incidents
A short tour of command-injection-shaped CVEs. For per-version specifics I would rather link out than risk a stale number; the lessons below are what I want to remember.
- Shellshock, CVE-2014-6271 (September 2014). Bash before 4.3 patch 25 mis-parsed function definitions in exported environment variables: if a variable's value started with a function definition followed by a trailing command, Bash would parse the function and then execute the trailing command at shell startup. Combined with CGI, which exports HTTP headers as environment variables, this turned any CGI-backed endpoint into an unauthenticated RCE through a crafted User-Agent header. The lesson is what happens when data containers (env vars) get parsed as code (function definitions). The fix shipped within days; the cleanup took years because Bash was everywhere.
- GitLab ExifTool injection, CVE-2021-22205 (April 2021). GitLab Community and Enterprise editions before 13.10.3, 13.9.6, and 13.8.8 passed user-uploaded image files to ExifTool to strip metadata. ExifTool's DjVu handler allowed embedded Perl code in image metadata to be executed during parsing. Unauthenticated RCE by uploading a crafted image to a public project. Patched in April 2021, then re-exploited in the wild through 2022 against unpatched self-hosted GitLabs. Image-processing libraries are RCE sinks, and every web app that runs them on uploaded files inherits the risk.
- Confluence OGNL injection, CVE-2022-26134 (June 2022). Atlassian Confluence Server and Data Center across every supported branch (1.3.0 before 7.4.17, plus 7.13.0 before 7.13.7, 7.14.0 before 7.14.3, 7.15.0 before 7.15.2, 7.16.0 before 7.16.4, 7.17.0 before 7.17.4, and 7.18.0 before 7.18.1) evaluated OGNL expressions inside the request URI. A request like
/${@java.lang.Runtime@getRuntime().exec("id")}/triggered evaluation as part of URL routing, executing the command. Unauthenticated, single-request RCE, exploited in the wild before the patch shipped. Not shell-metacharacter injection, but the underlying pattern (user string reaching an evaluator that callsRuntime.exec) is the same shape one layer up.
The version-specific details for each CVE live in the NVD entries linked above; pull the current advisory before quoting a CVSS or patched-version number.
Frequently asked questions
Where to go next
This article is the deep dive on the classic shell-metacharacter sink. The variants and the wider map:
- Up to the remote code execution practitioner guide for the full taxonomy: command injection, argument injection, server-side template injection, and direct eval.
- Across to argument injection for the variant that gets through
escapeshellargby abusing the called binary's own flag parser. - Across to server-side template injection for the same data-becomes-code mistake one layer up in template engines.
- Across to eval injection for the dumbest version of the bug: user input handed straight to the language runtime.
- Back to the web application security vulnerabilities taxonomy for the hub.
The recurring lesson across the whole RCE family is the same one I keep writing about. Every place untrusted input crosses into something that parses bytes as code is a sink. For OS command injection that something is /bin/sh. The only reliable defence is to make the crossing not happen: pass arguments as arguments, never as a concatenated string, and prefer a library call over a subprocess whenever one exists.
Sources
Authoritative references this article was fact-checked against.
- OWASP, Command injectionowasp.org
- CWE-78cwe.mitre.org
- PortSwigger, OS command injectionportswigger.net





