Back to Blog
high SEVERITY8 min read

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

A high-severity command injection vulnerability was discovered in `bin/cli.js` where user-controlled input was passed directly to `execSync()` without sanitization, potentially allowing attackers to execute arbitrary shell commands. The fix replaces shell-based execution with `execFileSync()` using an argv array, eliminating the attack surface entirely. This proactive hardening prevents exploitation of the Claude plugin marketplace install/update/uninstall functionality.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js where user input passed to `execSync()` in `bin/cli.js` could be executed as shell commands. The fix replaces `execSync()` with `execFileSync()` and uses an argv array instead of string concatenation, preventing shell interpretation of special characters. This approach eliminates the need for input sanitization by removing the shell entirely from the execution path.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace `execSync()` with `execFileSync()` using argv array; resolve executable path on Windows
riskRemote code execution if user input reaches child_process calls
languageJavaScript (Node.js)
root causeUser-controlled `nameWithVersion` passed to `execSync()` without shell escaping
vulnerabilityCommand Injection via Child Process

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:

  1. execFileSync() vs. execSync(): execFileSync() spawns a process directly without invoking a shell. It does not interpret shell metacharacters.

  2. 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.

  3. Platform-Aware Executable Resolution: The new resolveExecutableForPlatform() function handles Windows-specific quirks. On Windows, execFileSync() doesn't automatically apply PATHEXT, so the code now explicitly resolves claude.cmd on 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.


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 .cmd shim resolution because execFileSync() doesn't apply PATHEXT on Windows like execSync() 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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #388

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

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

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.

high

How command injection happens in JavaScript/Node.js and how to fix it

A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.