Sichere Subprozesse in kleinen PHP-Anwendungen - Argumente statt Shell-Programme übergeben
Eine kleine PHP-Anwendung benötigt manchmal ein Werkzeug, das PHP selbst nicht bereitstellt: einen Bildkonverter, einen Dokument-Renderer, ein Werkzeug zur Medienanalyse oder einen eng begrenzten Wartungsbefehl. Der Aufruf eines ausführbaren Programms kann sinnvoll sein. Gefährlich wird es, wenn eine Befehlszeile wie gewöhnlicher Text behandelt und Daten aus einer Anfrage darin eingefügt werden.
Es geht nicht nur um die Frage, ob sich verdächtige Satzzeichen entfernen lassen. Entscheidend ist, ob die Anwendung drei verschiedene Dinge voneinander trennen kann: das von ihr ausgewählte ausführbare Programm, die von ihr ausgewählten Optionen und die als einzelnes Argument übergebenen Daten. Sobald eine Shell eine zusammengesetzte Zeichenfolge erhält, hängt diese Trennung vom Parser einer weiteren Sprache ab.
Dieser Artikel entwickelt ein defensives Muster für PHP 7.4 oder neuer unter Linux. Er behauptet weder, dass proc_open() jeden Kindprozess sicher macht, noch bietet er einen universellen Prozess-Supervisor. Das engere Ziel besteht darin, die Grenze zwischen Steuerung und Daten zu bewahren und anschließend die weiterhin erforderlichen Schutzmaßnahmen zu bestimmen.
Die Shell ist ein Sprachinterpreter
MITREs CWE-78 beschreibt OS Command Injection als Veränderung des Befehls, den eine Anwendung an das Betriebssystem senden will, durch extern beeinflusste Eingaben. Entscheidend ist das Wort Befehl. Eine Shell startet nicht nur ein Programm: Sie interpretiert Anführungszeichen, Variablenexpansion, Umleitungen, Pipelines, Befehlstrennzeichen und weitere Syntax.
Das folgende Code-Muster verdeutlicht dies, ohne dass dafür ein Angriffs-Payload nötig ist:
$command = '/usr/bin/tool --mode fixed ' . $requestValue;
exec($command, $output, $exitCode);
Die Anwendung sieht $requestValue als Daten vor. Die Shell erhält ein einzelnes Programm, das sowohl feste Syntax als auch variablen Text enthält. Die Korrektheit hängt nun von Regeln für Anführungszeichen, dem Betriebssystem, der Locale und jeder künftigen Änderung an der Zeichenfolge ab. Das PHP-Handbuch zu exec() warnt ausdrücklich davor, von Benutzern bereitgestellte Daten ohne Escaping an die Funktion zu übergeben.
Zunächst sollte besser gefragt werden, ob überhaupt ein Subprozess erforderlich ist. Das OWASP OS Command Injection Defense Cheat Sheet und CWE-78 bevorzugen Sprach-APIs oder Bibliotheken, wenn diese denselben Vorgang ausführen können. PHPs mkdir() ist beispielsweise eindeutiger als der Aufruf des Systemprogramms mkdir. Eine Bibliothek beseitigt einen Parser und liefert der Anwendung in der Regel strukturiertere Fehler.
Einen Argumentvektor statt einer Befehlszeichenfolge übergeben
Wenn ein externes Programm tatsächlich erforderlich ist, bietet PHP eine strukturell robustere Möglichkeit. Seit PHP 7.4 darf der Befehl laut proc_open()-Handbuch als Array übergeben werden. PHP dokumentiert, dass der Prozess dann direkt und ohne Umweg über eine Shell geöffnet wird und PHP das notwendige Escaping der Argumente übernimmt.
Der Unterschied wird in der Datenstruktur sichtbar:
$process = proc_open(
[
'/usr/bin/tool',
'--mode',
'fixed',
'--',
$requestValue,
],
$descriptors,
$pipes
);
Jedes Array-Element ist ein einzelnes Argument. Shell-Satzzeichen innerhalb von $requestValue werden nicht als Pipeline oder zweiter Befehl neu interpretiert, weil keine Shell das Array als Shell-Programm parst. Das ähnelt vom Grundprinzip her einem vorbereiteten Datenbank-Statement: Steuerungsstruktur und Daten werden über unterschiedliche Kanäle übermittelt.
Diese Analogie hat Grenzen. Ein Wert in einem vorbereiteten SQL-Statement kann nicht zu einem SQL-Schlüsselwort werden, doch ein Argument wird weiterhin vom ausführbaren Programm interpretiert. Beginnen variable Daten mit einem Bindestrich, kann ein Programm sie als Option behandeln. Akzeptiert das Programm eine Option, die eine andere Konfiguration einliest, eine ausgewählte Datei schreibt oder ein Hilfsprogramm startet, war für schädliches Verhalten nie eine Shell erforderlich. OWASP bezeichnet dies als Argument Injection.
Ausführbares Programm, Optionen und Operanden brauchen getrennte Richtlinien
Bei einer hilfreichen Prüfung wird jedes Element vor dem Prozessstart eingeordnet.
Das ausführbare Programm fest vorgeben
Eine Anfrage darf weder den Namen noch den Pfad eines Binärprogramms bestimmen. Statt sich auf PATH zu verlassen, sollte ein absoluter, von der Anwendung kontrollierter Pfad wie /usr/bin/grep verwendet werden. Das PHP-Handbuch weist darauf hin, dass ein einfacher Dateiname im ersten Array-Element über PATH gesucht wird; ein absoluter Pfad vermeidet diese Auswahlentscheidung.
Unterstützt die Anwendung mehrere Vorgänge, sollte sie eine kleine interne Kennung einem festen ausführbaren Programm und einer festen Argumentvorlage zuordnen. Unbekannte Kennungen sind abzulehnen. Ein beliebiges „command“-Feld sollte nicht angenommen und erst anschließend bereinigt werden.
Optionen möglichst fest vorgeben
Optionen bestimmen, was das Programm tun darf. Sie gehören in den Anwendungscode und nicht in ein Formularfeld. Benutzer dürfen möglicherweise einen fachlichen Modus aus einer Allowlist auswählen, doch die Anwendung sollte diese Auswahl in einen geprüften Satz von Optionen übersetzen.
Viele Unix-Werkzeuge erkennen -- als Ende der Optionen. Wird es vor variablen Operanden platziert, kann es verhindern, dass ein mit einem Bindestrich beginnender Wert zu einer weiteren Option wird. Dies ist keine allgemeingültige Zusicherung: Die Dokumentation des aufgerufenen Programms muss dessen Argumentgrammatik bestätigen.
Operanden anhand ihrer Bedeutung validieren
Ein strukturierter Aufruf beantwortet nicht die Frage, ob ein Operand angemessen ist. Eine Seitenzahl sollte als Ganzzahl innerhalb eines sinnvollen Bereichs geparst werden. Ein Format sollte aus einer kleinen Allowlist stammen. Einen Pfad sollte normalerweise der Server erstellen oder auflösen, statt ihn direkt vom Browser zu übernehmen. Freitext benötigt eine Längenbegrenzung und alle domänenspezifischen Einschränkungen.
Positive Validierung ist beständiger als die Pflege einer Liste von Zeichen, die in der Shell von gestern gefährlich erschienen. Außerdem erkennt sie gewöhnliche Fehler, bevor diese den Kindprozess erreichen.
Escaping ist ein eingeschränkter Fallback
Manche Aufgaben erfordern tatsächlich Shell-Funktionen wie eine Pipeline oder eine Umleitung. In diesem Fall ist die Shell eine bewusste Abhängigkeit und keine unsichtbare Annehmlichkeit. Der Befehl sollte klein, so weit wie möglich fest vorgegeben, für die tatsächliche Plattform geprüft und ausschließlich mit einzeln maskierten Argumenten versehen sein.
PHPs escapeshellarg() ist dafür vorgesehen, eine Zeichenfolge in ein einzelnes Shell-Argument umzuwandeln. Die Funktion darf nicht mit escapeshellcmd() verwechselt werden. Letztere maskiert eine vollständige Befehlszeichenfolge, erlaubt laut eigenem Handbuch jedoch weiterhin eine beliebige Anzahl von Argumenten.
Auch escapeshellarg() ist kein Grund, eine Shell zu bevorzugen. PHP dokumentiert ein anderes Verhalten unter Windows, wo einige Zeichen ersetzt werden, und weist darauf hin, dass das Verhalten bei Multibyte-Zeichen von der aktuellen LC_CTYPE-Locale abhängt. Escaping behandelt das Parsing durch die Shell. Es validiert weder die Optionen des ausführbaren Programms noch Berechtigungen, Ressourcennutzung oder Geschäftsregeln.
Ein kleines Linux-Beispiel
Das folgende Beispiel durchsucht eine von der Anwendung kontrollierte Textdatei nach einer literalen Zeichenfolge. Das ausführbare Programm und die Optionen sind fest vorgegeben, -- beendet das Parsen von Optionen für GNU grep, und der variable Wert belegt ein einzelnes Array-Element.
<?php
declare(strict_types=1);
$needle = trim((string) ($_POST['needle'] ?? ''));
if ($needle === '' || strlen($needle) > 80 || str_contains($needle, "\0")) {
http_response_code(422);
exit('Invalid search text');
}
$descriptors = [
0 => ['file', '/dev/null', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open(
[
'/usr/bin/grep',
'--fixed-strings',
'--line-number',
'--',
$needle,
'/srv/example/data/public-notes.txt',
],
$descriptors,
$pipes,
'/srv/example',
['PATH' => '/usr/bin:/bin', 'LANG' => 'C']
);
if (!is_resource($process)) {
throw new RuntimeException('Could not start search process');
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
if ($exitCode > 1) {
error_log('grep failed: ' . substr($stderr, 0, 500));
http_response_code(500);
exit('Search failed');
}
echo htmlspecialchars($stdout, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
Dieses Beispiel ist bewusst eng gefasst. GNU grep definiert den Exit-Status 0 für einen Treffer, 1 für keine ausgewählten Zeilen und einen Wert größer als 1 für einen Fehler. Daher wäre „ungleich null bedeutet Fehler“ hier falsch. Die Ausgabe wird für HTML codiert, weil ein sicherer Prozessaufruf die Ausgabe des Kindprozesses nicht automatisch für eine HTML-Antwort sicher macht.
Das Snippet eignet sich außerdem weder für beliebige Befehle noch für große Ausgabemengen. Wird stdout vollständig vor stderr gelesen, kann ein Deadlock entstehen, wenn der Kindprozess eine Pipe füllt, während der Elternprozess auf die andere wartet. Ein produktiv eingesetzter Wrapper für potenziell große Streams muss sie parallel leeren, Byte-Limits durchsetzen und sorgfältig aufräumen. Die Dokumentation zu proc_open() warnt ausdrücklich vor dem Schließen von Pipes und vor Deadlocks.
Die Möglichkeiten des Kindprozesses begrenzen
Die Trennung von Argumenten verhindert einen wichtigen Parsing-Fehler. Sie macht den Kindprozess jedoch nicht zu vertrauenswürdigem Code. Mehrere unabhängige Begrenzungen bleiben wichtig:
- Identität: PHP und der Kindprozess sollten nur mit den für die Aufgabe erforderlichen Berechtigungen ausgeführt werden. Das Least-Privilege-Prinzip verringert die Folgen eines Fehlers, behebt ihn aber nicht.
- Arbeitsverzeichnis: Ein explizites absolutes Verzeichnis sollte übergeben werden, statt versehentlich ein Verzeichnis zu erben.
- Umgebung: Soweit praktikabel, sollte eine minimale Umgebung bereitgestellt werden. Geheimnisse, die der Kindprozess nicht benötigt, dürfen nicht übergeben werden, und Variablen wie
PATHdürfen nicht für die Auswahl von Code als vertrauenswürdig gelten. - Eingabe: Für umfangreiche Daten sind Pipes oder vom Server verwaltete Dateien vorzuziehen. Befehlszeilenargumente können auf manchen Systemen für andere lokale Prozesse sichtbar sein und sollten keine Geheimnisse enthalten.
- Ausgabe: stdout und stderr sollten begrenzt und beide als nicht vertrauenswürdig behandelt werden. Interne Diagnosen dürfen nicht an Besucher zurückgegeben werden.
- Zeit: Eine Frist sollte festgelegt werden. Ein Kindprozess kann auch ohne Injection-Schwachstelle hängen bleiben.
- Parallelität: Die Anzahl ressourcenintensiver Kindprozesse, die eine Anfrage oder ein Konto erzeugen kann, sollte begrenzt werden.
Die Behandlung von Timeouts erfordert Vorsicht. Laut proc_terminate()-Handbuch sendet die Funktion einem Prozess ein Signal und kehrt sofort zurück; sie wartet nicht auf seine Beendigung. Ein Befehl kann außerdem Nachkommen erzeugen. Robustes Abbrechen hängt vom Betriebssystem und Prozessmodell ab. Eine Webanfrage sollte daher nicht versuchen, spontan einen universellen Manager für Prozessbäume zu implementieren.
Oft besteht die klarere Architektur darin, einen eng definierten Auftrag in eine Warteschlange zu stellen und ihn von einem dedizierten Worker unter einem eingeschränkten Dienstkonto ausführen zu lassen. Der Webprozess validiert und protokolliert dann die Absicht, statt eine HTTP-Verbindung offenzuhalten, während er ein externes Programm verwaltet.
Das Ergebnis beobachten, ohne Interna preiszugeben
Der Elternprozess sollte festhalten, ob die Prozesserstellung erfolgreich war, ob die Frist erreicht wurde, welchen dokumentierten Exit-Status der Prozess lieferte, wie lange er lief und eine begrenzte Diagnose. Er sollte weder Geheimnisse aus Anfragen noch vollständige Dokumente oder eine zusammengesetzte Befehlszeichenfolge mit sensiblen Werten protokollieren.
proc_close() wartet auf die Beendigung des Prozesses und gibt dessen Exit-Code zurück. Dieser Code ist nur im Kontext der Dokumentation des aufgerufenen Programms aussagekräftig. Standardfehlerausgabe bedeutet nicht automatisch einen Fehler, und eine leere Standardausgabe bedeutet nicht automatisch Erfolg. Die Schnittstelle des ausführbaren Programms sollte als API-Vertrag behandelt werden.
Eine Checkliste für die Prüfung
- Kann eine PHP-API oder eine gepflegte Bibliothek den externen Befehl ersetzen?
- Ist das ausführbare Programm über einen absoluten, festen und von der Anwendung kontrollierten Pfad festgelegt?
- Werden Argumente als Array an
proc_open()übergeben, statt als Shell-Text zusammengesetzt zu werden? - Sind die Optionen fest vorgegeben oder werden sie über eine kleine Allowlist zugeordnet?
- Unterstützt das Programm eine Markierung für das Ende der Optionen vor variablen Operanden?
- Wird jeder Operand gemäß seiner fachlichen Bedeutung, seinem Typ und seiner Länge validiert?
- Sind Arbeitsverzeichnis, Umgebung, Identität, Dateisystemzugriff und Netzwerkzugriff eingeschränkt?
- Sind stdin, stdout, stderr, Laufzeit, Ausgabegröße und Parallelität begrenzt?
- Werden Exit-Codes anhand der Dokumentation des ausführbaren Programms interpretiert?
- Sind die Protokolle nützlich, ohne Geheimnisse oder Details des Kindprozesses gegenüber Besuchern offenzulegen?
Fazit
Der stärkste Schutz bei Subprozessen besteht oft darin, gar keinen zu starten. Wenn PHP ein externes Werkzeug aufrufen muss, ist der nächststärkere Schritt struktureller Art: das ausführbare Programm im Code auswählen, die Optionen unter Kontrolle der Anwendung halten und jeden Operanden ohne Shell als separates Array-Element übergeben.
Dieses Design verhindert, dass Daten zu Shell-Syntax werden, beendet die Prüfung jedoch nicht. Das ausführbare Programm interpretiert weiterhin Argumente, und das Betriebssystem gewährt dem Kindprozess weiterhin Zeit, Arbeitsspeicher, Dateien, Netzwerkzugriff und Berechtigungen. Validierung und Eingrenzung bleiben notwendig.
Eine hilfreiche abschließende Frage lautet daher nicht: „Wurde jedes gefährliche Zeichen maskiert?“ Sondern: „Welcher Parser erhält diesen Wert als Nächstes und über welche Befugnisse verfügt dieser Parser?“
Quellen
- PHP Documentation Group, “proc_open”.
- PHP Documentation Group, “exec”.
- PHP Documentation Group, “escapeshellarg”.
- PHP Documentation Group, “escapeshellcmd”.
- PHP Documentation Group, “proc_close”.
- PHP Documentation Group, “proc_terminate”.
- OWASP Cheat Sheet Series, “OS Command Injection Defense Cheat Sheet”.
- MITRE CWE, “CWE-78: Improper Neutralization of Special Elements used in an OS Command,” CWE 4.20.
- GNU Project, “GNU Grep Manual: Exit Status”.
