OS Command Injection: The Complete Practical Guide
Preventing OS Command Injection
How to stop command injection for real: avoid the shell with argument arrays, use native libraries, allow-list input, never blacklist, and harden the runtime.
Published
Every command injection in this cluster comes from one root cause: user input reaching a shell that parses it as commands. So the fix is singular and reliable. Do not involve a shell, and there is nothing to inject into. Everything else is defense in depth behind that. This page covers the controls in priority order. Build the habit on a free live target.
The one fix that matters: do not call a shell
Use your language's process API with an argument array, so input is passed as a single argument and never parsed for separators:
# safe: no shell; host is one argument to ping, separators are inert
subprocess.run(["ping", "-c", "1", host], shell=False, check=True)// safe: execFile does not spawn a shell; args are passed directly
execFile("ping", ["-c", "1", host], (err, stdout) => { /* ... */ });The dangerous forms to avoid are the ones that hand a string to a shell: subprocess.run(..., shell=True), Node child_process.exec, PHP system/exec/shell_exec/backticks, and any manual string concatenation into a command. Reach for the argument-array variant every time.
Better still: use a native library
The strongest version of "do not call a shell" is not shelling out at all. Most of the features that invite command injection have a library that takes structured input:
- Resolve DNS or ping with a networking library, not the
ping/nslookupbinaries. - Resize or convert images with an imaging library's API, not an ImageMagick command line.
- Render PDFs, extract archives, and process files with libraries that take arguments, not CLI tools.
A library call cannot be tricked by shell syntax because there is no shell in the path.
If a value must reach a command: allow-list
Sometimes you genuinely must pass a value to a binary. Validate it against a strict allow-list pattern and reject everything else:
- A hostname: match a strict hostname or IP regex.
- A number: parse it as an integer.
- A filename from a known set: compare against an explicit list.
Allow-list what is permitted. Never blacklist what is forbidden, because, as the filter bypass techniques show, you cannot enumerate every shell trick.
Close argument injection too
An argument array stops separator injection but not argument injection, where your value is parsed as a flag. Two additions close it:
- Insert a
--option terminator before the user value, so the binary stops reading options and treats the rest as positional (["curl", "--", url]). - Reject values that start with a dash and validate structure (an allowed URL scheme and host, a filename with no path).
Never build the command by concatenation
Do not assemble a command with string formatting, template literals, or concatenation under any circumstances. The moment user input is glued into a command string, a shell somewhere will parse it. The argument-array API exists precisely so you never have to build that string.
Harden the runtime
Assume a bug slips through and make execution worthless:
- Run the worker as an unprivileged user, never root.
- Confine it to a locked-down container with a read-only filesystem where possible.
- Filter egress, including DNS, so blind and out-of-band techniques have no channel to confirm through or exfiltrate over.
This does not fix the bug, but it turns a successful injection into a dead end.
A short checklist
- Use an argument-array process API, never a shell string.
- Prefer a native library over invoking a CLI tool at all.
- Allow-list the value against a strict pattern, never blacklist characters.
- Add a
--option terminator and reject leading dashes to close argument injection. - Never build a command by concatenation.
- Run unprivileged, contained, and egress-filtered as defense in depth.
That is the whole defense. For the offensive side in one place, see the command injection cheat sheet, and to prove to yourself that an argument array really does close the bug, break a shell-string version on a free live target.
← Back to OS Command Injection: The Complete Practical Guide