Back to Blog
critical SEVERITY7 min read

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

A critical command injection vulnerability in `tools/dev/src/index.ts` allowed attackers to execute arbitrary shell commands through unsanitized subprocess arguments. The fix was simple but essential: explicitly setting `shell: false` in the `spawn()` call to prevent shell metacharacter interpretation. This vulnerability demonstrates why subprocess handling requires explicit security controls in Node.js.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js where the `spawn()` function in `tools/dev/src/index.ts:322` was called without explicitly disabling shell interpretation. User-controlled arguments could contain shell metacharacters (`;`, `|`, `$()`, etc.) that would be interpreted by `/bin/sh`, enabling arbitrary command execution. The fix: add `shell: false` to the spawn options to ensure arguments are passed directly to the executable without shell parsing.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixAdd shell: false to spawn() options object to disable shell interpretation
riskRemote code execution with the privileges of the Node.js process
languageTypeScript/Node.js
root causespawn() called without explicit shell: false, allowing shell metacharacters in user-controlled args to be interpreted
vulnerabilityCommand Injection via subprocess shell interpretation

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

In the development tools repository, security researchers discovered a critical command injection vulnerability in tools/dev/src/index.ts that could have allowed attackers to execute arbitrary shell commands on systems running the package. The vulnerability existed in the runLoggedCommand() function, which spawns subprocesses to execute build and development commands. By passing specially crafted arguments containing shell metacharacters, an attacker could break out of the intended command and execute malicious code.

This is a real-world example of why subprocess handling in Node.js requires explicit security controls—and how a single line of code can make the difference between a vulnerable and secure implementation.


The Vulnerability Explained

What Happened

The vulnerable code in tools/dev/src/index.ts:322 looked like this:

const child = spawn(request.command, request.args, {
  cwd: request.cwd,
  env: request.env,
  // shell: false is NOT explicitly set here
  stdio: ["ignore", request.logFd, request.logFd],
  windowsHide: process.platform === "win32",
  windowsVerbatimArguments: request.windowsVerbatimArguments,
});

The critical issue: the spawn() call doesn't explicitly set shell: false.

While Node.js defaults to shell: false, this is a dangerous assumption to rely on. More importantly, without explicit security controls, future maintainers might not realize the security implications of this code, and the vulnerability could be reintroduced through refactoring.

The Attack

Imagine an attacker controls the request.args array passed to runLoggedCommand(). They could inject shell metacharacters:

// Attacker-controlled input
const maliciousRequest = {
  command: "echo",
  args: ["hello; cat /etc/passwd"],  // Shell injection payload
  cwd: "/some/path",
  env: process.env,
  logFd: 1
};

// Without shell: false, the semicolon is interpreted as a command separator
// Instead of echoing "hello; cat /etc/passwd", the shell executes:
// 1. echo hello
// 2. cat /etc/passwd

Other dangerous payloads could include:

  • Command substitution: ["$(whoami > /tmp/pwned)"] — executes a command and uses its output
  • Pipe injection: ["|", "rm", "-rf", "/"] — chains commands together
  • Environment variable injection: ["$(id > /tmp/exploit)"] — executes commands via variable expansion
  • Background execution: ["&", "nohup", "malware.sh"] — runs commands in the background

Why This Matters

The tools/dev/src/index.ts file is part of a Node.js library distributed to downstream consumers. Every project that uses this package inherits the vulnerability. If the library is used to:

  • Run build commands with user-supplied arguments
  • Execute scripts based on configuration files
  • Process command-line input from developers or CI/CD systems

...then attackers could exploit this to gain code execution in the build pipeline, compromise dependencies, or attack developer machines.


The Fix

The fix is elegantly simple: explicitly set shell: false in the spawn options.

Code Change

const child = spawn(request.command, request.args, {
  cwd: request.cwd,
  env: request.env,
+ shell: false,
  stdio: ["ignore", request.logFd, request.logFd],
  windowsHide: process.platform === "win32",
  windowsVerbatimArguments: request.windowsVerbatimArguments,
});

Why This Works

When shell: false is set:

  1. No shell interpreter is invoked: The command is executed directly via execve() on Unix or CreateProcess() on Windows
  2. Arguments are passed as-is: The array elements in request.args are passed directly to the executable, not parsed by a shell
  3. Shell metacharacters are literal: A semicolon, pipe, or dollar sign in an argument is treated as a literal character, not a special shell operator

