Skip to content

Latest commit

 

History

History
290 lines (208 loc) · 11.5 KB

File metadata and controls

290 lines (208 loc) · 11.5 KB

Lab: Command Injection

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.


Objective

  • 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 with proc_open() + an argument array.
  • Recognise the blue-team signals that command injection leaves behind.

Prerequisites

  • PHP 8.2 or 8.3 CLI with the built-in web server.
  • A Linux/macOS shell (the payloads assume a POSIX sh; Windows cmd.exe behaves differently).
  • Familiarity with HTTP query strings and curl.
  • Basic understanding that PHP's system/exec/shell_exec pass their argument to /bin/sh -c.

Lab Setup

Create a working directory and the vulnerable endpoint.

mkdir -p ~/labs/cmdi && cd ~/labs/cmdi

vuln.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:8000

A benign request should behave like a real ping:

curl -s 'http://127.0.0.1:8000/vuln.php?host=127.0.0.1'

Walkthrough

1. Confirm normal behaviour

The request above returns ping statistics for 127.0.0.1. So far the tool looks legitimate.

2. Exploit — inject a second command

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.

3. Exfiltrate a file

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.

4. Root cause

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.

5. Fix A — validate the input

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.

6. Fix B — escape the argument

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.

7. Fix C — remove the shell entirely (preferred)

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.


Exercises

  1. Prove escaping works. Point your exploit payloads at the Fix B endpoint and capture the raw output. Explain in one sentence why ;id no longer executes.
  2. Break option injection. Against the Fix B endpoint (validation removed), send host=-f or host=-c100000 and observe. Then restore Fix A and show it is rejected.
  3. Newline bypass. Some naive filters block ; and & but not the newline character %0a. Add a filter that strips only ; and & to a copy of vuln.php, then defeat it with a URL-encoded newline. What does this teach about deny-lists?
  4. Port the array fix. Rewrite an app that calls system('convert ' . $file . ' out.png') (ImageMagick) using the proc_open() array form.

Challenge

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.


Solution

[!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/x is stopped by -- plus host validation; the 60-second hang is killed by the proc_terminate() deadline. The hops value is forced through ctype_digit and an (int) cast so no non-numeric token survives.


Security Discussion

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.


References


Related

  • 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_exec sinks.