Application & code
Educational12 minOS Command Injection
An application builds a system shell command from untrusted input and hands it to a shell, so shell metacharacters in the input are interpreted as syntax — letting the attacker run arbitrary commands with the privileges of the application process.
Classification
- Domain
- Application & code
- ROOTVIEL support
- Educational — Recognized concept, documented for learning — not detected by ROOTVIEL.
- Analysis family
- code-src
- CWE
- CWE-78
- OWASP
- A03:2021-Injection
Definition
OS command injection is the failure to keep user-supplied data out of the command that an application asks the operating system to run. The code assembles a command line as a string and passes it to a shell; because a shell parses metacharacters — semicolons, pipes, backticks, `$()`, `&&`, redirection — any of those characters in the input is treated as command syntax rather than as data. The attacker thereby appends, chains, or substitutes commands of their own.
As with SQL injection, the defect is not in the operating system, which correctly executes what the shell parses; it is in the application that let data become command. The dangerous ingredient is almost always the shell: when a program invokes an external binary directly with an argument vector, there is no shell to interpret metacharacters, and the whole class largely disappears.
Context
This appears wherever an application shells out — invoking command-line tools for image conversion, PDF generation, archive extraction, network utilities (ping, nslookup, curl), virus scanning, or data processing — and builds the command from input. The input may be a filename, a URL, a hostname, an option flag, or any field that ends up on the command line.
- ▸Features that wrap a CLI tool (ImageMagick, ffmpeg, ping, tar/zip, git) and pass user input as part of the command.
- ▸Admin/diagnostic endpoints that run a network command on a user-supplied host or address.
- ▸File-processing pipelines where an uploaded filename or path is placed on a command line.
- ▸Scheduled jobs and webhooks that build a command from stored or externally fetched values.
- ▸Argument injection: even without a shell, attacker-controlled input interpreted as an option flag can change a trusted binary’s behaviour.
Technical fundamentals
There are two distinct ways to launch a process. A shell execution (system, popen, `sh -c "..."`, `child_process.exec`) hands a single string to a command interpreter, which tokenises and parses it — expanding variables, splitting on whitespace, and acting on metacharacters. A direct execution (execve-style, `child_process.execFile`/`spawn` with an argument array, `subprocess.run([...])` without a shell) passes the program and an explicit list of arguments to the kernel, with no shell in between and therefore no metacharacter parsing.
The clean model is taint dataflow with the shell as the sink: untrusted input is the source, and a shell-executing call is the dangerous sink. Injection exists when tainted data reaches that sink inside the command string with no neutralising control. The structural fix is to change the sink — use direct execution with an argument vector — so that user input is always a single argument value and can never be parsed as command syntax. A weaker, per-metacharacter approach (quoting, escaping) is fragile because shells have many quoting contexts and argument-injection remains even when the shell is avoided.
- Shell execution
- Passing one string to a command interpreter that parses metacharacters (system/exec/sh -c). The dangerous sink.
- Direct execution
- Launching a program with an explicit argument array and no shell, so input is always a value, never syntax.
- Metacharacter
- A shell-significant character — ; | & $ ` ( ) < > newline — that changes control flow when parsed.
- Argument injection
- Input that becomes an extra option/flag to a trusted binary (e.g. a leading dash), changing its behaviour even with no shell.
Root cause
The root cause is building a command as a single string and running it through a shell, which fuses data and command into one parse. The developer reaches for the convenient string-based API (exec/system) because it mirrors how a command looks when typed, not realising that typing also invokes shell parsing. The remedy is to stop producing a command string at all: pass the program and its arguments as separate tokens so the operating system receives data as data.
A secondary cause is treating the problem as one of blocking "bad characters". Blocklists miss encodings, alternative metacharacters, and context-specific tricks; and even a perfectly shell-safe argument can still be an injected option flag. The structural answer — direct execution plus validation that the value is a well-formed argument, not an option — addresses both.
How the attack works, step by step
- 1The application builds a command string that includes a user-supplied value — for example a hostname to ping — and runs it through a shell.
- 2The attacker supplies a value containing a shell metacharacter followed by a second command, such as a host value of "x; <command>" or "x && <command>".
- 3The shell parses the combined string, sees the separator, and executes the attacker’s command after (or instead of) the intended one.
- 4The attacker escalates: read or write files, open a reverse shell, exfiltrate environment variables and credentials, or pivot into the internal network — all with the application process’s privileges.
- 5Because the input was parsed by a shell, nothing distinguished the attacker’s command from the application’s own.
Blind variants mirror SQL injection: if no output is returned, the attacker infers success from a time delay (a sleep command) or an out-of-band callback (a DNS or HTTP request the injected command triggers).
Vulnerable example
A synthetic, illustrative diagnostic endpoint that pings a user-supplied host by building a shell command string:
synthetic — do not use; illustrates the flawconst { exec } = require("node:child_process") // UNSAFE: input concatenated into a string run by a shell (sh -c). function pingHost(req, res) { const host = req.query.host // exec() runs the string through a shell, which parses ; | && $() etc. exec("ping -c 1 " + host, (err, stdout) => { res.send(stdout) }) } // Attacker sends host: 8.8.8.8; cat /etc/passwd // The shell runs: ping -c 1 8.8.8.8 ; cat /etc/passwd // The second command executes with the app process's privileges.
Corrected example
The fix is to change the sink: launch the program directly with an argument array and no shell, so the host value is always a single argument. Add validation so the value is a well-formed host and cannot be mistaken for an option flag.
synthetic — direct execution, argument array, no shellconst { execFile } = require("node:child_process") const HOST_RE = /^[a-zA-Z0-9.-]{1,253}$/ // allow-list of host characters // SAFER: execFile runs the binary directly; args are a list, never a shell string. function pingHost(req, res) { const host = req.query.host if (!HOST_RE.test(host) || host.startsWith("-")) { res.status(400).send("invalid host") return } // "--" ends option parsing so a crafted value can't become a flag. execFile("ping", ["-c", "1", "--", host], (err, stdout) => { res.send(stdout) }) }
Limit of the fix: direct execution removes shell-metacharacter injection, but argument injection remains if the input can be read as an option — hence the leading-dash check and the "--" end-of-options marker. Avoiding the shell also means you lose shell features (globbing, pipes); build those in code instead. Running the worker with least privilege and in a sandbox reduces blast radius but does not substitute for removing the shell.
Impact
- ▸Arbitrary code execution on the host with the application process’s privileges — often the highest-severity outcome in an application.
- ▸Confidentiality: read files, environment variables, and credentials; exfiltrate data out-of-band.
- ▸Integrity and persistence: write files, install backdoors, modify configuration or cron jobs.
- ▸Lateral movement: use the compromised host as a pivot into internal networks and cloud metadata services.
Detection
The structural signal is a taint path from untrusted input to a shell-executing sink (system, popen, exec, `sh -c`) with the input inside the command string and no direct-execution alternative. Static analysis can flag these, but it over-reports where a value is in fact constant and can miss indirect command construction. Dynamic testing confirms exploitability: inject separators and a benign marker command (a measurable sleep, or an out-of-band DNS/HTTP callback) and observe the side effect. The most reliable review check is simply which API is used — a string-based shell call is the red flag; an argument-array direct call is not.
Where ROOTVIEL fits
This entry is reference material. ROOTVIEL’s verified, execute-and-prove engine targets the AI/agentic and Web3 domains, where it confirms a finding by running the attack in a sandbox against a deterministic check. OS command injection is an application/code weakness that ROOTVIEL does not currently settle with a verified engine, so a finding here is educational unless it is independently demonstrated in the target environment.
Support: educational. The input → command-string → shell dataflow is well suited to taint analysis, but ROOTVIEL does not promote a verified command-injection finding. Any match is a hypothesis to confirm with a controlled exploit, not a proven vulnerability.
Mitigation
Remove the shell from the path; only then add validation and containment.
- ▸Preventive: launch external programs with direct execution and an explicit argument array (execFile/spawn, `subprocess.run([...], shell=False)`); never build a command string for a shell.
- ▸Preventive: when a shell is genuinely unavoidable, validate inputs against a strict allow-list and refuse values that could be option flags; end options with "--".
- ▸Preventive: where possible, replace the external command with a library call in the same language (no process launch at all).
- ▸Defence in depth: run the worker with least privilege, in a container/sandbox, with no outbound network it does not need.
- ▸Detective: flag string-based shell APIs in code review and static analysis; prefer the argument-array APIs by convention.
Verifying the mitigation
Verify by confirming every process launch uses a no-shell, argument-array API, and that inputs are validated and cannot pose as option flags. Then try to exploit: send separator payloads, option-flag payloads, and out-of-band callbacks to each previously vulnerable field and confirm no extra command runs. Report the result as "these attempts failed against the tested inputs", not as a guarantee of no command injection anywhere.
A static pass means no input→shell path was found in the analysed code. Indirectly constructed commands or newly added shell calls can still exist, so keep the check in the build and pair it with dynamic probing.
Common mistakes
- ▸Escaping or blocklisting metacharacters instead of removing the shell — fragile across quoting contexts and encodings.
- ▸Switching to a direct-exec API but still passing a single string that the API itself hands to a shell.
- ▸Forgetting argument injection: a shell-safe value that starts with a dash can still become a dangerous option flag.
- ▸Validating the input in one path but shelling out with the raw value in another.
- ▸Relying on container isolation as the only control, so a single misconfiguration becomes full host compromise.
References
- [1]MITRE CWE-78 (OS Command Injection)
- [2]OWASP Top 10 — A03:2021 Injection
- [3]OWASP Cheat Sheet — OS Command Injection Defense
- [4]PortSwigger Web Security Academy — OS command injection
References point to public, well-known technical sources for further study.
Related articles
Same injection shape, the database as the interpreter.
Path TraversalOften chained once arbitrary commands can read files.
Server-Side Request Forgery (SSRF)An injected command can make outbound requests.
Insecure Output HandlingWhen generated text becomes the command string.
Taint analysis: source to sink