AuditCoreAuditCore
criticalCWE-78OWASP A03:2021 — InjectionRCEShell Injection

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. 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 1
    import { 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. 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 2
    import 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. 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 3
    package 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. 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. 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.

FAQ

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.

Don't just guess — scan and verify

AuditCore Free Trial scans your homepage for OS Command Injection and 50+ other vulnerability classes. No credit card. Results in 60 seconds.