Command Injection Deep Dive
Command Injection happens when untrusted input is treated as part of an operating system command. Instead of being just âdataâ, user input changes what the server asks the OS to do.
Production reality: command injection isnât always âuser input to shellâ â itâs often a helper function wrapping a CLI where escaping was assumed, not verified.
Why it exists (deep reason)
Many apps still call OS tools for real work: image/video processing, PDF generation, Git operations, backups, diagnostics, DNS/network checks, and log rotation. The danger appears when code builds a command as a single string and passes it to a shell.
- Shell interprets text: the shell parses special characters and operators as control flow.
- String concatenation collapses intent: the OS canât tell which parts were âdataâ vs âinstructionsâ.
- Execution is high-impact: OS commands can touch files, network, secrets, and other processes.
First-principles mental model
- Source: where attacker-controlled input enters (query params, form fields, headers, JSON, filenames, job configs).
- Transformation: how input is parsed/normalized (trim, decode, join, stringify, template).
- Sink: where execution happens (
child_process.exec,spawnwithshell:true, shell scripts, cron). - Boundary: safe boundary is âarguments array to a known executableâ; unsafe boundary is âstring to a shellâ.
Vulnerable vs secure code patterns (Node.js)
Vulnerable pattern (minimal)
// â Vulnerable: shell interprets a concatenated string
import express from "express";
import { exec } from "child_process";
const app = express();
app.get("/diagnostics/ping", (req, res) => {
const host = String(req.query.host || "");
exec("ping -c 1 " + host, (err, stdout) => {
if (err) return res.status(500).send("Command failed");
res.type("text/plain").send(stdout);
});
}); Secure pattern (avoid shell, pass args)
// â
Secure: no shell, args passed as an array
import { spawn } from "child_process";
function isAllowedHostnameOrIp(v) {
// Defensive validation: keep it intentionally strict.
// Allow only hostnames / IPv4 literals; reject spaces/control chars.
return /^[a-zA-Z0-9.-]{1,253}$/.test(v);
}
app.get("/diagnostics/ping", (req, res) => {
const host = String(req.query.host || "");
if (!isAllowedHostnameOrIp(host)) return res.status(400).send("Invalid host");
const child = spawn("ping", ["-c", "1", host], {
shell: false,
timeout: 3000,
stdio: ["ignore", "pipe", "pipe"]
});
let out = "";
child.stdout.on("data", (d) => (out += d.toString("utf8")));
child.on("close", (code) => {
if (code !== 0) return res.status(500).send("Command failed");
res.type("text/plain").send(out);
});
}); Secure pattern #2 (allow-list commands, never accept raw command)
// â
Safe command selection: map user choices to fixed executables/args
const allowedActions = {
disk: { cmd: "df", args: ["-h"] },
uptime: { cmd: "uptime", args: [] },
whoami: { cmd: "whoami", args: [] }
};
app.get("/admin/tools", (req, res) => {
const action = String(req.query.action || "");
const tool = allowedActions[action];
if (!tool) return res.status(400).json({ error: "Unknown action" });
const child = spawn(tool.cmd, tool.args, { shell: false, timeout: 2000 });
let out = "";
child.stdout.on("data", (d) => (out += d.toString("utf8")));
child.on("close", () => res.type("text/plain").send(out));
});
Where it still happens in modern stacks
- Media pipelines: image/video conversion, thumbnailing, audio extraction (wrappers around native tools).
- PDF/report generation: calling system binaries or scripts for rendering.
- Git/CI helpers: invoking Git, tar/zip, linters, scanners.
- Operational endpoints: diagnostics, âpingâ, ânslookupâ, log utilities.
- Background workers: job runners executing scripts based on task config.
- Container entrypoints: shell scripts that interpolate env vars or config fields.
Variants (why they differ)
- Shell injection: a shell interprets a string (highest risk when
execorshell:trueis used). - Argument injection: attacker can add/alter flags/arguments (even when not using a shell), causing unintended behavior.
- Path/Executable control: attacker influences which binary is executed (via PATH, relative paths, or user-controlled filenames).
- Script injection: untrusted input reaches a bash/sh script template or a command file.
- Second-order execution: input is stored (job config, filename, database field) and executed later by a worker.
Detection workflow (experienced-style, systematic)
- Find execution sinks: look for
child_process.exec,execSync,spawnwithshell:true, and any wrapper utilities. - Map inputs to sinks: which request fields/env vars/config values flow into command building.
- Classify boundary: string-to-shell vs args-array-to-binary, and whether any arguments are attacker-controlled.
- Check constraints: allow-lists, strict validation, fixed command selection, timeouts, and permissions of the executing user.
- Assess blast radius: filesystem access, secrets exposure, network egress, container privileges, and available tooling.
Safe validation (defensive verification)
Your proof should focus on control and impact boundaries, not on running arbitrary commands. Prefer demonstrating that input changes command behavior in a harmless way, and capture evidence cleanly.
Evidence checklist (safe)
- Baseline request/response showing normal behavior.
- Repeatable behavior differences correlated with input shape (without dangerous side effects).
- Logs/telemetry showing which command ran (if you have access).
- Proof of execution context: which user runs it, working directory, timeouts applied, and whether shell is used.
Exploitation progression (attacker mindset)
This is a conceptual explanation of how attackers think. It intentionally avoids copy/paste exploit steps.
Phase 1: Discover an execution surface
- Find endpoints/features that ârun toolsâ (diagnostics, converters, import/export, background jobs).
- Observe error messages, timeouts, or output that suggests OS tooling is involved.
Phase 2: Determine the execution boundary
- Is a shell interpreting a string, or is it a direct binary + args?
- Which parts of the command are influenced by input (command selection, arguments, filenames)?
Phase 3: Prove controllable behavior safely
- Confirm that input changes execution behavior in a repeatable, non-destructive way.
- Measure if constraints exist (allow-lists, timeouts, permissions, output capture).
Phase 4: Expand impact by chaining
- Look for adjacent primitives: file read/write, secret access (env/config), network egress, or privileged helpers.
- Attackers often prefer âindirectâ impact: reading configs or manipulating outputs rather than obvious damage.
Tricky edge cases & bypass logic (conceptual)
- Hidden shell usage: wrappers or libraries that call a shell under the hood even if your code uses
spawn. - Argument injection: âsafe toolâ becomes unsafe when given attacker-influenced flags or file paths.
- Environment influence: PATH, locale, and environment variables affecting executed behavior.
- Working directory & relative paths: relative references can target unintended files.
- Unicode/normalization: validation mismatches between app and OS/tool parsing.
- Second-order flows: user-controlled fields stored and executed later by workers or scripts.
- Container privileges: âinside containerâ still dangerous if secrets/mounts/metadata are reachable.
Fixes that hold in production
1) Avoid the shell
- Prefer
spawn(cmd, args, { shell:false })overexec. - Never pass concatenated strings to a shell interpreter.
2) Allow-list actions and arguments
- Map user choices to fixed commands and fixed argument templates.
- Validate any remaining dynamic fields with strict patterns and length limits.
3) Minimize privileges
- Run the app and workers as a low-priv OS user.
- Use containers correctly: least privileges, read-only FS where possible, drop capabilities.
- Restrict secrets: donât expose production secrets to processes that donât need them.
4) Add guardrails
- Apply timeouts, output limits, and concurrency limits to avoid resource abuse.
- Egress controls where tools might access the network.
- Centralize execution in one audited helper module.
Regression prevention
- Code review rule: ban
exec/execSyncfor user-influenced operations; require args-array + allow-lists. - Static checks: lint rule or grep-based CI check for
child_process.execandshell:true. - Unit tests: ensure validation rejects unexpected inputs; ensure only allowed actions are executed.
- Telemetry: log which tool/action ran (not sensitive args), exit codes, timeouts.
Confidence levels (low / medium / high)
- Low: feature likely runs OS commands, but no direct evidence input influences execution.
- Medium: evidence that input changes command behavior or errors, but boundary/impact not fully confirmed.
- High: confirmed unsafe boundary (string-to-shell or argument control) with repeatable, safe proof and clear impact scope.
Interview-ready summaries (60-second + 2-minute)
60-second answer
Command Injection is when user input reaches OS execution in a way that changes what the server runs. The root cause is usually building a command string and invoking a shell. My first step is to identify sinks like exec or shell:true, then trace which inputs feed them. The production fix is to avoid the shell, pass args as an array, allow-list actions, and run under least privilege with timeouts.
2-minute answer
I treat Command Injection as âuntrusted input crossing into an OS interpreter boundaryâ. In Node, the high-risk pattern is exec or string-based command construction, because the shell parses text and can reinterpret input as control flow. Even without a shell, argument injection can still cause harmful behavior if attacker controls flags or file paths. I validate findings by proving input influences execution behavior safely (repeatable differences) and documenting the execution context and privileges. Remediation is: remove shell usage, use spawn with args arrays, strict allow-lists for actions/args, least privilege execution, timeouts/output limits, and regression prevention via CI checks and tests.
Checklist (quick review)
- Search for
child_process.exec,execSync, andspawn(..., { shell:true }). - Ensure no user input is concatenated into command strings.
- Use args arrays; disable shell; apply strict allow-lists for actions/args.
- Validate file paths, hostnames, and IDs with strict patterns and length limits.
- Run as least-priv OS user; restrict secrets; add timeouts and output limits.
- Log tool usage and failures; add CI checks and regression tests.
Remediation playbook
- Contain: disable the feature or gate it (admin-only) while fixing.
- Identify sinks: inventory all command execution points and wrappers.
- Fix boundary: replace string-to-shell with args-array execution; remove
shell:true. - Allow-list: map user choices to fixed command templates; validate residual inputs strictly.
- Constrain: least privilege, timeouts, output limits, concurrency caps, egress restrictions.
- Test: add unit/integration tests that assert rejection of invalid inputs and only allowed actions.
- Prevent regressions: CI rules to forbid risky APIs and require reviewed execution helpers.
Interview Questions & Answers (Easy â Hard)
Easy
- What is Command Injection?
A: Plain-English: itâs when user input makes the server run unintended OS commands. Deeply, itâs untrusted input crossing into an OS execution sink (often via a shell). - What is the most common root cause in Node.js?
A: Plainly: building a command as a string. Deeply: passing attacker-influenced strings toexecor enablingshell:trueso a shell parses text. - Is validation alone enough?
A: Plainly: no. Deeply: validation helps, but the primary control is avoiding the shell and using args arrays with allow-lists. - Whatâs the best primary fix?
A: Plainly: donât use a shell. Deeply: usespawnwithshell:false, fixed commands, and strict allow-lists for arguments. - Whatâs the difference between Command Injection and SQL Injection?
A: Plainly: different interpreters (OS vs DB). Deeply: same patternâuntrusted input reaches an interpreter and changes structure; defenses are boundary controls and parameterization/allow-lists. - Whatâs a safe way to prove it?
A: Plainly: show repeatable behavior change without damage. Deeply: capture baseline vs modified behavior, document sink/boundary, and avoid arbitrary execution.
Medium
- Scenario: A âpingâ endpoint takes
host. What do you worry about?
A: Plainly: it might run an OS command. Deeply: confirm whether it uses shell execution; if so, user input may change parsing. Fix by using args-array execution plus strict hostname validation and timeouts. - Scenario: App uses
spawnbut setsshell:true. Why is that risky?
A: Plainly: it turns safe execution into shell execution. Deeply: the shell reintroduces parsing of special characters, collapsing the âdata vs instructionsâ boundary. - Scenario: No shell is used, but user controls a flag/filename argument. Whatâs the risk?
A: Plainly: the tool may behave dangerously. Deeply: argument injection can trigger file reads/writes, network access, or unsafe modesâso you still need allow-lists and strict validation. - Follow-up: How do you reduce blast radius?
A: Plainly: least privilege. Deeply: run as low-priv OS user, restrict secrets, apply container hardening, limit egress, and add timeouts/output caps. - Follow-up: What code review smells do you search for?
A: Plainly: command strings. Deeply:exec/execSync, template strings used in commands,shell:true, and wrappers that accept raw user strings. - Scenario: A worker executes jobs from DB config.
A: Plainly: second-order risk. Deeply: stored input can become execution later; fix via allow-listed job types, strict schemas, and forbidding raw command fields. - Scenario: A feature calls a shell script with interpolated env vars.
A: Plainly: interpolation can be unsafe. Deeply: the script is an interpreter too; fix by passing arguments safely, quoting properly in scripts, and validating upstream values.
Hard
- Scenario: âSafe toolâ reads files based on an argument. How can this become high impact?
A: Plainly: attacker may reach sensitive files. Deeply: even without shell injection, controlling paths/flags can expose secrets. Defend with strict allow-lists, path normalization, and privilege reduction. - Scenario: You canât see command output. How do you still reason about risk?
A: Plainly: look for consistent behavior differences and logs. Deeply: prove unsafe boundary (string-to-shell) via code paths, errors, timing, and controlled side effects that are safe to observe. - Scenario: Containerized app claims âitâs fine, itâs in Docker.â Do you agree?
A: Plainly: not automatically. Deeply: containers can still access secrets, mounts, metadata, and internal networks; impact depends on privileges, mounts, and egress controls. - Follow-up: What is your long-term prevention strategy?
A: Plainly: standardize safe execution. Deeply: a single audited helper module, CI rules banning risky APIs, templates for allow-lists, and tests/telemetry for all tool executions. - Follow-up: How do you handle âwe must run OS toolsâ requirements?
A: Plainly: run them safely. Deeply: fixed executables, args arrays, strict allow-lists, sandboxing, least privilege, and timeouts/output quotas. - Scenario: A âconvertâ feature uses a third-party library that calls OS tools internally.
A: Plainly: hidden sinks exist. Deeply: review library behavior, disable shell use if possible, constrain inputs, sandbox the worker, and monitor executions for anomalies. - Scenario: You fixed code to use
spawnbut kept user-controlled executable name. Still safe?
A: Plainly: no. Deeply: controlling executable selection is dangerous; commands must be fixed/allow-listed, and PATH should not be attacker-influenced. - Follow-up: What metrics/logs help detect abuse?
A: Plainly: tool usage anomalies. Deeply: log action name, exit codes, timeouts, frequency per user/IP, and alert on spikes or repeated failures without logging sensitive arguments.