Kurz gesagt: Command Injection in PHP verhindern Sie am sichersten, indem Sie gar keine Shell aufrufen: Viele Aufgaben erledigen PHP-Funktionen oder Bibliotheken direkt. Wo ein externes Programm nötig ist, übergeben Sie den Befehl seit PHP 7.4 als Array an proc_open(), dann startet PHP das Programm ohne Shell. Werte prüfen Sie gegen eine Erlaubnisliste, schließen Optionen mit -- ab und lassen den Prozess mit minimalen Rechten laufen.
Warum ein Systemaufruf so viel Macht hat
Bildbearbeitung, PDF-Erzeugung, Archivieren, Virenscan, Abfrage eines DNS-Eintrags: Für solche Aufgaben rufen PHP-Anwendungen gern externe Programme auf. Technisch ist das bequem, eine Zeile mit exec() oder shell_exec() genügt. Sicherheitstechnisch ist es eine der gefährlichsten Stellen im Code.
Wird ein Befehl als Text zusammengesetzt und von einer Shell ausgeführt, entscheidet die Shell, was Befehl und was Daten sind. Gelangt ein Wert von außen ungeprüft hinein, kann er die Bedeutung des Befehls verändern. Die Folge ist im schlimmsten Fall dieselbe wie bei PHP2Shell: Ausführung beliebiger Befehle auf dem Server mit den Rechten des PHP-Prozesses.
Der Unterschied: PHP2Shell beheben Sie mit einem PHP-Update. Eine Command Injection im eigenen Code bleibt nach jedem Update bestehen. Hinzu kommt, dass solche Stellen oft in Hilfsskripten stecken, die nur für Admins gedacht waren, etwa für Wartung, Diagnose oder Importe, und später ohne Schutz erreichbar bleiben.
Wo Command Injection in PHP entsteht
Gefährdet ist jede Stelle, an der PHP eine Shell oder ein Programm startet. Die Funktionen dafür sind:
| Funktion | Nutzt eine Shell? | Hinweis |
|---|---|---|
exec(), system(), passthru(), shell_exec() |
ja | Befehl als Text, Shell interpretiert ihn |
| Backtick-Operator | ja | entspricht shell_exec() |
popen() |
ja | Befehl als Text |
proc_open() mit String |
ja | Befehl als Text |
proc_open() mit Array |
nein | seit PHP 7.4, Argumente werden direkt übergeben |
mail() mit fünftem Parameter |
indirekt | zusätzliche Parameter an das Mailprogramm |
Ein typisch unsicheres Muster sieht so aus. Der Dateiname kommt aus der Anfrage und landet direkt in der Befehlszeile:
<?php
// UNSICHER: Wert aus der Anfrage wird Teil eines Shell-Befehls
$datei = $_POST['datei'];
exec('convert uploads/' . $datei . ' -resize 800x800 vorschau/' . $datei);
Erste Wahl: gar keine Shell
Der wirksamste Schutz ist, den Systemaufruf zu ersetzen. OWASP nennt das ausdrücklich als erste Verteidigungslinie. Für viele Aufgaben gibt es in PHP eigene Funktionen oder Erweiterungen:
- Verzeichnisse und Dateien:
mkdir(),copy(),rename(),unlink() - Archive:
ZipArchiveoderPharData - Bilder: GD oder Imagick als PHP-Erweiterung
- Netzwerkabfragen:
dns_get_record(),gethostbyname(), cURL - Prüfsummen:
hash_file()
Damit verschwindet die Frage, welche Zeichen eine Shell interpretiert, vollständig. Außerdem lassen sich die Funktionen für Systembefehle dann in der Konfiguration sperren, wie der Ratgeber disable_functions richtig einsetzen beschreibt.
Wenn ein externes Programm nötig ist: proc_open mit Array
Seit PHP 7.4 akzeptiert proc_open() den Befehl als Array. Laut PHP-Handbuch wird der Prozess dann direkt gestartet, ohne Umweg über die Shell, und PHP übernimmt die nötige Maskierung der Argumente. Zeichen wie Semikolon oder Pipe haben dann keine Sonderbedeutung mehr.
<?php
declare(strict_types=1);
$name = basename((string) ($_POST['datei'] ?? ''));
if (!preg_match('/^[a-z0-9_-]{1,64}\.(jpg|png)$/', $name)) {
throw new InvalidArgumentException('Ungültiger Dateiname.');
}
$befehl = ['/usr/bin/convert', '/srv/app/uploads/' . $name, '-resize', '800x800',
'/srv/app/vorschau/' . $name];
$prozess = proc_open($befehl, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($prozess)) {
throw new RuntimeException('Programm konnte nicht gestartet werden.');
}
$fehlerausgabe = stream_get_contents($pipes[2]);
$status = proc_close($prozess);
Drei Dinge machen das Beispiel robust: Das Programm steht mit vollem Pfad fest im Code, der Dateiname wird gegen ein enges Muster geprüft, und jedes variable Argument beginnt mit einem absoluten Pfad, kann also nicht als Option gelesen werden. Wo ein Wert allein steht, schließen Sie die Optionen mit -- ab, sofern das Programm das laut Dokumentation unterstützt, oder lehnen Werte ab, die mit einem Bindestrich beginnen.
Bei Aufgaben, die regelmäßig externe Programme brauchen, etwa Bild- oder PDF-Verarbeitung in größerem Umfang, lohnt eine Entkopplung: Die Webanwendung legt nur einen Auftrag in eine Warteschlange, ein getrennter Hintergrunddienst mit eigenem Benutzer und eigenen Rechten führt ihn aus. Der PHP-Prozess, der Anfragen aus dem Internet beantwortet, braucht dann keine Funktionen für Systembefehle mehr, und sie lassen sich für ihn sperren. Externe Programme sollten außerdem Zeit- und Speichergrenzen haben, damit ein einzelner Auftrag den Server nicht blockiert.
Ein Sonderfall ist mail(). Der fünfte Parameter übergibt zusätzliche Optionen an das Mailprogramm des Systems. Werte aus Formularen, etwa eine Absenderadresse, dürfen dort nie ungeprüft landen. Robuster ist eine Mailbibliothek, die per SMTP versendet und keinen Systemaufruf braucht.
escapeshellarg: richtig einsetzen und Grenzen kennen
Wo Bestandscode noch mit Befehlszeilen arbeitet, ist escapeshellarg() für jeden einzelnen Wert die Mindestmaßnahme. Die Funktion setzt den Wert in Anführungszeichen, sodass die Shell ihn als genau ein Argument behandelt. escapeshellcmd() ist für Benutzereingaben ungeeignet, weil es zusätzliche Parameter zulässt, wie OWASP an einem Beispiel zeigt.
Wichtige Grenzen von escapeshellarg():
- Argument-Injection bleibt möglich: Ein maskierter Wert, der mit einem Bindestrich beginnt, wird vom Zielprogramm trotzdem als Option gelesen.
- Windows verhält sich anders: Dort ersetzt die Funktion laut Handbuch bestimmte Zeichen wie
%und"durch Leerzeichen; Werte kommen also verändert an. - Zeichensatz: Je nach Locale-Einstellung des Servers können Nicht-ASCII-Zeichen verloren gehen.
Fundstellen im Bestandscode finden
Eine Suche nach Systemaufrufen ist schnell gemacht und liefert eine überschaubare Liste:
grep -rnE "\b(exec|system|passthru|shell_exec|popen|proc_open)\s*\(" src/ --include="*.php"
grep -rn '`' src/ --include="*.php" | grep -v '^\s*//'
Jede Fundstelle braucht eine Antwort auf drei Fragen: Lässt sich der Aufruf durch PHP-Funktionen ersetzen? Fließen Werte von außen hinein? Mit welchen Rechten läuft der Prozess? Die letzte Frage ist entscheidend für den Schaden im Ernstfall. Ein PHP-Prozess mit eigenem Benutzer und ohne Schreibrechte auf den Code richtet weniger an, siehe Least Privilege: Rechte des Webserver-Benutzers.
Startet ein PHP-FPM- oder Webserver-Prozess eine Shell, ist das ein starkes Warnsignal für einen Einbruch. Wie Sie das erkennen, beschreibt Warnsignal: Der Webserver startet eine Shell.
Wie hilft PHP SOS konkret?
- Wir suchen alle Systemaufrufe in Ihrer Anwendung, in Plugins und in Hilfsskripten.
- Wir ersetzen Aufrufe, wo möglich, durch PHP-Funktionen und stellen die übrigen auf Aufrufe ohne Shell um.
- Wir sperren nicht benötigte Funktionen in der PHP-Konfiguration und prüfen die Rechte des PHP-Benutzers.
- Wir testen die Änderungen und dokumentieren, welche Programme die Anwendung noch aufrufen darf.
Die Prüfung eigener Software bieten wir als Sicherheitsaudit und Penetrationstest an.
Häufige Fragen
Reicht escapeshellarg gegen Command Injection?
Es verhindert, dass ein Wert als weiterer Befehl ausgeführt wird. Es verhindert nicht, dass der Wert als Option des aufgerufenen Programms gelesen wird, wenn er mit einem Bindestrich beginnt. Deshalb zusätzlich Werte prüfen und, wo das Programm es unterstützt, Optionen mit -- abschließen.
Was ist der Unterschied zwischen escapeshellarg und escapeshellcmd?
escapeshellarg behandelt einen einzelnen Wert als genau ein Argument. escapeshellcmd maskiert nur Metazeichen in einer ganzen Befehlszeile und lässt zusätzliche Parameter zu. Für Benutzereingaben ist escapeshellarg die richtige Wahl, ein Aufruf ohne Shell noch besser.
Sollte ich exec und system per disable_functions sperren?
Wenn Ihre Anwendung sie nicht braucht, ja. Das begrenzt den Schaden nach einem Einbruch. Es ersetzt aber keinen sicheren Code, denn Anwendungen, die Befehle benötigen, brauchen die Funktionen weiterhin.
Wie erkenne ich, ob eine Command Injection ausgenutzt wurde?
Ein starkes Warnsignal sind PHP- oder Webserver-Prozesse, die eine Shell wie sh oder bash starten. Dazu kommen unbekannte Prozesse, neue Dateien in Upload-Ordnern und ungewöhnliche ausgehende Verbindungen.