Skip to content
pwnsy
web-securityintermediate#command-injection#web-security#owasp#rce#appsec

Command Injection: Shell Metacharacters & Safe APIs

How OS command injection breaks out of an intended command via shell metacharacters, and why argument-array APIs beat string concatenation.

Some applications need to run an operating-system command: ping a host, convert an image, look up a DNS record, generate a PDF with an external tool. If any part of that command is built from user input and handed to a system shell, the application has almost certainly opened a door to running attacker-chosen commands on the server. That is OS command injection, and when it is present, it usually means full server compromise.

The bug is a cousin of SQL injection. Both come from mixing untrusted data into a language the machine will interpret, and both are solved the same way in principle: keep the data and the instructions in separate lanes so the data can never become an instruction. With commands, the interpreter is the shell, and the separation is achieved by not invoking a shell at all.

The core mechanism

Say an application lets a user check whether a host is reachable, and it implements that by running the system ping command with the hostname the user typed. If the code builds a command string and passes it to a shell, it might look like this conceptually:

ping -c 1 <user_input>

Supply a normal hostname and it works. Supply this instead:

example.com; cat /etc/passwd

and the shell sees two commands separated by a semicolon. It runs the ping, then runs cat /etc/passwd. The application asked for one command. The shell, doing exactly what shells do, ran two. The attacker's command executes with the privileges of the application process.

The reason this works is that the shell is a language interpreter. When you hand it a string, it parses that string for structure: separators, pipes, substitutions, redirections. It cannot tell that the hostname was supposed to be inert data. To the shell it is all one program to parse and run.

It is worth being precise about where the trust boundary sits. The application code is not the interpreter. The application builds a string and hands it off, and the interpretation happens in the shell, one layer down, where the developer's intent is invisible. That gap is the entire vulnerability. The shell has no concept of "this part was a hostname the user typed and that part was my command template." It sees one line of shell script and executes it faithfully. Any mental model that treats the input as data right up until the shell receives it is misleading, because the moment the shell parses the line, the input has become code.

The metacharacters that break out

The attacker's toolkit is the set of characters the shell treats as syntax rather than text:

