Command Injection

When AI-generated code gives attackers shell access to your server

Quick Answer

Command injection lets attackers run arbitrary system commands when your app passes user input to shell functions like exec(). AI tools often generate exec(`command ${userInput}`) patterns. Replace exec() with execFile() or spawn() using array arguments. OWASP A03:2021 Injection.

Command Injection Severity

8.8-9.9 CVSS Score
6.21% Copilot Code
#3 OWASP Top 10
RCE Impact

Sources: MITRE CWE-78, CISA, GitHub Copilot Research

What is command injection?

Command injection happens when attackers hijack your shell commands to run their own code on your server. By inserting shell metacharacters like ;, |, or &&, they break out of your intended command and execute arbitrary programs.

For example, if your code runs ping ${userIP} and an attacker sends 8.8.8.8; cat /etc/passwd, the shell runs both ping AND the cat command. This is ranked #3 in OWASP Top 10 under the Injection category.

The impact is severe: attackers gain complete server takeover, data exfiltration, and can deploy ransomware. CISA rates command injection with CVSS scores of 8.8-9.9 (Critical).

How do shell metacharacters enable attacks?

Shell metacharacters are special symbols that shells (bash, sh) interpret as commands rather than text. When user input contains these characters, the shell executes unintended commands:

; Command separator ping; rm -rf /
| Pipe ping | cat /etc/passwd
&& Chain on success ping && whoami
$() Command substitution ping $(whoami)
> Redirect output echo data > /etc/cron.d/backdoor
` Backticks ping `whoami`

The attacker doesn't just control arguments - they control the entire command. This is why command injection is so dangerous compared to other injection types.

Why do AI tools generate vulnerable shell commands?

Vibe coding with AI tools often produces command injection vulnerabilities because AI generates what's common in training data. When you prompt "run a shell command" or "ping a server", AI naturally reaches for exec() with template literals.

Academic research on GitHub Copilot security found that 6.21% of generated code contains CWE-78 command injection vulnerabilities. AI tools don't distinguish between trusted and untrusted input - they generate readable, functional code that happens to be insecure.

The pattern exec(`command ${variable}`) is cleaner than array-based alternatives, so it appears frequently in vibe coded projects. Cursor and Claude Code exhibit similar patterns when asked to execute system commands.

How does exec() become a security trap?

child_process.exec() is a bash interpreter, not a program launcher. Every character in the command string passes through shell interpretation. Here's the vulnerable pattern AI tools generate:

Vulnerable - AI-Generated exec()
// VULNERABLE: Common AI-generated pattern
const { exec } = require('child_process')

app.get('/ping', (req, res) => {
  const host = req.query.host

  // Attacker sends: host=8.8.8.8; cat /etc/passwd
  exec(`ping -c 4 ${host}`, (error, stdout) => {
    res.send(stdout)
  })
})

What happens with malicious input:

  1. exec() spawns a shell (bash/sh)
  2. Shell interprets ; as command separator
  3. Attacker's command runs with your app's privileges
  4. Output of cat /etc/passwd returned to attacker

What's the difference between exec, execFile, and spawn?

FunctionUses ShellSafe by DefaultUse When
exec()YesNoNever with user input
execFile()No*YesRunning specific binary
spawn()No*YesStreaming output

*Unless shell: true option is set

exec() - DANGEROUS
// Shell interprets everything
exec(`ls -la ${userPath}`)
execFile() - SAFE
// Arguments passed directly
execFile('ls', ['-la', userPath])

The key difference: execFile() and spawn() pass arguments directly to the program without shell interpretation. Metacharacters become literal text, not commands. Read the Auth0 guide for more depth.

Pattern 1: Safe alternative with execFile()

Vulnerable - exec with user input
const { exec } = require('child_process')

app.get('/fileinfo', (req, res) => {
  const filename = req.query.file
  // INJECTION: ; cat /etc/shadow
  exec(`ls -la ${filename}`, (err, stdout) => {
    res.send(stdout)
  })
})
Secure - execFile with validation
const { execFile } = require('child_process')
const path = require('path')

const ALLOWED_DIR = '/safe/directory'

app.get('/fileinfo', (req, res) => {
  const filename = req.query.file

  // Validate: only allow specific directory
  const safePath = path.join(
    ALLOWED_DIR,
    path.basename(filename)
  )

  // Verify path didn't escape
  if (!safePath.startsWith(ALLOWED_DIR)) {
    return res.status(403).send('Access denied')
  }

  // execFile: args are NOT shell-interpreted
  execFile('ls', ['-la', safePath], (err, stdout) => {
    if (err) {
      return res.status(500).send('Error listing file')
    }
    res.send(stdout)
  })
})

Always combine execFile() with input validation. Use path.basename() to strip directory traversal, and verify the resolved path stays within allowed directories. This protects against both command injection and path traversal.

Pattern 2: Safe spawn() with streaming

Vulnerable - exec for long-running command
app.get('/backup', (req, res) => {
  const dbName = req.query.db
  // INJECTION: ; rm -rf /
  exec(`pg_dump ${dbName}`, (err, stdout) => {
    res.send(stdout)
  })
})
Secure - spawn with allowlist
const { spawn } = require('child_process')

const ALLOWED_DATABASES = ['users', 'products', 'orders']

app.get('/backup', (req, res) => {
  const dbName = req.query.db

  // Allowlist validation - only known values
  if (!ALLOWED_DATABASES.includes(dbName)) {
    return res.status(400).send('Invalid database')
  }

  const backup = spawn('pg_dump', [dbName])

  backup.stdout.pipe(res)

  backup.stderr.on('data', (data) => {
    console.error('Backup error:', data.toString())
  })

  backup.on('error', (err) => {
    res.status(500).send('Backup failed')
  })
})

spawn() is ideal for streaming large outputs. Always use allowlist validation when possible - don't rely on sanitization alone. If the value isn't in your list of known-good values, reject it.

What dangerous patterns should I search for?

Search your vibe coded projects for these patterns. Each one is a potential command injection vulnerability:

Dangerous Patterns to Find
// Pattern 1: Template literals in exec
exec(`command ${userInput}`)
exec(`command ${req.query.param}`)
exec(`command ${req.body.value}`)

// Pattern 2: String concatenation
exec('command ' + userInput)
exec('ls -la ' + filename)

// Pattern 3: Entire command from user
exec(userCommand)
exec(`${userCommand}`)

// Pattern 4: Array joined into command
const options = req.body.options
exec(`tool ${options.join(' ')}`)  // Array could contain ;

Use Semgrep or eslint-plugin-security to automatically detect these patterns in your codebase.

AI Fix Prompt for Command Injection

Copy this prompt to scan your vibe coded project for command injection vulnerabilities:

Review my code for command injection vulnerabilities (CWE-78):

1. **Find exec() with user input**: Search for child_process.exec,
   execSync, or require('child_process').exec that includes:
   - req.query, req.body, req.params values
   - Template literals with variables
   - String concatenation with user data

2. **Replace with safe alternatives**:
   - Use execFile() or spawn() instead of exec()
   - Pass arguments as array, not string
   - Never use shell: true with user input

3. **Validate all input**:
   - Use allowlists for command names
   - Validate/sanitize arguments (alphanumeric only)
   - Use path.basename() for filenames
   - Verify paths stay within allowed directories

4. **Check for indirect injection**:
   - Arrays that get joined into commands
   - Config values that come from user input
   - Environment variables set from requests

For each vulnerability:
- Show the dangerous code
- Show the secure replacement using execFile/spawn
- Note if allowlist validation is also needed

Frequently Asked Questions

What is command injection?

Command injection is a vulnerability where attackers insert shell metacharacters (;, |, &&, $()) into user input to execute arbitrary system commands. When your app passes unsanitized input to exec(), attackers can run any command with your app's privileges - enabling complete server takeover.

Why is exec() dangerous in Node.js?

exec() spawns a shell (bash/sh) that interprets metacharacters. When user input contains ; or |, the shell treats them as command separators or pipes. exec("ping " + userIP) with userIP="; rm -rf /" executes both ping AND the destructive command.

How do I safely execute shell commands?

Use execFile() or spawn() instead of exec(). These pass arguments directly to the program without shell interpretation. Pass arguments as arrays: execFile("ls", ["-la", userPath]) instead of exec("ls -la " + userPath). Never use shell: true with user input.

What's the difference between exec and execFile?

exec() spawns a shell that interprets the command string including metacharacters. execFile() runs the program directly, passing arguments as an array. execFile("ls", ["-la", path]) treats the entire path as one argument - metacharacters aren't interpreted.

Can spawn() be vulnerable to command injection?

spawn() is safe by default because it doesn't use a shell. However, if you set shell: true, spawn becomes as dangerous as exec(). Also, some programs interpret arguments as commands (find -exec), so always validate input even with spawn().

Related Content

Find Command Injection in Your Code

VibeShip Scanner detects exec() with user input, missing validation, and other shell command vulnerabilities.

Scan Your Code Free