This lab walks you through building a small "network diagnostics" PHP endpoint that shells out to the system ping command, exploiting its OS command injection flaw to run arbitrary commands, and then hardening it by removing the shell entirely or, where a shell is unavoidable, escaping arguments correctly.
- Understand how untrusted input reaches a shell interpreter through
shell_exec,exec,system, and backticks. - Exploit an OS command injection vulnerability to read arbitrary files and run arbitrary commands.
- Fix the flaw with three layered techniques: strict input validation,
escapeshellarg(), and eliminating the shell withproc_open()+ an argument array. - Recognise the blue-team signals that command injection leaves behind.
- PHP 8.2 or 8.3 CLI with the built-in web server.
- A Linux/macOS shell (the payloads assume a POSIX
sh; Windowscmd.exebehaves differently). - Familiarity with HTTP query strings and
curl. - Basic understanding that PHP's
system/exec/shell_execpass their argument to/bin/sh -c.
Create a working directory and the vulnerable endpoint.
mkdir -p ~/labs/cmdi && cd ~/labs/cmdivuln.php — the deliberately broken diagnostics tool:
<?php
declare(strict_types=1);
// VULNERABLE: user input is concatenated straight into a shell command line.
$host = $_GET['host'] ?? '127.0.0.1';
// VULNERABLE: shell_exec sends this whole string to /bin/sh -c
$output = shell_exec('ping -c 1 ' . $host);
header('Content-Type: text/plain');
echo "Pinging {$host}...\n\n";
echo $output ?? '(no output)';Launch the built-in server:
php -S 127.0.0.1:8000A benign request should behave like a real ping:
curl -s 'http://127.0.0.1:8000/vuln.php?host=127.0.0.1'The request above returns ping statistics for 127.0.0.1. So far the tool looks legitimate.
Because $host lands inside /bin/sh -c, shell metacharacters are honoured. The ; terminator chains an extra command:
curl -s 'http://127.0.0.1:8000/vuln.php?host=127.0.0.1;id'You will see the ping output followed by the output of id — the web server's UID. Other separators work identically: &&, ||, a pipe |, or command substitution $(...) / backticks.
Read something you should never be able to reach:
curl -s 'http://127.0.0.1:8000/vuln.php?host=127.0.0.1;cat%20/etc/passwd'%20 is a URL-encoded space. The response now contains /etc/passwd. Swap in any command — uname -a, env, or a reverse shell — to appreciate the blast radius: full code execution as the web user.
The application treats input as code (a shell command line) instead of data (a hostname argument). Any character with meaning to /bin/sh — ; | & $ ` > < ( ) newline — changes the command's structure.
A hostname or IP has a narrow, well-defined shape. Reject anything else before it ever reaches a command.
<?php
declare(strict_types=1);
$host = $_GET['host'] ?? '127.0.0.1';
// Allow-list: an IPv4/IPv6 address, or a DNS hostname label set.
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
$isName = preg_match('/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.?)+$/', $host) === 1;
if (!$isIp && !$isName) {
http_response_code(400);
exit("Invalid host.\n");
}Validation is necessary but should never be your only defence — allow-lists drift and get copied into places that forget them.
When you still hand a string to a shell, wrap every user-controlled token in escapeshellarg(). It single-quotes the value and neutralises embedded quotes so the shell sees one literal argument.
<?php
declare(strict_types=1);
$host = $_GET['host'] ?? '127.0.0.1';
// ...validation from Fix A here...
// escapeshellarg quotes the value so ; | $() etc. are inert.
$cmd = 'ping -c 1 ' . escapeshellarg($host);
$output = shell_exec($cmd);
header('Content-Type: text/plain');
echo $output ?? '(no output)';Now 127.0.0.1;id becomes the literal argument '127.0.0.1;id', which ping simply fails to resolve — no second command runs. Note: escapeshellarg() protects argument injection, not option injection (a value like --help or -f), which is why validation in Fix A still matters.
The strongest fix is to never invoke /bin/sh. proc_open() with an array command executes the binary directly via execvp, so no shell parses metacharacters at all.
<?php
declare(strict_types=1);
$host = $_GET['host'] ?? '127.0.0.1';
if (filter_var($host, FILTER_VALIDATE_IP) === false
&& preg_match('/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.?)+$/', $host) !== 1) {
http_response_code(400);
exit("Invalid host.\n");
}
// Array form: no /bin/sh involved; each element is a distinct argv entry.
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open(
['ping', '-c', '1', '--', $host],
$descriptors,
$pipes
);
if (!is_resource($process)) {
http_response_code(500);
exit("Could not start ping.\n");
}
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
header('Content-Type: text/plain');
echo $stdout;The -- end-of-options marker also defeats option injection. Re-run every payload from steps 2–3 against this version: they are inert.
- Prove escaping works. Point your exploit payloads at the Fix B endpoint and capture the raw output. Explain in one sentence why
;idno longer executes. - Break option injection. Against the Fix B endpoint (validation removed), send
host=-forhost=-c100000and observe. Then restore Fix A and show it is rejected. - Newline bypass. Some naive filters block
;and&but not the newline character%0a. Add a filter that strips only;and&to a copy ofvuln.php, then defeat it with a URL-encoded newline. What does this teach about deny-lists? - Port the array fix. Rewrite an app that calls
system('convert ' . $file . ' out.png')(ImageMagick) using theproc_open()array form.
Build a traceroute endpoint that accepts a host and an optional integer max-hop count (-m). Requirements: no /bin/sh invocation anywhere, both parameters validated with allow-lists, a 5-second wall-clock timeout enforced on the child process, and the raw command logged for audit. Demonstrate that none of ;, $(...), -f/tmp/x, or a 60-second hang can get through.
[!success]- Solution
<?php declare(strict_types=1); $host = (string)($_GET['host'] ?? ''); $hops = (string)($_GET['hops'] ?? '15'); // Allow-list both inputs. $hostOk = filter_var($host, FILTER_VALIDATE_IP) !== false || preg_match('/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.?)+$/', $host) === 1; $hopsOk = ctype_digit($hops) && (int)$hops >= 1 && (int)$hops <= 30; if (!$hostOk || !$hopsOk) { http_response_code(400); exit("Invalid parameters.\n"); } // Array argv: no shell parses metacharacters. -- stops option injection. $argv = ['traceroute', '-m', (string)(int)$hops, '--', $host]; error_log('traceroute exec: ' . implode(' ', $argv)); // audit log $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $process = proc_open($argv, $descriptors, $pipes); if (!is_resource($process)) { http_response_code(500); exit("Could not start traceroute.\n"); } stream_set_blocking($pipes[1], false); $deadline = microtime(true) + 5.0; // wall-clock timeout $out = ''; while (microtime(true) < $deadline) { $status = proc_get_status($process); $out .= stream_get_contents($pipes[1]); if (!$status['running']) { break; } usleep(100_000); } $status = proc_get_status($process); if ($status['running']) { proc_terminate($process, 9); // kill the 60s hang http_response_code(504); $out .= "\n[timed out]\n"; } foreach ($pipes as $p) { if (is_resource($p)) { fclose($p); } } proc_close($process); header('Content-Type: text/plain'); echo $out;
;and$(...)are never interpreted because no shell runs;-f/tmp/xis stopped by--plus host validation; the 60-second hang is killed by theproc_terminate()deadline. Thehopsvalue is forced throughctype_digitand an(int)cast so no non-numeric token survives.
Root cause. Command injection is a failure to separate code from data at the shell boundary — the same class of bug as SQL injection, but the interpreter is /bin/sh instead of a database engine. Passing a string to system/exec/shell_exec/popen/backticks always spawns a shell that re-parses metacharacters.
Defence-in-depth. Layer, do not choose: (1) design out the shell — call the binary directly with an argv array via proc_open(), or better use a native PHP/library function so no subprocess exists at all; (2) validate every input against a strict allow-list of shape and range; (3) if a string command is truly unavoidable, wrap arguments in escapeshellarg() and commands in escapeshellcmd(), and add -- to stop option injection; (4) run the web process as a low-privilege user with a restrictive disable_functions (exec,system,shell_exec,passthru,proc_open,popen) where the app genuinely needs no subprocesses.
Blue-team detection. Watch for web-server child processes that should never exist — id, whoami, cat, sh, curl, wget, nc parented by php-fpm/apache. Auditd execve rules and EDR process-tree telemetry surface these immediately. Log the exact argv you execute (as in the challenge) so anomalous arguments are reviewable. WAF rules catching ;, |, $( and encoded newlines in parameters give an early signal but are bypass-prone and must never be the primary control.
- OWASP — OS Command Injection: https://owasp.org/www-community/attacks/Command_Injection
- OWASP Cheat Sheet — OS Command Injection Defense: https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
- PHP Manual —
escapeshellarg(): https://www.php.net/manual/en/function.escapeshellarg.php - PHP Manual —
proc_open(): https://www.php.net/manual/en/function.proc-open.php - CWE-78 — Improper Neutralization of Special Elements used in an OS Command: https://cwe.mitre.org/data/definitions/78.html
- Command-Injection — the concept note this lab operationalises with runnable PHP.
- Local-File-Inclusion — sibling injection class where untrusted input steers a file path instead of a shell.
- SQL-Injection-Prevention — same code-vs-data root cause at the database boundary.
- File-Upload-Security — uploaded files are a common source of the tainted paths fed to shell tools.
- Docker-for-PHP — run the vulnerable and fixed endpoints in a throwaway container with a low-privilege user.
- PHPStan — static analysis can flag tainted data reaching
exec/shell_execsinks.