Command Injection

Command Injection: How One Semicolon Hands Over Your Server

OS command injection turns a harmless-looking parameter into a shell prompt for an attacker. Here is how shell metacharacters escape into system commands, the blind variants, and the fix that ends it.

SelfSec Team7 min read
Part 1 of 3from theCommand Injectionseries
; id

At a glance

CWE-78
Interpreter
Operating-system shell
Required condition
Attacker input reaches a shell command string as syntax rather than one fixed argument.
Potential impact
Arbitrary commands run with the application process's operating-system privileges.
Primary defense
Use a native library or direct process API with separate validated arguments.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

Command injection happens when an application builds an operating-system command out of untrusted input and hands it to a system shell. Because the shell interprets certain characters as control syntax, an attacker can use them to break out of the intended command and append their own. The injected commands then run with the privileges of the web process — which is usually enough for file access, data theft, network pivoting and full server takeover.

It is a specific and especially common path to remote code execution. The distinguishing feature is the vector: the OS command shell itself, rather than an application-level interpreter. If you have ever shelled out to ping, convert, ffmpeg or git with a value the user supplied, this is the bug to understand.

The mental model: two parsers, two trust boundaries

Command handling often involves two different parsers. First a shell may interpret separators, substitutions, redirections and quoting. Then the selected program parses its own options and operands. Removing the shell closes the first boundary, but the program's option parser still needs deliberately shaped arguments.

Execution path What interprets the input Main risk
shell_exec("ping " . $host) Shell, then ping New commands, pipes and substitutions
Direct process with separate arguments ping only Unexpected options or invalid operands
Native networking library Library API Input validation and resource limits

The safest design is the third row: use a DNS, image, archive or networking API that performs only the operation the feature needs. If a process is genuinely required, use the second row and validate every operand for its intended type.

How it happens

The root cause is string concatenation into a shell command. Consider a network-diagnostics endpoint that lets an admin ping a host:

$host = $_GET['host'];
$output = shell_exec("ping -c 1 " . $host);
echo "<pre>$output</pre>";

The developer expects host to be something like example.com. But the value goes straight into a string that a shell will parse, and a shell treats characters like ;, |, &&, $() and backticks as command separators and substitutions, not as part of a hostname. The input is data; the shell reads part of it as instructions.

A concrete attack

Instead of a hostname, the attacker supplies one with a separator and a second command attached:

GET /diag?host=example.com;id HTTP/1.1
Host: app.example

The string handed to the shell becomes ping -c 1 example.com;id. The shell runs the ping, reaches the semicolon, and then runs id as a completely separate command. The response carries both:

<pre>PING example.com ... 1 packets transmitted
uid=33(www-data) gid=33(www-data) groups=33(www-data)</pre>

That uid= line is the operating system answering — proof of execution. The probe id is preferred over whoami precisely because the uid=N(name) format is highly specific and almost never appears in a legitimate page. The same trick works with |id, &&id, command substitution like $(id) or `id`, and even a raw newline that terminates the line so the probe runs fresh.

When nothing comes back: blind injection

Many sinks discard the command's output. Execution still leaks through timing. The attacker injects a command that deliberately stalls and watches the clock:

GET /diag?host=example.com;sleep 5 HTTP/1.1
Host: app.example

If the response takes five seconds longer than baseline, the sleep ran. To rule out a target that is merely slow, the technique is run as a true/false pair — ;sleep 5 against ;sleep 0 — and only a reliable, repeatable delta counts. On Windows the same idea uses a platform-appropriate delay. When timing is unreliable, an authorized lab can use a unique hostname on a callback service operated for that assessment. A DNS lookup observed there proves execution with no in-band signal.

The attacker never needed to know how the command was built. They only needed the application to let a shell read their input as syntax instead of as a value.

What an attacker gains

Once arbitrary commands run on the host, the attacker has the keys to it:

  • Read and exfiltrate files the web process can access — config, secrets, source code, user data.
  • Steal credentials from environment variables, connection strings and cloud metadata endpoints.
  • Pivot into the internal network, using the server as a beachhead behind the firewall.
  • Plant persistence — web shells, cron jobs, scheduled tasks — that outlive a reboot.
  • Take over the server entirely, since the injected commands run with the application's full privileges.

