How to fix OS Command Injection
`exec('convert ' + filename + ' out.jpg')` with filename `'a; rm -rf /'` is RCE on your server. The fix is conceptually one rule: never invoke a shell with user input. Pass arguments as an array. Use a library if you have to.
What is OS Command Injection?
OS command injection happens when an app constructs a shell command from user input and executes it via a shell. The shell interprets metacharacters (`;`, `&`, `|`, `$()`, backticks) to chain or substitute commands. `system('ping ' + host)` with host=`google.com; cat /etc/passwd` runs both.
The fundamental fix isn't escaping — it's avoiding the shell entirely. Every language has two ways to invoke external programs: 'with shell' (interprets metacharacters, vulnerable) and 'without shell' (executes one program with literal args, safe). Use the second.
Where command injection hides: image processing (ImageMagick, ffmpeg), document conversion (pandoc, libreoffice), DNS/network tools (ping, nslookup, traceroute), file operations (zip, tar, cp), git operations, custom CLI integrations. Audit any code path that calls external programs with parameters derived from user input.
What an attacker can do
The concrete impact of leaving OS Command Injection unpatched.
Remote Code Execution as the web user
Run arbitrary commands; install backdoors; pivot to internal network.
Filesystem read/write to anything the process can access
Read .env, source code, certificates; write web-accessible PHP shell, modify config.
Lateral movement via SSH/AWS keys
Use stolen credentials to access other servers, S3 buckets, internal services.
Persistence via cron jobs / systemd units
Create cron job to maintain access even after fixing the original bug.
How do I know if I'm vulnerable?
Manual: search for `exec`, `execSync`, `spawn` (with `shell:true`), `child_process` in Node; `os.system`, `subprocess.run(shell=True)`, `subprocess.call(shell=True)` in Python; `system`, `exec`, `passthru`, backticks `` ` `` in PHP; `Kernel.system`, backticks, `IO.popen` in Ruby; `Runtime.exec` (single-string form) in Java. Each one needs proof the input is sanitized or hardcoded.
Automated: AuditCore's Command Injection Scanner sends payloads (`;id`, `&&id`, `|id`, `$(id)`, backtick-id) to every parameter and checks responses for `uid=` (Linux) or `Windows IP Configuration` (Windows). Static analysis via Semgrep flags shell-true and string-concat-into-exec patterns.
How to fix OS Command Injection
5 ordered steps. Apply them in order — each builds on the previous.
- 1
Pass arguments as an array — no shell
Every language has a way to invoke an external program with explicit args, no shell interpretation. Use it.
Node: `child_process.execFile()` or `spawn()` with array args. Python: `subprocess.run([...], shell=False)`. PHP: pcntl_exec() or escapeshellarg(). Ruby: `Kernel.exec(prog, *args)` (multi-arg form). Go: `exec.Command(name, args...)`.
javascriptStep 1import { execFile, spawn } from "node:child_process"; // ❌ exec — runs through shell, vulnerable exec(`convert ${userFilename} out.jpg`); // ✅ execFile — no shell, args are literal execFile("convert", [userFilename, "out.jpg"]); // Or spawn for streaming const proc = spawn("convert", [userFilename, "out.jpg"]); // Python equivalent: // import subprocess // subprocess.run(["convert", user_filename, "out.jpg"], check=True) // (default shell=False — safe) // PHP — use proc_open with arg array // $proc = proc_open(["convert", $userFilename, "out.jpg"], $descriptorspec, $pipes); // Or escapeshellarg() each argument when shell is unavoidable // system("convert " . escapeshellarg($userFilename) . " out.jpg"); - 2
Allow-list when the command itself comes from input
Sometimes the command name is user-controlled (e.g., choosing which converter to run). Allow-list the names; never construct from input.
Map user input to a fixed set of allowed commands. The user picks from a menu; you look up the command server-side.
pythonStep 2import subprocess ALLOWED_CONVERTERS = { "to-pdf": ["libreoffice", "--headless", "--convert-to", "pdf"], "to-png": ["convert", "-resize", "1024x"], "to-mp4": ["ffmpeg", "-i", "-", "-c:v", "libx264"], } def convert(format_id: str, input_path: str, output_path: str): if format_id not in ALLOWED_CONVERTERS: raise ValueError("Unsupported format") cmd = ALLOWED_CONVERTERS[format_id] + [input_path, output_path] subprocess.run(cmd, check=True, timeout=60, shell=False) - 3
Validate input format before passing to subprocess
Even with shell=False, some programs interpret their own arguments unsafely (find -exec, ssh -o, gpg --batch). Whitelist input format.
If the input is a filename, restrict to `[A-Za-z0-9._-]+`. If a domain, RFC 1035 hostname regex. If a number, `int()` cast. Reject anything else.
goStep 3package main import ( "errors" "os/exec" "regexp" ) var validHostname = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`) func ping(host string) ([]byte, error) { if !validHostname.MatchString(host) { return nil, errors.New("invalid hostname") } if len(host) > 253 { return nil, errors.New("hostname too long") } // ✅ exec.Command — no shell cmd := exec.Command("ping", "-c", "4", "-W", "5", host) return cmd.Output() } - 4
Use language-native libraries instead of subprocess where possible
Most things you'd shell out for have a library equivalent. Use the library — no shell, no injection surface.
Image processing: sharp / Pillow / ImageMagick bindings. Zip/tar: archive libraries. Network: built-in DNS / HTTP libs. Git: gitpython / nodegit / go-git.
pythonStep 4# ❌ Shelling out to imagemagick subprocess.run(["convert", input_path, "-resize", "300x300", output_path]) # ✅ Use Pillow (Python) or sharp (Node) from PIL import Image img = Image.open(input_path) img.thumbnail((300, 300)) img.save(output_path) # ❌ Shelling out to git subprocess.run(["git", "clone", repo_url]) # ✅ Use GitPython from git import Repo Repo.clone_from(repo_url, "/tmp/clone") # repo_url still validated - 5
Sandbox subprocess with seccomp / AppArmor + drop privileges
Defense-in-depth: even successful command injection achieves limited harm if the process can only access /tmp and can't network out.
Run worker subprocesses as non-root, in a container with read-only root filesystem, network=none, and seccomp filter blocking dangerous syscalls.
yamlStep 5# Docker for image-processing worker services: image-worker: image: my-worker read_only: true tmpfs: - /tmp:size=100M network_mode: none cap_drop: [ALL] user: "65534:65534" security_opt: - no-new-privileges:true - seccomp=image-worker-seccomp.json mem_limit: 512m cpus: 1.0 pids_limit: 100
How to verify the fix
AuditCore — Command Injection Scanner sends `;id`, `&&id`, `|id`, `$(id)`, backtick-id payloads against discovered parameters and looks for `uid=` in responses (proof of execution).
Manual: in any field that ends up as a subprocess argument (image upload filename, ping host, document conversion source), try `; id` or `&& id` or `\`id\``. If the response contains `uid=`, you have RCE. Test in Burp Repeater for cleaner payload iteration.
Frequently asked questions
Is escapeshellarg() / shlex.quote() safe?+
Safer than nothing, but error-prone. They handle shell metacharacter escaping but don't help if the program itself parses arguments unsafely (e.g., `find -exec`, options that take callable arguments). Always prefer no-shell invocation. Use escapeshellarg only when no-shell really isn't an option.
Is system() vulnerable but exec() safe?+
Depends on language. Node's `child_process.exec` runs through shell (vulnerable). `child_process.execFile` and `spawn` (without shell:true) don't. Python's `os.system` always uses shell. `subprocess.run` defaults to no-shell — safe. PHP's `system`, `exec`, `passthru`, backticks all use shell.
What's the difference between OS command injection and code injection (CWE-94)?+
OS command injection (CWE-78) is specifically shell command construction. Code injection (CWE-94) is broader — includes eval(), template injection, deserialization. Both have similar fixes (don't construct executable from input).
Can I use a CLI wrapper library to be safe?+
Libraries like Node's execa or Python's plumbum add ergonomics on top of native subprocess. They're safe by default (no shell), but still vulnerable if you opt into shell mode. Use them — but read the docs to confirm shell-by-default is off.
How do I find existing command injection in legacy code?+
Grep your codebase for the language-specific dangerous functions. Run AuditCore's Semgrep integration in the static phase. For runtime detection, the AuditCore Command Injection Scanner sends payloads to every API parameter discovered by the crawler.
Related fix guides
How to fix Path Traversal
Often combined — path traversal to write a malicious file, then command injection (or direct execution) to run it.
Read guideHow to fix insecure deserialization
Deserialization gadget chains usually escalate to command injection.
Read guideHow to fix file upload
Image processing pipelines (ImageMagick, ffmpeg) have history of command-injection CVEs.
Read guide