How Command Injection Happens in Node.js Child Process Calls and How to Fix It
Introduction
In the Claude package's bin/cli.js, a high-severity command injection vulnerability was hiding in the plugin marketplace installation logic. At line 1076, the code was executing shell commands using a function argument nameWithVersion without any sanitization or validation. This seemingly innocent code pattern—passing user input directly to execSync()—created a dangerous attack surface where malicious actors could inject shell metacharacters and execute arbitrary commands on any system running the CLI.
The vulnerability existed in the Claude plugin install, update, and uninstall commands, which handle user-provided package names. If an attacker could influence the nameWithVersion variable—through command-line arguments, environment variables, or configuration files—they could break out of the intended command and execute system commands like rm -rf / or exfiltrate sensitive data.
This is precisely the type of vulnerability that automated exploit-development tools look for: a code pattern that chains with other weaknesses to enable remote code execution. By fixing it proactively, the team removed an exploit primitive before it could be weaponized.
The Vulnerability Explained
The Dangerous Pattern: execSync() with String Concatenation
The original vulnerable code in bin/cli.js looked something like this:
// VULNERABLE: User input directly in shell command string
const result = execSync(`claude plugin install ${nameWithVersion}`);
This pattern is deceptively simple, which makes it particularly dangerous. Here's why:
The Problem: When you pass a string to execSync() without specifying shell: false, Node.js spawns a shell (typically /bin/sh on Unix or cmd.exe on Windows) and passes the entire string to be interpreted. The shell treats special characters like ;, |, $(), and backticks as command operators, not literal characters.
Concrete Attack Scenario: Imagine an attacker crafts a malicious package name:
claude plugin install "my-package; rm -rf /"
Or via environment variable injection:
claude plugin install "$(whoami > /tmp/pwned.txt)"
The shell would execute:
1. claude plugin install my-package (the intended command)
2. rm -rf / (the injected command—catastrophic)
Or in the second case:
1. Execute whoami in a subshell
2. Pass the output as part of the command
The nameWithVersion variable, controlled by user input, becomes a vector for arbitrary command execution. An attacker doesn't need to break into your system—they just need to trick you into running the CLI with a malicious package name.
Why This Matters for the Claude Package
The Claude package is distributed as an npm module used by developers and organizations. If an attacker can exploit this vulnerability in the CLI, they can:
- Execute arbitrary code on the developer's machine during plugin installation
- Steal credentials from environment variables or configuration files
- Modify source code or inject backdoors into the development environment
- Pivot to internal systems if the developer has elevated privileges or network access
The attack surface is particularly wide because plugin names are often sourced from user input, configuration files, or even CI/CD pipelines.
The Fix
From execSync() to execFileSync() with Argv Arrays
The fix replaces shell-based execution with direct process spawning, eliminating the shell as an attack vector entirely. Here's the actual change from the PR:
Before (Vulnerable):
const { execSync } = require('child_process');
// Vulnerable: shell interprets special characters in nameWithVersion
const result = execSync(`claude plugin install ${nameWithVersion}`);
After (Secure):
const { execFileSync } = require('child_process');
// Secure: no shell; arguments are data, not code
const claudeBin = resolveExecutableForPlatform('claude');
const result = execFileSync(claudeBin, ['plugin', 'install', nameWithVersion]);
Why This Fix Works
The key differences:
-
execFileSync()vs.execSync():execFileSync()spawns a process directly without invoking a shell. It does not interpret shell metacharacters. -
Argv Array vs. String Concatenation: Arguments are passed as an array
['plugin', 'install', nameWithVersion], not concatenated into a string. Each element is treated as a literal argument, not a shell command fragment. -
Platform-Aware Executable Resolution: The new
resolveExecutableForPlatform()function handles Windows-specific quirks. On Windows,execFileSync()doesn't automatically applyPATHEXT, so the code now explicitly resolvesclaude.cmdon Windows systems.
The Supporting Code Change
The PR also adds a helper function to resolve the correct executable:
function resolveExecutableForPlatform(executable, platform = process.platform) {
if (platform === 'win32' && executable === 'claude') {
return 'claude.cmd';
}
return executable;
}
This ensures the fix works consistently across operating systems.
Concrete Example: Attack Prevention
Malicious Input:
node bin/cli.js "my-package; rm -rf /"
Old Behavior (Vulnerable):
execSync(`claude plugin install my-package; rm -rf /`);
// Shell executes TWO commands:
// 1. claude plugin install my-package
// 2. rm -rf / ← DISASTER
New Behavior (Secure):
execFileSync('claude', ['plugin', 'install', 'my-package; rm -rf /']);
// Executes ONE command with the literal argument:
// claude plugin install "my-package; rm -rf /"
// The semicolon is just part of the package name, not a command separator
The malicious input becomes harmless because it's treated as data, not code.
Regression Testing: Guarding Against Future Exploitation
The PR includes comprehensive regression tests that verify the security invariant: "Shell commands never include unsanitized user input"
const payloads = [
{ input: 'normal-package', description: 'valid input' },
{ input: '$(whoami)', description: 'command substitution' },
{ input: '; rm -rf /', description: 'command chaining' },
{ input: '`id`', description: 'backtick execution' },
{ input: 'package || cat /etc/passwd', description: 'OR operator injection' }
];
test.each(payloads)('rejects adversarial input: $description', async ({ input }) => {
// Verify that shell command results don't appear in output
// If the injection worked, we'd see whoami output, uid=, /etc/passwd content, etc.
const forbiddenResults = [
process.env.USER || process.env.USERNAME,
'uid=',
'root:',
'command not found'
];
forbiddenResults.forEach(forbidden => {
expect(output).not.toContain(forbidden);
});
});
These tests ensure that even if someone accidentally reverts to execSync() or adds similar vulnerable patterns, the test suite will catch it immediately.
Prevention & Best Practices
1. Always Use execFileSync() or spawn() with Argv Arrays
// ✅ GOOD: No shell, argv array
const { execFileSync } = require('child_process');
execFileSync('npm', ['install', packageName]);
// ❌ BAD: Shell enabled, string concatenation
const { execSync } = require('child_process');
execSync(`npm install ${packageName}`);
// ❌ BAD: Even with execFileSync, shell: true negates the benefit
execFileSync('npm', ['install', packageName], { shell: true });
2. Never Trust User Input in Command Execution
Even if you think input is validated, treat it as untrusted:
// ❌ BAD: Even with "validation"
function installPlugin(name) {
if (!/^[a-z0-9-]+$/.test(name)) throw new Error('Invalid name');
return execSync(`claude plugin install ${name}`);
}
// ✅ GOOD: Validation + safe execution
function installPlugin(name) {
if (!/^[a-z0-9-]+$/.test(name)) throw new Error('Invalid name');
return execFileSync('claude', ['plugin', 'install', name]);
}
3. Use Static Analysis Tools to Detect Patterns
Semgrep rule javascript.lang.security.detect-child-process.detect-child-process catches these vulnerabilities automatically:
semgrep --config p/security-audit bin/cli.js
Other tools:
- ESLint: eslint-plugin-security with rule detect-child-process
- NodeJsScan: Detects dangerous child_process patterns
- Snyk: Scans for known vulnerable patterns in dependencies
4. Apply Principle of Least Privilege
Even with safe execution APIs, limit what the CLI process can do:
// Run the subprocess with restricted permissions
execFileSync('claude', ['plugin', 'install', packageName], {
uid: unprivilegedUserId, // Run as non-root user
gid: unprivilegedGroupId,
timeout: 30000, // Prevent hanging
maxBuffer: 1024 * 1024 // Limit output
});
5. Reference OWASP and CWE Standards
- CWE-78: OS Command Injection
- CWE-88: Argument Injection or Modification
- OWASP A03:2021: Injection
Key Takeaways
-
Never use
execSync()with string concatenation for user-controlled input. The shell interprets special characters as operators, not literal data. -
execFileSync()with argv arrays is the gold standard for safe child process execution. It bypasses the shell entirely, treating all arguments as data. -
Platform-specific executable resolution matters. The fix explicitly handles Windows
.cmdshim resolution becauseexecFileSync()doesn't applyPATHEXTon Windows likeexecSync()does. -
Regression tests are your safety net. The test suite verifies that common injection payloads (command substitution, chaining, backticks) cannot execute, preventing future regressions.
-
Proactive removal of exploit primitives raises the bar against automated attack tools. Even if this vulnerability wasn't immediately exploitable in isolation, removing the pattern prevents it from being chained with other weaknesses.
How Orbis AppSec Detected This
Source: User-controlled package name passed to the nameWithVersion function argument in bin/cli.js (CLI plugin install/update/uninstall commands).
Sink: execSync() call at line 1076 in bin/cli.js where the unsanitized nameWithVersion is interpolated into a shell command string.
Missing Control: No input validation, no shell escaping, no use of safe APIs like execFileSync() with argv arrays.
CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command - 'OS Command Injection')
Fix: Replace execSync() with execFileSync(), pass arguments as an argv array instead of string concatenation, and implement resolveExecutableForPlatform() to handle Windows .cmd shimming.
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 through child process calls is one of the most dangerous vulnerabilities in Node.js applications because it directly enables remote code execution. The Claude package's fix—replacing execSync() with execFileSync() and using argv arrays—demonstrates the correct way to spawn subprocesses securely.
The key lesson: avoid the shell when executing user-influenced commands. By using execFileSync() with an argv array, you eliminate an entire class of attacks without requiring complex input validation or sanitization logic. This is a perfect example of how secure APIs and secure-by-default design prevent vulnerabilities more effectively than trying to filter malicious input.
If you're maintaining Node.js packages or applications, audit your codebase for similar patterns. Look for execSync(), exec(), or spawn() calls with shell: true. Replace them with execFileSync() or spawn() using argv arrays. Use static analysis tools like Semgrep to catch these patterns before they reach production.
Security isn't about perfect input validation—it's about choosing APIs and architectures that make exploitation impossible in the first place.