Wer ein altes PHP-System übernimmt, bekommt die Frage nach der Sicherheit früh gestellt, und meistens in einer Form, die nicht zu beantworten ist: Ist das sicher? Die ehrliche Antwort auf diese Frage lautet immer nein, und sie hilft niemandem.
Nützlich wird es mit einer Liste. In gewachsenen PHP-Systemen finde ich immer wieder dieselben zehn Lücken, und das ist keine Überraschung: Sie entstehen aus Praktiken, die zur Entstehungszeit üblich waren.
Dieser Artikel geht sie durch, mit dem Befehl, der sie findet, und in einer Reihenfolge, die nach Risiko sortiert ist und nicht nach Aufwand. Was er nicht ist: ein Ersatz für einen Penetrationstest. Er ist das, was man vorher selbst tun kann.
Die Reihenfolge ist nach Risiko, nicht nach Aufwand
Der übliche Fehler bei so einer Liste ist, sie von oben nach unten abzuarbeiten oder mit dem Billigsten anzufangen. Beides führt dazu, dass die gefährlichste Lücke am längsten offen bleibt.
Sortiert wird nach zwei Fragen. Was kann ein Angreifer damit erreichen? Daten lesen ist schlimm, Daten ändern ist schlimmer, Code ausführen ist das Ende. Und: Wie schwer ist es, die Lücke zu finden? Was ein Scanner in fünf Minuten findet, wird auch gefunden.
Die ersten vier Punkte unten sind die, bei denen beide Antworten ungünstig sind. Sie gehören in die erste Woche, unabhängig davon, was sonst ansteht.
Die vier, die zuerst drankommen
1. Abfragen, die aus Zeichenketten zusammengesetzt werden. Der Klassiker, und in Code von vor 2012 die Regel statt der Ausnahme. Ein Treffer reicht, um die Datenbank zu lesen und meist auch zu schreiben.
# Abfragen mit Variablen darin: jeder Treffer ist zu pruefen
grep -rnE '(query|exec|prepare)\s*\(\s*["'"'"'].*\$' src/ --include="*.php"
# Und die Stellen, an denen Eingaben ungefiltert in Abfragen wandern
grep -rnE '\$(_GET|_POST|_REQUEST)\[[^]]+\][^;]*(query|WHERE|SELECT)' src/Behoben wird das nicht durch Escapen, sondern durch vorbereitete Abfragen mit Parametern. Der Unterschied ist keine Stilfrage: Escapen muss an jeder Stelle richtig gemacht werden, Parameter sind von der Konstruktion her sicher.
2. Passwörter mit veralteten Verfahren. md5() und sha1() auf Passwörtern sind heute in Stunden zu brechen, mit oder ohne Salz.
grep -rnE '(md5|sha1)\s*\(\s*\$(passwort|password|pass|pw)' src/ --include="*.php"Die Umstellung braucht kein Zurücksetzen aller Passwörter. Beim nächsten erfolgreichen Anmelden wird neu gehasht, und nach einigen Monaten sind die alten Werte verschwunden:
if (password_verify($eingabe, $nutzer->hash)) {
// Alt: md5 oder sha1. Beim Anmelden still auf das aktuelle
// Verfahren heben, ohne dass der Nutzer etwas merkt.
if (password_needs_rehash($nutzer->hash, PASSWORD_DEFAULT)) {
$this->nutzer->setzeHash($nutzer->id, password_hash($eingabe, PASSWORD_DEFAULT));
}
}Wer noch ältere Verfahren ablösen will, braucht einen Übergang: Beim Anmelden zuerst gegen den alten Hash prüfen, dann neu setzen. Wer nach zwölf Monaten nicht angemeldet war, bekommt beim nächsten Versuch ein Zurücksetzen.
3. Hochgeladene Dateien, die im Web-Verzeichnis landen. Wenn ein Upload unter einem Pfad liegt, den der Webserver ausliefert, und die Endung nicht geprüft wird, kann jemand eine PHP-Datei hochladen und aufrufen. Das ist die Lücke, bei der aus Datenzugriff Codeausführung wird.
grep -rn "move_uploaded_file" src/ --include="*.php" -A 3Die Prüfung der Endung reicht dabei nicht, weil der Dateiname vom Absender kommt. Drei Dinge zusammen tragen: Der Speicherort liegt außerhalb des ausgelieferten Verzeichnisses, der Dateiname wird neu vergeben statt übernommen, und der Inhaltstyp wird am Inhalt geprüft und nicht an der Angabe des Browsers.
4. Fehlende Prüfung, ob jemand etwas sehen darf. Eine Seite prüft, ob jemand angemeldet ist, und nimmt dann die Kennung aus der Adresse. Wer die Zahl ändert, sieht fremde Daten. Das findet kein Scanner zuverlässig und jeder neugierige Nutzer in einer Minute.
# Stellen, die eine Kennung aus der Anfrage nehmen. Jede einzelne
# braucht die Frage: gehoert dieser Datensatz zum angemeldeten Nutzer?
grep -rnE '\$_(GET|POST)\[.(id|kunde|bestellung|rechnung)' src/ --include="*.php"Drei, die schnell behoben sind
5. Abhängigkeiten mit bekannten Lücken. Der billigste Punkt der ganzen Liste, weil ein Befehl die Antwort liefert und die Behebung meist eine Versionserhöhung ist.
composer audit --format=tableWichtig ist, was danach passiert: Der Befehl gehört in die Pipeline, sonst ist die Liste in drei Monaten wieder so lang wie vorher. Und er gehört mit einer Entscheidung versehen, was bei einem Fund passiert, der sich nicht sofort beheben lässt.
6. Fehlermeldungen mit Innenansicht. Ein Fehler in Produktion, der einen Aufrufstapel mit Dateipfaden, Klassennamen und manchmal Zugangsdaten in der Anfrage zeigt. Das ist keine Lücke für sich, es ist die Anleitung für alle anderen.
php -i | grep -E "^(display_errors|expose_php|error_reporting)"
# Erwartet: display_errors = Off, expose_php = Off7. Zugangsdaten im Repository. Eine Konfigurationsdatei mit Passwort, ein Schlüssel in einem alten Skript, ein Zugangsschlüssel in einem Kommentar. Wichtig dabei: Das Entfernen aus der aktuellen Fassung reicht nicht, die Historie enthält es weiter.
# Ueber die gesamte Historie, nicht nur den aktuellen Stand
git log -p --all -S 'password' -- '*.php' '*.ini' '*.yml' | head -50
# Praktischer: ein Werkzeug, das Muster kennt
docker run --rm -v "$PWD:/pfad" trufflesecurity/trufflehog:latest \
git file:///pfad --only-verifiedWas gefunden wird, wird nicht nur entfernt, sondern zuerst zurückgezogen: Das Passwort wird geändert, der Schlüssel neu erzeugt. Alles andere ist Aufräumen mit dem Gefühl von Sicherheit.
Drei, die Struktur brauchen
8. Sitzungen, die nach der Anmeldung dieselbe Kennung behalten. Wer einem Nutzer vor der Anmeldung eine Sitzungskennung unterschieben kann, ist nach dessen Anmeldung angemeldet. Die Abhilfe ist eine Zeile, und sie fehlt in altem Code fast immer.
// Direkt nach erfolgreicher Pruefung der Zugangsdaten:
session_regenerate_id(true);
$_SESSION['nutzer_id'] = $nutzer->id;
// Und in der Konfiguration, einmalig:
// session.cookie_httponly = 1
// session.cookie_secure = 1
// session.cookie_samesite = Lax
// session.use_strict_mode = 19. Formulare ohne Schutz gegen fremde Absender. Eine Seite, die auf eine Anfrage hin etwas ändert, ohne zu prüfen, ob die Anfrage von der eigenen Seite kam. In einem Framework ist das eingebaut, in gewachsenem Code steht es je Formular an oder eben nicht.
Der Umbau ist unangenehm, weil er jedes Formular betrifft. Er lässt sich zentral machen: eine Prüfung, die für alle ändernden Anfragen greift, plus eine ausdrückliche Liste von Ausnahmen für Endpunkte, die von außen aufgerufen werden. Die Liste ist dann gleichzeitig die Übersicht darüber, welche Stellen das sind, und die ist ihrerseits nützlich.
10. Verwaltungsbereiche, die nur ein Passwort trennt. Der Bereich, in dem man alles kann, hängt an denselben Zugangsdaten wie der Rest, erreichbar aus dem ganzen Internet. Hier lohnt keine Diskussion über Passwortstärke, sondern eine zweite Hürde: eine Beschränkung auf bekannte Netze, ein zweiter Faktor, oder beides.
Das ist der Punkt, der am häufigsten aufgeschoben wird, weil er unbequem ist und niemandem etwas Sichtbares bringt. Er ist gleichzeitig der, bei dem ein einziger Vorfall den größten Schaden anrichtet.
Wo Zugangsdaten stattdessen liegen sollten, steht in Konfiguration und Secrets für Go-Services.
Was in der ersten Woche passiert
Zehn Punkte sind zu viel für einen Anfang. Was ich in der ersten Woche mache, wenn ich ein solches System übernehme, sind vier Dinge, und alle vier sind an einem Tag machbar.
composer auditlaufen lassen und die Funde mit bekannter Ausnutzbarkeit sofort beheben. Das ist die einzige Kategorie, bei der jemand ohne Kenntnis des Systems angreifen kann.- Fehlerausgabe in Produktion prüfen. Eine Einstellung, fünf Minuten, und sie nimmt allen anderen Lücken die Anleitung.
- Das Repository nach Zugangsdaten durchsuchen und gefundene Geheimnisse zurückziehen. Nicht entfernen, zurückziehen.
- Die Verwaltungsbereiche einschränken, notfalls grob über eine Netzbeschränkung. Das ist in einer Stunde erledigt und schließt das größte Einzelrisiko.
Der Rest wird eine Liste mit Terminen. Sie gehört ins Repository, nicht in eine Präsentation, und jeder Punkt bekommt ein Datum und einen Namen.
Dieselbe Übung auf der Infrastrukturseite steht in IAM aufräumen in einem gewachsenen AWS-Konto.
Was diese Liste nicht leistet
Drei Grenzen gehören dazu, damit niemand die falsche Sicherheit mitnimmt.
Sie ist nicht vollständig. Es sind die zehn, die ich am häufigsten finde. Ein System kann alle zehn erfüllen und trotzdem eine Lücke haben, die aus seiner Fachlogik kommt.
Sie prüft nicht die Infrastruktur. Offene Verwaltungszugänge auf dem Server, Datenbanken, die aus dem Internet erreichbar sind, Sicherungen in einem offenen Speicher: Das ist eine eigene Durchsicht, und sie findet in gewachsenen Umgebungen regelmäßig mehr als der Code.
Sie ersetzt keinen Test durch jemand anderen. Ein Penetrationstest findet Dinge, die man selbst nicht findet, weil man das System kennt. Die zehn Punkte oben sind das, was man vorher beheben sollte, damit dieser Test nicht damit ausgelastet ist.
Und die Einordnung, die beim Reden mit der Geschäftsführung hilft: Diese Punkte sind kein Argument für eine Modernisierung. Sie sind alle einzeln behebbar, ohne das System umzubauen, und genau so gehören sie behandelt: als Betriebsaufgabe mit Terminen, nicht als Teil eines Vorhabens, das erst genehmigt werden muss. Das Argument für die Modernisierung ist ein anderes.
Wie eine schrittweise Modernisierung abläuft, steht auf einer eigenen Seite; die Liste oben ist dort die Vorarbeit, die unabhängig davon Sinn ergibt.
Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.