With this fix, the attack payload ["hello; cat /etc/passwd"] is passed to the echo command as a single literal string, resulting in:

$ echo "hello; cat /etc/passwd"
hello; cat /etc/passwd

No command injection occurs.

Regression Testing

The PR also added a comprehensive test to prevent future regressions:

describe("runLoggedCommand security: shell injection prevention (V-001)", () => {
  it("spawn is invoked with shell: false to prevent user-controlled argument injection", async () => {
    const src = await readFile(path.join(toolsDevRoot, "src/index.ts"), "utf8");

    // Security invariant: runLoggedCommand must pass shell: false to spawn.
    assert.match(
      src,
      /shell:\s*false/,
      "spawn() in runLoggedCommand must explicitly set shell: false",
    );

    // Guard against regressions that re-introduce shell: true
    assert.doesNotMatch(
      src,
      /shell:\s*true/,
      "No spawn() call in index.ts should set shell: true",
    );
  });
});

This test reads the source file and verifies that:
- shell: false is explicitly present
- shell: true never appears in the file

This ensures the security property is maintained through code reviews and automated testing.


Prevention & Best Practices

1. Always Use shell: false Explicitly

Even though Node.js defaults to shell: false, explicitly setting it makes the security intent clear:

// ✅ Good: explicit security control
const child = spawn('command', ['arg1', 'arg2'], { shell: false });

// ⚠️ Risky: relying on defaults
const child = spawn('command', ['arg1', 'arg2']);

// ❌ Dangerous: enabling shell interpretation
const child = spawn('command', ['arg1', 'arg2'], { shell: true });

2. Pass Arguments as Arrays, Never Concatenate Strings

// ✅ Good: arguments passed as array elements
spawn('echo', ['hello', 'world']);

// ❌ Dangerous: concatenating strings with shell interpretation
exec('echo hello world');  // Uses shell by default

3. Use execFile() for Simple Cases

For executing a single file without shell interpretation, execFile() is a good alternative:

import { execFile } from 'child_process';

execFile('ls', ['-la', '/tmp'], (error, stdout, stderr) => {
  if (error) throw error;
  console.log(stdout);
});

4. Validate and Sanitize User Input

While shell: false is the primary defense, defense-in-depth suggests validating input:

function validateCommand(cmd) {
  // Allowlist: only permit known, safe commands
  const allowedCommands = ['build', 'test', 'lint'];
  if (!allowedCommands.includes(cmd)) {
    throw new Error('Invalid command');
  }
  return cmd;
}

function validateArgs(args) {
  // Ensure args is an array of strings
  if (!Array.isArray(args)) throw new Error('Args must be array');
  if (!args.every(arg => typeof arg === 'string')) {
    throw new Error('All args must be strings');
  }
  return args;
}

const command = validateCommand(request.command);
const args = validateArgs(request.args);
spawn(command, args, { shell: false });

5. Use Security Scanners and Linters

Tools can automatically detect dangerous patterns:

  • Semgrep: Detects spawn/exec calls without proper controls
  • ESLint with security plugins: Flags shell: true usage
  • Orbis AppSec: Identifies command injection risks through taint analysis

6. Reference OWASP and CWE Standards

  • CWE-78: OS Command Injection — https://cwe.mitre.org/data/definitions/78.html
  • OWASP Command Injection: https://owasp.org/www-community/attacks/Command_Injection
  • Node.js Child Process Security: https://nodejs.org/en/docs/guides/security/#command-injection

Key Takeaways

  • Never rely on shell defaults: Explicitly set shell: false in every spawn() call to make security intent clear and prevent accidental regressions
  • Arguments must be arrays, not strings: Passing arguments as array elements ensures they're not interpreted by a shell, even if shell: false is somehow removed
  • The runLoggedCommand() function now safely handles untrusted input: User-controlled arguments in request.args are passed directly to the executable without shell parsing
  • Regression tests protect against future vulnerabilities: The new test ensures shell: false remains in place and prevents shell: true from being reintroduced
  • Command injection is preventable through API design: Choosing the right Node.js APIs (spawn with shell: false) eliminates entire classes of vulnerabilities

How Orbis AppSec Detected This

Source: User-controlled arguments passed to the runLoggedCommand() function via the request.args parameter

Sink: The spawn(request.command, request.args, ...) call in tools/dev/src/index.ts:322 without explicit shell: false

