Command Injection Defense in Practice: Code Filtering and WAF Rule Configuration
Command injection can fully compromise a server. This article gives deployable defenses at code, OS, and WAF layers: PHP/Python secure coding, disable_functions hardening, and ModSecurity rules. Copy-paste ready.
Command injection means the application concatenates untrusted input into an OS command. The attacker then runs arbitrary commands on the host. Severity is typically critical. This article gives deployable command-injection defenses at the code, OS, and WAF layers.
Scope
- Business that must invoke OS commands: file conversion, archive, ping checks, PDF generation, and similar
- Legacy code that cannot be rewritten immediately and needs transitional controls
- Ops teams that need gateway-layer catch-all for injection payloads
Prerequisites
- Linux host (Debian/Ubuntu in this article)
- PHP 7.4+ or Python 3.8+
- Nginx plus ModSecurity 3.x already deployed (WAF layer optional)
- Permission to change site code
Injection mechanics
Code like system("ping -c 4 " . $ip) plus input 127.0.0.1; cat /etc/passwd lets the shell treat ; as a command separator and run a second command. Common metacharacters: ;, |, &&, ||, $(), backticks, newlines. Defense in three points: never concatenate strings, drop privileges, catch leftovers at the gateway.
Procedure
Step 1: Fix the code (root cause)
PHP: whitelist first, then escapeshellarg the whole argument:
<?php
// Bad: system("ping -c 4 " . $_GET['ip']);
$ip = $_GET['ip'] ?? '';
// Whitelist: legal IP only
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
http_response_code(400);
exit('invalid ip');
}
$arg = escapeshellarg($ip); # critical: one escaped argument
system("ping -c 4 " . $arg);
Python: subprocess with a list and shell=False:
import subprocess, ipaddress
ip = input("ip: ")
ipaddress.ip_address(ip) # illegal input raises
# critical: argv list + shell=False; no second shell parse
subprocess.run(["ping", "-c", "4", ip], shell=False, timeout=10)
- If a stdlib call can replace the OS command, use it (e.g. socket port check, not ping)
- Ban
shell=Trueand f-string/format concatenation of commands - Whitelist, then escape. Both layers required.
Step 2: Disable dangerous PHP functions
Edit php.ini. Kill command-execution functions so a remaining bug still cannot spawn a shell:
; /etc/php/8.2/fpm/php.ini
disable_functions = exec,system,passthru,shell_exec,proc_open,popen,pcntl_exec
systemctl restart php8.2-fpm
php -r "var_dump(function_exists('exec'));" # bool(false) means applied
If the app truly needs exec-family functions, skip this step, rely on code + WAF, and still do Step 4 privilege drop.
Step 3: ModSecurity catch-all rules
OWASP CRS already ships the 932xxx command-injection set. Custom rules below fill gaps:
# /etc/nginx/modsec/custom-rules.conf
# Block semicolon/pipe followed by common command tokens
SecRule ARGS "@rx (?i)(?:;|\||&&)\s*(?:cat|id|whoami|wget|curl|nc|bash|sh|python|perl)\b" \
"id:1000101,phase:2,deny,status:403,msg:'Command Injection Attempt',log"
# Block command substitution $() and backticks
SecRule ARGS "@rx (?:\$\(|`)" \
"id:1000102,phase:2,deny,status:403,msg:'Command Substitution Detected',log"
nginx -t && systemctl reload nginx
tail -f /var/log/modsec_audit.log # watch blocks
Step 4: Least privilege
The web runtime account gets no sudo and tight directory writes. Injection then cannot easily escalate:
# Confirm runtime user
ps aux | grep -E 'php-fpm|gunicorn' | head -3
# Lock sudo: no ALL grant for www-data in sudoers
visudo -f /etc/sudoers.d/www-data
# Tighten site write perms
chown -R www-data:www-data /var/www/html/uploads
chmod -R 750 /var/www/html/uploads
Verification
Hit the endpoint with injection payloads:
# Assume /ping.php?ip=
curl -s "https://your-site.com/ping.php?ip=127.0.0.1;cat%20/etc/passwd" -o /dev/null -w "%{http_code}\n"
# Expect: 400 from code, or 403 from WAF
curl -s "https://your-site.com/ping.php?ip=\$(id)" -o /dev/null -w "%{http_code}\n"
# Expect: blocked as well
php -r "var_dump(function_exists('exec'));"returns false- ModSecurity audit log shows id:1000101 hits
FAQ
FAQ1: We use escapeshellarg. Scanner still reports command injection. Why?
escapeshellarg only protects argument position. If the user controls the command name (system($_GET['cmd'])), escaping does nothing. On Windows, escapeshellarg differs from Linux; pipes may still parse. Hard-code the command name. User input only in argument position.
FAQ2: Will disable_functions break the app?
Disabling the exec family does not affect file I/O, DB, or HTTP. Before deploy: grep -rn "system\|exec\|shell_exec" /var/www/html. If a Composer package needs proc_open (some image libraries), keep proc_open and back it with WAF rules.
Summary
Command-injection defense is defense in depth: whitelist plus argument escaping at the code layer, disable_functions plus privilege drop at the OS layer, WAF rules as catch-all for unknown payloads. With all three in place, even legacy code gets real protection.