CharacterEffect in a shell
;Ends one command, starts the next
&& / ||Run next command on success / on failure
|Pipe output of one command into another
` or $( )Command substitution: run this and insert its output
&Run the command in the background
newlineActs as a command separator
> <Redirect output or input to a file

Any one of these, if it reaches the shell inside user input, is a potential breakout. This is also why character blocklists are a losing game: the list is long, shells differ, and encoding or alternate syntax routinely slips past filters.

Argument execution versus shell execution

The single most important distinction in this whole topic is how the command gets launched. There are two ways, and they behave completely differently.

Shell execution hands a full command string to a shell to parse and run. The shell interprets metacharacters. This is the vulnerable path. Functions and patterns that spawn "a command line" or run "a shell command" fall here.

Direct (argument-array) execution launches a named program with an explicit array of arguments and no shell in between. The operating system starts the program and passes the arguments straight to it. There is no shell to parse metacharacters, so a semicolon in an argument is just a semicolon: a literal character delivered to the program as data. The ping program receives example.com; cat /etc/passwd as a single hostname argument, fails to resolve it, and does nothing dangerous.

The lesson writes itself. When you must run an external program, call it directly with a fixed program name and an argument array, and never route user input through a shell. This is the primary fix, and it is close to absolute, because it removes the interpreter the attack depends on.

Do not let a shell back in through the side door

Switching to an argument-array API only helps if no shell sneaks back in. Some execution functions run a shell when you pass a single string but skip it when you pass an array; some invoke a shell whenever a certain flag is set. Passing a shell path as the program, or setting a shell option, reintroduces the exact vulnerability you were trying to remove. Confirm that your chosen API does not spawn a shell, and never build the argument list by concatenating user input into one string.

Blind command injection

As with other injection classes, the output is not always returned. In blind command injection the server runs your command but shows you nothing. It is still exploitable through side channels.

  • Time-based detection. Inject a command that sleeps for a fixed number of seconds. If the response takes that much longer, the injection is real. This confirms execution with no output at all.
  • Out-of-band callbacks. Inject a command that makes the server perform a DNS lookup or HTTP request to a host the attacker controls. When the callback arrives, execution is proven, and the same channel can carry stolen data out by embedding it in the hostname or path.

Blind command injection is slower to weaponize and just as serious, because confirmed execution of one command means the attacker can run any command.

There is also a class between visible and fully blind: the application runs your command and returns an error or a status but not the command's output. Even here, an attacker can often coax data out by redirecting a command's output into a file inside the web root and then requesting that file over HTTP, or by making the difference between success and failure depend on the answer to a yes-or-no question about the system. The recurring lesson is that the absence of visible output is not the absence of a channel. Once execution is confirmed, the attacker's problem is only ever how to read the result, and there are many ways to solve that.

How to defend against command injection

The controls are ordered by strength. The first one is the real fix; the rest reduce risk when the first cannot fully apply.

  1. Do not call the shell. Where you must run an external program, use the direct argument-array API for your language, passing a fixed program name and each argument as a separate array element. This is the defense that ends the vulnerability.
  2. Better still, avoid shelling out at all. Most tasks people spawn commands for, DNS lookups, file operations, image processing, have a native library that does the job in-process with no command line involved. Prefer it.
  3. Allowlist the input, not the characters. When input feeds a command, constrain it to a strict set of acceptable values or a tight pattern (for example, a valid hostname format), and reject everything else. Allowlisting the shape of valid input beats trying to enumerate every dangerous character. See our input validation guide for how to build these checks.
  4. Run with least privilege. The application process should have the minimum operating-system permissions it needs, so that even a successful injection lands somewhere constrained rather than as a high-privilege user.
  5. Never build commands by string concatenation. If you find user input being glued into a command string anywhere in the codebase, treat it as a finding regardless of the filtering around it.
Same root cause as SQL injection

Command injection and SQL injection are the same disease with different interpreters. Both come from mixing untrusted data into text that a machine will parse as instructions, and both are cured by keeping data out of the instruction stream: parameterized queries for SQL, argument arrays for commands. If your team already treats string-built SQL as forbidden, extend the exact same rule to string-built shell commands.

Because a single confirmed command means the attacker can run anything the process can, command injection is a favorite pivot for delivering further payloads, and the delivery often arrives as a crafted file or parameter. Our Exploit Intelligence dashboard tracks which command-injection and remote-code-execution flaws are under active exploitation, so you can see which of these bugs are being used right now rather than treating every advisory identically.

Where the input hides

The obvious injection point is a form field that clearly feeds a command, such as a hostname box behind a ping tool. The dangerous ones are the inputs nobody thought of as reaching a shell. Command injection follows the data, so the vulnerability can sit far from where the value entered the system.

  • Filenames and upload metadata. An uploaded file's name, or a field inside its metadata, may be passed to an external tool for conversion or inspection. A filename like photo.jpg; id reaches a shell if the processing step builds a command string around it.
  • HTTP headers. A User-Agent, Referer, or X-Forwarded-For value that gets logged and then fed to a shell command for analysis is attacker-controlled text arriving through a channel the developer rarely treats as input.
  • Values from a database. Input stored earlier and used later is still input. A value that was safe to store becomes dangerous the moment it is concatenated into a command, which is why the trust boundary is the shell call, not the original request.
  • Fields inside structured formats. A name inside an XML or JSON document, a value pulled from a configuration file, or an entry from a directory service can all reach a shell if some downstream step shells out with them.
  • Indirect parameters. An option flag, a format string, or a file path passed to an external tool can be abused even without classic separators, by smuggling in the tool's own dangerous options. This is argument injection, a close relative covered below.

The recurring lesson is that you cannot secure command execution by guarding one field. You secure it by never sending any untrusted value through a shell, wherever that value came from.

Argument injection: the quieter cousin

Switching to an argument-array API removes the shell, which stops the classic metacharacter breakout. It does not automatically stop a subtler problem: an attacker who controls one argument can sometimes abuse the target program's own options.

Suppose an application runs a known program with an argument array and inserts a user-supplied value as one argument. No shell is involved, so a semicolon in that value is inert. But if the user-supplied value begins with a dash, the program may interpret it as an option rather than as data. Many command-line tools have options that read or write files, execute helpers, or change behavior in ways an attacker can exploit. Feeding such a tool an argument like --output=/path/to/somewhere or an option that triggers command execution turns a single controllable argument into a foothold, all without any shell metacharacter.

The defenses are specific. Where a tool supports it, use the -- separator that marks the end of options, so everything after it is treated as a positional argument rather than a flag. Validate that a value which should be a hostname, a filename, or a number actually matches that shape before it becomes an argument. And prefer passing data through mechanisms that cannot be confused with options, such as standard input or an explicit file argument you construct yourself. Argument injection is why "I used an argument array" is necessary but not the whole story when the argument value is attacker-controlled.

How to detect command injection

Finding these bugs combines reading code for the dangerous pattern with probing a running system for its side effects.

  • Search the codebase for shell-invoking calls. Grep for the functions and patterns in your language that spawn a shell or run a command line, then check whether any argument is built from input. Concatenation of a variable into a command string is the signature to hunt for. This weakness is catalogued as CWE-78, Improper Neutralization of Special Elements used in an OS Command.
  • Timing probes. Inject a value that, if it reached a shell, would run a command that sleeps for a fixed number of seconds. A response that consistently slows by that amount confirms execution even when nothing is returned. Vary the delay to rule out coincidence.
  • Out-of-band callbacks. Inject a value that would make the server perform a DNS lookup or HTTP request to a host you control. An arriving callback proves execution, and the hostname or path can carry small pieces of data out. This works precisely when the response body shows nothing.
  • Differential responses. Where output is hidden but an error or status leaks, make the success or failure of the request depend on the answer to a yes-or-no question about the system, then read the answer from the difference in responses.
  • Output redirected into the web root. In a visible-filesystem setup, an attacker can redirect a command's output into a file under the web root and then request that file over HTTP. Seeing such a request pattern in logs, a write into a served directory followed by a fetch, is a strong signal.

The unifying idea for detection is that confirmed execution of even one harmless command, a sleep or a network callback, proves the vulnerability, because a process that will run one attacker-chosen command will run any of them.

Several vulnerability classes get grouped under "injection," and separating them clarifies both the mechanism and the fix.

VulnerabilityInterpreter abusedPrimary fix
OS command injectionThe system shellArgument-array execution, no shell
Argument injectionThe target program's option parserEnd-of-options --, shape validation
SQL injectionThe database query plannerParameterized queries
Code injectionA language interpreter, via eval-like callsNever evaluate untrusted input as code
Server-side template injectionA template engineKeep user input out of template source

The pattern across the whole table is one disease with different interpreters. Each bug comes from untrusted data reaching a component that parses text as instructions, and each is cured by keeping the data out of the instruction stream for that specific interpreter. If your team already forbids string-built SQL, the same reasoning extends directly to string-built shell commands, template source, and code passed to an evaluator.

Common mistakes

Blocklisting characters instead of allowlisting input. Enumerating dangerous characters is a losing game: the list is long, shells differ, and encoding or alternate syntax routinely slips past. Constrain input to a strict shape of what is valid and reject everything else.

Assuming an argument array is automatically safe. It removes the shell, which is the main fix, and it does not stop argument injection when the attacker-controlled value can pose as an option. Add the end-of-options marker and validate the value's shape.

Escaping instead of separating. Trying to escape metacharacters so a string is safe to hand to a shell is fragile and easy to get wrong across shells and locales. Removing the shell entirely is both simpler and more reliable than escaping for it.

Trusting stored or second-hand input. A value that was safe to store is still untrusted when it is later concatenated into a command. The trust boundary is the shell call, wherever the value came from.

Treating hidden output as safety. No visible output does not mean no channel. Timing, out-of-band callbacks, and redirected files all exfiltrate results from blind injection. The absence of output is not the absence of exploitation.

Frequently asked questions

What is OS command injection in one sentence? It is a vulnerability where user input is placed into a command that a system shell runs, letting an attacker append their own commands to the one the application intended and execute them with the application's privileges.

Why is calling a shell the root cause? The shell is a language interpreter. When it receives a string it parses that string for structure, treating characters like ;, |, and backticks as separators and substitutions rather than as data. The application cannot mark part of the string as inert, so once the shell parses the line the injected text has become code. Removing the shell removes the interpreter the attack depends on.

Does input validation alone fix command injection? Validation reduces risk and is worth doing, and it is not the primary fix on its own. Attackers evade character blocklists with encoding and alternate syntax, and validation logic can have gaps. The reliable fix is to run programs directly with an argument array so no shell parses the input, with allowlist validation layered on top.

What is the difference between command injection and SQL injection? They are the same class of bug with different interpreters. Command injection abuses the system shell; SQL injection abuses the database query planner. Both come from mixing untrusted data into text a machine parses as instructions, and both are cured by keeping data out of the instruction stream: argument arrays for commands, parameterized queries for SQL.

How do I exploit or confirm blind command injection with no output? Confirm it through side channels. Inject a command that sleeps for a fixed time and watch for a matching delay in the response, or inject one that makes the server call out to a host you control and watch for the DNS or HTTP callback. Either result proves execution without any output, and the callback channel can also carry data out.

Is switching to an argument-array API always enough? It ends the classic shell-metacharacter attack, which is the main win. Watch for two residual issues: a shell sneaking back in because some APIs spawn one when passed a single string or a shell flag, and argument injection where a controllable value poses as an option to the target program. Confirm no shell is invoked, use the end-of-options marker, and validate argument shapes.

How severe is command injection compared to other web bugs? It sits among the most severe because a single confirmed command usually means the attacker can run any command the application process can, which is the definition of remote code execution. From there an attacker can read secrets, move deeper into the network, install persistence, and pivot to other systems. That is why it is treated as a top-severity finding whenever it is present, and why the direct fix of removing the shell is worth prioritizing over partial mitigations.

Why prefer a native library over shelling out at all? Most tasks that people spawn commands for, such as DNS lookups, file operations, and image processing, have an in-process library that does the same job with no command line involved. Using it removes the shell, the argument-parsing surface, and the process-spawn overhead in one move. Shelling out should be the last resort, reserved for tools with no library equivalent, and even then only through an argument array.

Command injection is severe but genuinely avoidable. The whole vulnerability rests on handing user input to a shell, so the whole fix is to stop doing that: run programs directly with argument arrays, prefer native libraries over shelling out, allowlist the input, and drop privileges. Do those and the metacharacters that would have broken out have nothing left to break out of.

Sources & further reading

Sharetwitterlinkedin

Related guides