Missing control: No explicit shell: false option and no validation that the spawn options disable shell interpretation

CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

Fix: Added shell: false to the spawn options object to ensure arguments are passed directly to the executable without shell metacharacter interpretation

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

Command injection in Node.js subprocess calls is a critical vulnerability that can lead to remote code execution—but it's entirely preventable through explicit security controls. The fix in tools/dev/src/index.ts demonstrates that sometimes the most important security improvements are the simplest: a single line of code (shell: false) that makes the security intent unmistakable.

For developers maintaining libraries or tools that execute subprocesses, this is a powerful reminder: always explicitly set shell: false, pass arguments as arrays, and test your security assumptions. Use tools like Orbis AppSec to automatically catch these issues before they reach production.

Secure subprocess handling isn't just about preventing attacks—it's about writing code that's safe by default and easy for future maintainers to understand and maintain securely.


References

Frequently Asked Questions

What is command injection?

Command injection occurs when an application passes unsanitized user input to a system shell or command interpreter, allowing attackers to inject shell metacharacters that execute arbitrary commands.

How do you prevent command injection in Node.js?

Use spawn() or execFile() with shell: false (the default), pass arguments as an array rather than concatenating strings, validate and sanitize all user input, and use allowlists for expected command patterns.

What CWE is command injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'). Related: CWE-77 (Improper Neutralization of Special Elements used in a Command).

Is input validation enough to prevent command injection?

No. While validation helps, the only reliable defense is using APIs that don't invoke a shell (spawn with shell: false) and passing arguments as array elements, not concatenated strings.

Can static analysis detect command injection?

Yes. Tools like Semgrep, ESLint with security plugins, and SAST scanners can detect spawn() calls without shell: false and identify patterns where user input flows to command execution functions.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #4754

Related Articles

critical

How command injection happens in Python os.system() and how to fix it

A critical command injection vulnerability was discovered in `src/O4_Geotag.py` where file paths and coordinate values were concatenated directly into `os.system()` calls invoking `gdal_translate` and `gdalwarp`. Because `os.system()` passes its argument through a shell interpreter, any shell metacharacters in the file path variable `f` — sourced from file enumeration or user-supplied input — could be exploited to execute arbitrary commands. The fix replaces both shell invocations with direct ca

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in `script/llm_semantic_analyzer.py` at line 394, where user-controlled input (API keys and model parameters) was interpolated directly into shell commands passed to `subprocess.run` with `shell=True`. An attacker who could control these parameters could inject shell metacharacters like `; rm -rf /` or `$(whoami)` to execute arbitrary commands. The fix sanitizes all user input before it reaches shell execution.

critical

How command injection happens in Python subprocess and how to fix it

A command injection vulnerability in `skills/skill-comply/scripts/runner.py` allowed attackers who could influence skill definition files to execute arbitrary binaries on the host system via `subprocess.run()`. The fix introduces an explicit allowlist of permitted executables (`ALLOWED_SETUP_EXECUTABLES`) that gates every command before it reaches the subprocess call at line 110. This closes a significant attack surface in the skill-comply pipeline without breaking legitimate setup workflows.

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in a CGI script that processed HTTP requests using `subprocess.check_output()` with `shell=True`. Attackers could inject arbitrary shell commands through URL parameters using metacharacters like semicolons, pipes, or backticks. The fix converts the command from a string to a list and sets `shell=False`, preventing shell interpretation of user input.

critical

How command injection happens in Java Runtime.exec() and how to fix it

A critical OS command injection vulnerability (CWE-78) was discovered in `page-object/sample-application/src/main/java/com/iluwatar/pageobject/App.java` at line 81, where a single-string invocation of `Runtime.getRuntime().exec()` passed a concatenated command directly to the Windows shell, allowing an attacker who controls the `applicationFile` value to chain arbitrary OS commands. The fix replaces this dangerous pattern with a properly constructed `ProcessBuilder` that uses absolute executable

critical

How buffer overflow in strcat() happens in C and how to fix it

A critical buffer overflow vulnerability was discovered in the `daemonize()` function of `tpl.c`, where command-line arguments are concatenated into a fixed-size 8192-byte buffer using `strcat()` without any bounds checking. An attacker who controls command-line arguments can overflow this buffer to corrupt adjacent memory and potentially achieve arbitrary code execution. The fix adds a buffer-length check before each concatenation to ensure writes never exceed the declared buffer size.