Command Injection
The shell is a parser you did not intend to invoke. Pass an argument array, or do not spawn a process at all.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
When a backend shells out, what turns a filename into code execution — and what removes the possibility?
Uploaded PDFs need a thumbnail. There is a well-known command-line tool that does it, and wiring it up is fifteen minutes of work.
Build the command as a string with the file path in it and hand it to the shell helper — exec('convert ' + path + ' out.png'). It works on the first try, which is most of the appeal.
The string is not a command. It is a program in the shell's language, and the shell parses metacharacters — separators, substitutions, redirections — before the target tool ever runs.
- The string is not a command. It is a program in the shell's language, and the shell parses metacharacters — separators, substitutions, redirections — before the target tool ever runs.
- The input does not have to be obviously hostile to break it: an ordinary filename with a space, a quote or an ampersand changes the parse, so the same defect produces support tickets long before it produces an incident.
- It runs as your service account, which can reach your database, your secret store and your cloud credentials. This is the highest-severity class in the checklist because the blast radius is the whole process identity.
- Quoting the interpolated value looks like a fix and is a new parser to get right — nested quotes, backslashes, newlines, and different rules on a different platform.
What is actually happening
- There are two distinct ways to start a process. Through a shell (
sh -c "..."), which parses one string into words, expansions, pipelines and redirections. Directly (execvewith an argument vector), where the program name and each argument are separate strings that nothing re-parses. - Every command-injection vulnerability is the first form. Removing the shell removes the parser, and with it the entire class — there is no metacharacter that means anything to a program receiving arguments as an array.
- Language APIs differ in which one they give you by default, and the names are not a reliable guide: some take a string and use a shell, some take an array and do not, and some do either depending on an argument you did not pass (Worker Processes).
- A second, quieter case: the argument array is safe from shell parsing but the target tool may still interpret an argument as an option. A value beginning with a dash can become a flag, so the position and the shape of the argument matter even without a shell.
- The path variant is related and separate: even with no shell, a filename joined into a path can escape the intended directory (Path Traversal in Security Engineering, and File Uploads Through the Backend here).
One string, or a vector of arguments
The whole lesson is visible in the two call signatures. One of them hands a string to a language that has separators, substitutions and redirections. The other hands a program a list of strings and no interpreter in between.
Notice that the safe version does not filter anything. It does not need to: there is no context in which an argument value could stop being an argument value.
import { exec } from 'node:child_process'
exec(`convert ${userPath} -resize 200x200 ${outPath}`, (err, stdout) => {
// the shell parses this string before convert ever runs
})import { execFile } from 'node:child_process'
// paths are generated from ids we own, not from the client
const src = join(WORK_DIR, `${upload.id}.pdf`)
const out = join(WORK_DIR, `${upload.id}.png`)
execFile('convert', ['--', src, '-resize', '200x200', out], {
timeout: 10_000,
maxBuffer: 1 << 20,
env: { PATH: '/usr/bin' }, // not the parent environment
cwd: WORK_DIR,
}, (err, stdout) => { /* ... */ })The second form has no shell, so metacharacters are ordinary bytes in an argument. The timeout stops a crafted input from occupying a worker forever, maxBuffer stops unbounded output becoming unbounded memory, and the trimmed environment means a compromise of convert does not hand over the database password.
Where the shell gets back in
Teams fix the obvious call and reintroduce the shell somewhere less visible. The rows below are the recurring routes, and the response column is the structural fix rather than a filter.
The last row is the one worth internalising: removing the shell does not make an argument inert to the *program* receiving it. Separating options from operands is a small habit that closes it.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A helper wraps spawn but joins arguments for a log line | Logs contain a runnable command; someone later reuses that string to run it | A safe API and an unsafe representation of the same call living side by side | Log executable plus argument count; never construct a joined command string at all |
shell: true added to make a pipe or a glob work | One call site is exploitable again with no visible change to the call shape | The convenience of shell features, not a misunderstanding | Do the pipe in code: two spawns and a stream, or a library that does the whole job |
| A cron entry, entrypoint script or migration helper | Application code is clean; the vulnerability is in an operational script | The rule was applied to "the code" and not to everything that runs | Same rule everywhere a process is started, including scripts in the image |
| User value passed as an argument to a flexible tool | A value beginning with a dash is consumed as an option | Option parsing in the target program, not shell parsing | Allow-list the value, and pass -- before operands so nothing after it is read as a flag |
| Environment inherited by the child | Compromise of the child yields database and cloud credentials | Default inheritance of the parent environment | Pass an explicit minimal env; keep secrets out of the environment where the platform allows a file or a store (Secrets Are Not Configuration) |
Bound the child, because the file is hostile too
Even with a perfect call site, you are handing an attacker-supplied document to a large C codebase whose job is parsing untrusted formats. Your own code being correct does not make the tool's parser correct.
So the second control is containment: what can this process reach if it is fully compromised? A conversion worker with no credentials, no outbound network and a scratch directory turns the worst case into a contained one. This is the same reasoning as least-privileged database users in SQL Injection — the control does not prevent the bug, it prices it.
How to build it
Most important first.
- First, ask whether a process is needed at all. An in-process library for image or PDF work has no argument parsing, no shell, and no separate binary to keep patched.
- If you must spawn: use the array form, with the shell disabled explicitly —
execFile/spawnwith an argument array in Node,subprocess.run([...], shell=False)in Python. Never build a command string. - Do not pass user text as an argument at all where you can avoid it. Generate the input path and the output path yourself from an id you control, and let the user's name live only in a database column (File Uploads Through the Backend).
- Where a user value genuinely must be an argument, allow-list it against a fixed set, and separate options from operands (
--before operands) so a value cannot become a flag. - Bound the child: a timeout, a memory limit, a working directory it cannot escape, and the least-privileged account that can do the job. A converter does not need database credentials in its environment (Resource Limits).
- Treat the child's output as untrusted input on the way back. It is bytes from a program processing a hostile file (The Trust Boundary).
What can go wrong
- The array form used everywhere except one place — a health script, a cron job, a migration helper — where a string was quicker.
- A "safe" wrapper that accepts an array and internally joins it into a string for logging, then someone reuses the joined string to actually run the command.
- Environment inherited wholesale, so the child process gets every secret in the parent's environment (Secrets Are Not Configuration).
- No timeout, so a crafted input that makes the tool loop consumes a worker permanently; enough of them and the service is out of workers (Failure Propagation).
- Unbounded output: the child writes gigabytes to stdout and the parent buffers it into memory.
- A blocklist of metacharacters, which is the same losing strategy as blocklisting in SSRF — When the Backend Fetches a URL — the parser has more surface than the list.
- Check-then-use on a path is a TOCTOU gap: validating that a path is inside the upload directory and then opening it later can be defeated if anything else can move or replace it. Open by a descriptor you obtained, or work under a directory nothing else writes to.
- A temp file with a predictable name can be replaced between creation and use. Use the platform's atomic temp-file creation rather than composing a name.
- Successful command injection is remote code execution as your service identity. Everything that identity can reach — database, object storage, cloud role, internal network — is in scope immediately (SSRF — When the Backend Fetches a URL is often the next step from there).
- The control that actually removes the class is structural: no shell, argument arrays. Filtering input is a supporting measure, not the control.
- Least privilege decides what the RCE is worth. A converter running in a container with no credentials, no network egress and a read-only filesystem turns a critical into a contained one (Defence in Depth).
- The uploaded file itself is attacker-controlled input to a C library with a long history of parser bugs. Isolation matters even when your own code is perfect.
- Exploit construction is Security Engineering's subject (Command Injection there). Here the deliverable is the call-site rule and the sandbox.
- "We validate the filename, so the shell is fine." Validation reduces the input space; the shell still parses whatever passes. Remove the parser rather than trying to outrun it.
- "Quoting the argument makes it safe." Quoting is escaping, with the same engine-specific and platform-specific pitfalls as escaping SQL by hand.
- "No shell means no problem." Argument arrays remove shell parsing, not option parsing or path handling. A value starting with a dash is still an argument the target program interprets.
- "It only runs an internal tool on our own files." The file arrived over HTTP from a stranger, and so did the name.
Operating it
- Log every process spawn with the executable and the argument count — never the joined command line, which will eventually contain a secret or a customer's data.
- Alert on child processes that exceed their timeout, and on non-zero exits by class. A change in the exit-code distribution is usually the first sign of hostile input.
- Watch process count and zombie/orphan children. A leak of unreaped children exhausts process table limits long before anything logs an error.
- Where the platform allows it, alert on any process spawned by the service that is not on the expected list. That detects the successful case, which nothing else will.
- Spawning a process per request does not scale the way a function call does: fork and exec cost real time and memory, and at high rates the process table and the scheduler become the bottleneck (Worker Processes).
- At any volume, move this work out of the request path into a job queue with bounded concurrency, so a burst of uploads cannot spawn a burst of processes (Background Jobs, Unbounded Concurrency).
- At larger scale the conversion usually deserves its own isolated service with no credentials — which is one of the legitimate reasons to extract a service (Microservices).
- The array form means you cannot use pipes, globs or shell redirection. That is the point, and it is genuinely less convenient — multi-step pipelines become explicit code.
- An in-process library removes the subprocess and adds a native dependency inside your process, where a parser bug is a crash or worse in your own address space rather than in a sandboxed child. Neither option is free; pick knowing which risk you are taking.
- Sandboxing the child costs operational work: another image, another set of limits, another thing to keep patched.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe shell-versus-exec distinction exists on every operating system that has both. Windows differs in the details of argument quoting, which makes hand-built command strings worse there, not better.
- LANGUAGE-SPECIFICNode:
execuses a shell,execFileandspawndo not unlessshell: true. Python:subprocess.runwith a list andshell=Falsedoes not; passing a string withshell=Truedoes;os.systemalways does. The safe API exists everywhere and is never the shortest one.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.