Defending Against Command Injection
Command injection exists because user input reaches a function that hands a string to a shell, and the shell parses that string for metacharacters (;, |, `, $(), …). Remove the shell from the path and the metacharacters mean nothing; where a command genuinely must run, pass arguments as data, not as text to be re-parsed. This page is ordered by effectiveness - the first control removes the class, the rest limit what a missed case can do.
1. Don’t invoke a shell - use native functions
Section titled “1. Don’t invoke a shell - use native functions”The complete fix is to not build a shell command at all. Back-end languages have library calls for most things an app shells out for, and they can’t be command-injected:
- Pinging/host checks → a socket call (
fsockopen) or an ICMP library, notsystem("ping ..."). - File operations, archiving, HTTP, DNS → the language’s file/zip/HTTP/resolver libraries, not
tar/curl/digsubprocesses. - Image/PDF processing → a binding to the library (not shelling to
convert/wkhtmltopdf) where one exists.
If nothing native exists, the question becomes how you spawn the process - section 2.
2. If you must run a program, pass arguments as a vector (no shell)
Section titled “2. If you must run a program, pass arguments as a vector (no shell)”The single most important habit: execute the binary directly with an argument array, so no shell ever parses the input. The separator characters never get their special meaning because there is no shell to interpret them.
# Vulnerable - a shell parses the whole stringos.system("ping -c1 " + ip)subprocess.run("ping -c1 " + ip, shell=True)
# Safe - argv vector, no shell; ip is one opaque argumentsubprocess.run(["ping", "-c1", ip]) # shell=False is the defaultThe equivalent in each stack: Node execFile/spawn('ping', ['-c1', ip]) without shell: true (never child_process.exec); Java ProcessBuilder("ping","-c1",ip) or Runtime.exec(String[]) (not the String overload); PHP pcntl_exec/proc_open with an argv array; Ruby system("ping","-c1",ip) (multi-arg form, not the single-string form).
3. Allowlist the input - don’t blocklist
Section titled “3. Allowlist the input - don’t blocklist”Validate every value against the format it must have, server-side, and reject anything else. Positive validation (permit a known-good set) not negative (strip bad characters):
if (filter_var($_GET['ip'], FILTER_VALIDATE_IP)) { /* ok */ } else { /* reject */ }// non-standard formats: anchor a tight regex to the exact operand shape, and// make sure it can't start with '-'. ^[A-Za-z0-9._-]+$ is NOT enough - it// accepts --help, -rf, -o, so it still permits argument injection.// e.g. a hostname/label: ^[A-Za-z0-9][A-Za-z0-9.-]*$ (leading char is alnum)Validate on the back end, not just the front end - the front-end check is UX and is trivially skipped with a proxy. The subtlety that actually closes the argument-injection gap from section 2 is rejecting option-like values: a value that validates as “only safe characters” can still be a flag if it begins with -. Anchor the format so the operand can’t start with -, validate it against the shape you expect (an IP, a hostname, a numeric id), and still ---separate positional arguments where the program supports it. Allowlisting is defence in depth behind sections 1–2.
4. Harden the runtime (limits the blast radius)
Section titled “4. Harden the runtime (limits the blast radius)”Configuration that shrinks what a successful injection yields:
- Least privilege: run the web/app process as a low-privileged user (
www-data), so RCE isn’t instant root and can’t read secrets it doesn’t need. - Disable the dangerous sinks: PHP
disable_functions = system,exec,shell_exec,passthru,popen,proc_open,pcntl_execwhere the app doesn’t need them. This removes the sinks at the PHP level, which is the useful part for command injection. - Confine with OS-enforced isolation: a minimal container with restricted mounts, seccomp/AppArmor, and the low-privileged account above - these constrain a process that does spawn. Note that PHP’s
open_basediris not one of these: it restricts PHP’s own file functions only, so a command run throughsystem()is a child process outside its scope and can still read/etc/passwd(subject only to the OS user’s permissions). Don’t presentopen_basediras confinement for a successful command injection. - Normalise input at the edge: reject double-encoded and non-ASCII where the parameter shouldn’t contain it, so encoding tricks don’t slip past validation.
5. Defense in depth
Section titled “5. Defense in depth”- WAF (ModSecurity, cloud): flags known separator/obfuscation patterns and, in report-only mode, is an early warning. It is evadable (filter-bypass, and the Bashfuscator/DOSfuscation tooling) - a layer, never the fix.
- Logging and alerting: shell metacharacters,
$IFS, base64-pipe-to-shell, and long encoded blobs in parameters are high-signal. Hardening’s purpose is to make a hit noisier and slower, buying detection time.
For the report
Section titled “For the report”- Primary: replace shell-spawning calls with native library functions; where a process is required, exec it with an argument vector and no shell.
- Arguments: allowlist-validate each user value, and
---separate positional args to close argument injection. - Runtime hardening: low-privileged service account,
disable_functions, and OS-enforced isolation (minimal container, seccomp/AppArmor) - notopen_basedir, which doesn’t constrain spawned commands. - Defense in depth: WAF in at least report-only mode; alert on separator/obfuscation indicators.
Avoid recommending a character blocklist, escapeshellcmd, or a WAF as the primary fix - each is bypassable, and this cluster documents how.
Related
Section titled “Related”- Argument injection - the bug that survives a no-shell argv exec
- Filter bypass - why blocklists and escaping aren’t a fix
- Detection - the separators and probes these controls must stop