Where command injection appears

The obvious sinks are diagnostic features that call ping or traceroute, but the same mistake appears around document conversion, media processing, archive extraction, source-control automation, PDF generation and backup tools. Values may arrive through filenames, headers, stored configuration or queued jobs rather than the request currently being handled.

Review both the source and the sink. A value remains untrusted if a user can influence it anywhere in its lifetime, and an execution API is dangerous if it invokes a shell explicitly or implicitly. Pay particular attention to wrappers named run, execute, system, shell, cmd or process and to helper libraries that accept a single command string.

The fix: avoid the shell

The reliable defence is to never let user input reach a shell as syntax. Two principles do the work: avoid invoking a shell at all, and where a command is genuinely needed, pass arguments as an explicit array so they can never be reinterpreted.

The vulnerable ping, fixed in PHP by validating an IP address and passing an argument array to proc_open, which supports direct process execution on current PHP versions:

$host = $_GET['host'];
if (filter_var($host, FILTER_VALIDATE_IP) === false) {
    http_response_code(400);
    exit;
}

$spec = [
    ['pipe', 'r'],
    ['pipe', 'w'],
    ['pipe', 'w']
];
$process = proc_open(['ping', '-c', '1', $host], $spec, $pipes);
$output = stream_get_contents($pipes[1]);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
echo '<pre>' . htmlspecialchars($output, ENT_QUOTES, 'UTF-8') . '</pre>';

Because $host is now a single argument and no shell parses the line, example.com;id is treated as one (invalid) hostname — there is no separator to act on. Layer these on top:

  • Allowlist the input where its shape is known — a hostname, an integer, a filename from a fixed set.
  • When a shell is truly unavoidable, escape arguments with a vetted library, never by hand.
  • Run the process with least privilege so a foothold yields as little as possible.

In C#, the same array-based approach avoids cmd /c entirely:

var psi = new ProcessStartInfo("ping")
{
    ArgumentList = { "-c", "1", host },
    UseShellExecute = false,
    RedirectStandardOutput = true
};
using var proc = Process.Start(psi);

That C# form keeps UseShellExecute disabled and adds each argument separately. It still needs the same IP or hostname validation because direct execution does not prevent argument injection or application-level abuse.

Fixes that do not hold

  • Removing only semicolons: shells have multiple separators, substitutions, redirections and newline behavior.
  • Quoting by hand: quoting rules differ across POSIX shells, cmd.exe, PowerShell and nested interpreters.
  • Escaping while still accepting options: a perfectly escaped value such as --output=file can still change what the target program does.
  • Running inside a container: isolation can reduce the blast radius, but the injected command still owns everything available inside that workload.
  • Suppressing command output: this creates blind command injection; timing and authorized callback evidence can still confirm it.

A developer review checklist

  1. Replace operating-system utilities with native libraries wherever possible.
  2. Find every execution sink and establish whether a shell is involved.
  3. Keep the executable fixed; never let a request choose it.
  4. Pass arguments separately and validate each one as an IP, hostname, path, identifier or enum.
  5. Use -- before operands where the chosen program documents end-of-options handling.
  6. Apply time, memory, output-size and process-count limits under a low-privilege account.

References

How SelfSec proves this one

Scanner behaviour
Module
Command Injection
How it probes
Separator, substitution and argument-boundary probes across both shell-backed and direct-execution paths.
How it confirms
Timing differentials and out-of-band DNS or HTTP callbacks confirm execution when the command produces no visible output.

Reported as CWE-78 · ATT&CK T1059 · SARIF 2.1.0

A reflected response alone is not enough to promote a Command Injection finding — the classical engine has to confirm the behaviour before it reaches the report. See the detection engine

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about Command Injection

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The Command Injection series

  1. 01OverviewCommand Injection: How One Semicolon Hands Over Your ServerReading
  2. 02Argument InjectionArgument Injection: Hijacking a Command Without a Shell6 min
  3. 03BlindBlind Command Injection: Proving Execution Through Time and DNS5 min

Related reading