php2shell: Patchen heißt erst einmal suchen
Warum diese Lücke anders war
Am 21. Oktober 2026 hat das PHP-Projekt zwei kritische Lücken geschlossen, beide mit CVSS 9.2: CVE-2026-109618 und CVE-2026-109619. In der Szene lief das Ganze unter dem Namen php2shell, und der Name trifft es: Eine einzige Anfrage ohne Anmeldung reicht, um Code als Webserver-Benutzer auszuführen.
Drei Sätze erklären, warum das unangenehmer ist als die übliche Plugin-Lücke. Die Schwachstelle sitzt nicht in einer Anwendung, sondern in der Laufzeit darunter, also sind WordPress, Laravel, Shopware und eine Parkseite mit einer einzigen index.php gleichermaßen betroffen. Unter mod_php greift der Angriff, während PHP die Anfrage einliest, bevor eine Zeile Anwendungscode läuft. Logins, Plugins und eine Firewall innerhalb der Anwendung helfen deshalb nicht.
Behoben ist das in PHP 8.5.12, 8.4.27, 8.3.36 und 8.2.35. Für 7.4, 8.0 und 8.1 hat das PHP-Team ausnahmsweise minimale Quelltext-Backports bereitgestellt, die selbst gebaut werden müssen. Offizielle Releases für ältere Zweige gibt es nicht, womit die Sache aber nicht erledigt ist: Distributionen und spezialisierte Hoster pflegen ihre eigenen Pakete und können einen Fix auch dorthin zurückportieren. Ein Blick in die tatsächlich genutzte Paketquelle lohnt sich deshalb, bevor man eine alte Version als verloren abschreibt.
Angekündigt war das Release für 16:00 Uhr, veröffentlicht wurde es um 15:34 Uhr. Vorher kursierten im Netz angeblich funktionierende Exploits, ob die echt waren, ist nicht geprüft. Entscheidender ist eine andere Beobachtung: Der Sicherheitsdienstleister Sansec berichtet, dass Cloudflare bereits am Vormittag erste Angriffe auf PHP-Seiten gemeldet hat, also Stunden vor dem Patch. Wer an diesem Tag auf das Release wartete, wartete nicht im luftleeren Raum.
Erst suchen, dann patchen
Patchen heißt an dieser Stelle erst einmal suchen: Es kann nur, wer weiß, wo überall PHP läuft, und auf einem gewachsenen Server ist das keine rhetorische Frage. Diese fünf Befehle beantworten sie vollständig:
ls /etc/php/*/fpm/pool.d/ # FPM-Pools je Version
grep -H -E "PHPRC|exec" /var/www/php-fcgi-scripts/*/.php-fcgi-starter # fcgid und CGI
apachectl -M | grep php # mod_php geladen?
grep -n php /var/spool/cron/crontabs/* # Crons, auch unsichtbare
update-alternatives --query php | grep Value # System-CLI
Die ersten drei sind naheliegend, die letzten beiden sind die, an denen es klemmt.
„Keine Site nutzt diese Version" reicht als Freigabe nicht. Eine alte Version kann von keinem Pool mehr benutzt werden und trotzdem gebraucht sein. Ein Control-Panel läuft gern mit einer aktuellen Version, liest aber über ein PHPRC in seinem Starter-Skript die php.ini einer ganz anderen. Wird die entfernt, startet das Panel beim nächsten Mal ohne Konfiguration. Sichtbar wird so etwas nur, wenn man die Starter-Skripte mit durchsucht. Kleine Zugabe für alle, die es nachstellen: Solche Starter tragen oft chattr +i, ein sed -i scheitert dort mit „Operation not permitted" und lässt die Datei unverändert zurück, ohne dass das Skript stehen bleibt.
Crons, die die Oberfläche nicht kennt. Ruft ein Projekt seinen Cronjob mit /usr/bin/php auf, läuft er mit der jeweiligen Systemversion und wechselt stillschweigend mit, sobald jemand die System-CLI umstellt. Besonders unangenehm: Solche Einträge stehen oft nicht im Hosting-Panel, sondern in einer von Hand installierten Crontab des Site-Users und sind in der Oberfläche unsichtbar.
Die Stunden bis zum Patch
Zwischen „es gibt eine Lücke" und „der Patch ist drauf" lagen an diesem Tag mehrere Stunden. Die kursierende Notlösung war session.upload_progress.enabled = Off, und schon dabei liegt die erste Falle: PHP hat pro Version und pro SAPI eine eigene php.ini. FPM, CLI, CGI und mod_php sind vier getrennte Dateien mit vier getrennten conf.d-Verzeichnissen. Wer nur den FPM-Zweig anfasst, hat die Websites abgedeckt und Panel, Webmail und sämtliche Cronjobs offen gelassen.
; Rückweg: Datei löschen + reload
session.upload_progress.enabled = Off
Als eigene Datei unter conf.d, etwa /etc/php/8.3/fpm/conf.d/99-security-upload-progress.ini. Eigene Datei deshalb, weil der Rückweg dann aus einem rm besteht. Die Einstellung ist PHP_INI_PERDIR, eine .user.ini im Webverzeichnis kann sie wieder einschalten, und die Gegenprobe gehört an beide Enden: php -i für die CLI, eine Testdatei im Pool für die Site.
Weil das nur einen Teil abdeckt, kam als Zweites eine Sperre für Upload-Anfragen dazu. Das kostet Formulare und Datei-Uploads, ist für ein paar Stunden aber verkraftbar:
<If "req('Content-Type') =~ m#^\s*multipart/#i && ! -R '203.0.113.0/24'">
Require all denied
</If>
Das Netz im Beispiel stammt aus RFC 5737, dort gehört das eigene hinein, damit man sich nicht selbst aussperrt. Für nginx reicht eine Zeile im Server-Block:
if ($content_type ~* "^\s*multipart/") { return 403; }
An der Apache-Fassung sind zwei Anläufe gescheitert, und beide Fehler hätten ohne Gegenprobe wie ein Erfolg ausgesehen. Erstens ist %{CONTENT_TYPE} in Apache-Ausdrücken der Content-Type der Antwort, nicht der Anfrage. Eine damit gebaute Sperre greift schlicht nie. Richtig ist req('Content-Type'). Zweitens wirkt eine naheliegende Sperre über <FilesMatch "\.php$"> vor der Übergabe an FPM und damit auf allen Sites, auch auf denen, die gar nicht gemeint waren.
curl -s -o /dev/null -w '%{http_code}\n' -F 'f=@/etc/hostname' https://example.com/ # erwartet 403
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/ # erwartet 200
Die Sperre hat in gut einer Viertelstunde sieben POST-Anfragen abgewiesen, alle von Mietservern aus Cloud-Netzen. Danach fiel die Entscheidung, PHP ganz abzuschalten, bis der Patch da ist.
Abschalten will dabei richtig gemacht werden. Ein systemctl stop allein reicht nicht, ein Paket-Trigger oder das Hosting-Panel startet FPM still wieder. systemctl mask --runtime sperrt das bis zum nächsten Neustart. Wichtig für später: Beim Patchen muss vorher unmask passieren, sonst scheitern die Neustarts, welche die Pakete selbst auslösen.
Dass das keine übertriebene Vorsicht war, lässt sich an der Branche ablesen. Der Managed-Hoster maxcluster, mit dem ich als Partner zusammenarbeite, hat am selben Nachmittag PHP auf allen Kundenclustern abgeschaltet, ausdrücklich wegen genau dieser beiden CVEs, und das Wartungsfenster mehrfach verlängert, weil die Sicherheitsupdates für die eingesetzten Distributionen noch fehlten. Wer an diesem Tag zu dem Schluss kam, dass Abschalten die ruhigere Variante ist, war damit in guter Gesellschaft.
Eine Kleinigkeit am Rande, die beim Abschalten auffiel: Eine statische Site ohne ErrorDocument 503 kann im Kreis leiten, wenn ihre eigene Soft-404-Regel in der .htaccess die Fehlerseite abfängt. Dann kommt eine Weiterleitung auf sich selbst statt der Wartungsmeldung. Harmlos, aber ein guter Grund, ErrorDocument 503 zu setzen, bevor man es braucht.
Was die Logs zeigen, und was nicht
Wer bei so einer Gelegenheit in die Access-Logs sieht, findet garantiert verdächtige POST-Anfragen: gleichförmige Bursts von Mietservern aus den großen Clouds, mehrere Anfragen pro Sekunde auf /, über viele Domains verteilt, jeweils von einer anderen Adresse. Die Versuchung ist groß, das als Exploit-Versuche zu melden.
Nur steht es da nicht. Apache loggt den Content-Type der Anfrage standardmäßig nicht. Ob ein POST eine multipart-Anfrage mit Nutzlast war oder eine harmlose Formularabsendung, lässt sich hinterher nicht entscheiden. Zeitpunkt, Form und Ziel können passen, ein Beweis ist das nicht, und so sollte man es auch aufschreiben.
Dass es überhaupt aktive Angriffe gab, ist dagegen belegt, nur eben nicht aus dem eigenen Access-Log. Die ehrliche Fassung lautet also: Angriffe liefen, und ob die eigenen Logzeilen dazugehörten, ist offen.
Sansec nennt in seiner Analyse allerdings Merkmale, die unterhalb des Access-Logs liegen und deshalb aussagekräftiger sind. Angriffe erzeugen demnach:
- ungewöhnliche Multipart-Anfragen, gefolgt von Speicherlimit-Fehlern oder wiederholten 500ern
- unerwartet große oder fehlerhafte
PHPSESSID-Cookies in der Antwort, weil eine Variante der Lücke darüber Speicherinhalte preisgibt - unerklärliche Neustarts von PHP-Workern
- eine Häufung fataler Fehler rund um
memory_limit, besonders bei Multipart-Anfragen
Das ist die bessere Suchliste, denn diese Spuren stehen im Error-Log und im Prozessbild, nicht in der Zugriffsstatistik. Dazu gehören die üblichen Zeichen eines erfolgreichen Einbruchs: fremde Prozesse der Site-Benutzer, unerwartete ausgehende Verbindungen, PHP-Dateien mit frischem Zeitstempel, die sich nicht durch ein Update erklären lassen. Diese Liste abzuarbeiten ist beruhigender als das Starren auf POST-Zeilen, und sie blieb hier ohne Befund.
Der praktischste Vorsatz, den so ein Tag hinterlässt, ist deshalb eine Logformat-Änderung: %{Content-Type}i und %{Content-Length}i mit aufnehmen. Beim nächsten Mal muss dann nicht geraten werden.
Der Patch, und warum die gewohnte Quelle ausfiel
Hier kam die Überraschung des Tages. PHP-Pakete für Debian und Ubuntu kommen üblicherweise aus dem PPA von Ondřej Surý, und das hängt nicht an den automatischen Sicherheitsupdates: Diese Pakete kommen nur, wenn man sie holt. Dass die installierte Version mehrere Patchstände hinter dem aktuellen liegt, ist der Normalfall, nicht das Versäumnis.
Am Patchtag fiel die Quelle dann ganz aus. Ab 16:04 Uhr antwortete ppa.launchpadcontent.net mit 503, und das ist die unangenehme Sorte Ausfall: apt meldet dann nur Ign: und arbeitet still mit der alten Paketliste weiter. Wer nicht genau hinsieht, hält das für „keine Updates verfügbar". Als das PPA wieder erreichbar war, hatte es die Fixes noch nicht.
Die Pakete lagen zu diesem Zeitpunkt längst auf packages.sury.org, der Quelle, auf die Surý ohnehin umzieht. Spätestens um 16:25 Uhr waren sie dort, also gut 50 Minuten nach dem Release auf php.net. Nach dem Umstellen der Paketquelle um 16:47 Uhr dauerte das eigentliche Update zwei Minuten für 49 Pakete. Insgesamt waren die PHP-Seiten 80 Minuten offline.
Die Lehre daraus ist allgemeiner als dieser eine Tag: Eine zweite Bezugsquelle zu kennen, bevor man sie braucht, verkürzt eine Abschaltung um Stunden. Dass das kein hausgemachtes Problem war, zeigt dieselbe Statusseite: Auch bei maxcluster stand am Nachmittag der Entschluss, die Paketquellen zu wechseln, bevor die ersten Cluster wieder online gehen konnten. Und der Patch gehört per Changelog belegt, nicht per Versionsnummer, denn nur dort steht, ob die CVEs wirklich enthalten sind.
Was die offiziellen Meldungen über diese Maßnahmen sagen
Im Nachhinein lässt sich prüfen, ob die getroffenen Maßnahmen zu dem passen, was die Advisories empfehlen. Die beiden Lücken sind technisch sehr verschieden:
| CVE-2026-109618 | CVE-2026-109619 | |
|---|---|---|
| Kern | PHP springt bei Speichererschöpfung per longjmp() aus dem Kontext, während Anfragedaten halb initialisiert sind |
Ein Worker behält Zustand einer Anfrage, deren Speicher schon frei ist; eine spätere Anfrage im selben Worker greift darauf zu |
| Auslöser | präparierte multipart-Anfragen, noch vor dem ersten Skript | ein fataler Fehler als Auslöser, die nächste Anfrage im Worker als Opfer |
| am stärksten betroffen | mod_php (Exploit vorgeführt), FrankenPHP | alle persistenten Worker |
| Empfehlung von php.net | Multipart vor PHP blocken, kleines post_max_size |
session.upload_progress.enabled=0, Worker neu starten |
Beide Maßnahmen des Tages decken sich also mit den offiziellen Empfehlungen, jede für eine der Lücken. Zum Glücksfall gehört, dass mod_php auf diesem Server schon mittags entfernt worden war, und zwar aus einem völlig anderen Grund.
Aufräumen als Nebenwirkung
Das ist der eigentliche Ertrag eines solchen Tages. Die Suche nach allen PHP-Verbrauchern fördert zutage, was über Jahre liegen geblieben ist: Versionen ohne Sicherheitsupdates, ein geladenes mod_php, das keine Site mehr nutzt, Crons mit unversionierten Aufrufen. Zwei Fallen beim Entfernen lohnen die Erwähnung, weil sie teuer werden können.
php-common darf niemals in einer Purge-Liste stehen. Daran hängen sämtliche phpX.Y-* aller Versionen, auch der aktuellen. Und ein nachinstalliertes Zusatzpaket wie phpX.Y-cgi hebt die ganze Versionsfamilie mit, weil es dieselbe Paketversion von phpX.Y-common verlangt. Beides steht ordentlich im Probelauf, in einer Zeile wie „17 upgraded". Der nützlichste Satz des Tages ist deshalb unspektakulär: Den Probelauf lesen, nicht nur laufen lassen.
Zwei Stolperstellen zum Schluss, die erst nach dem Versionswechsel auffallen. Steht in einer deploy.php ein fest verdrahtetes systemctl reload php8.3-fpm, leert jeder Deploy weiterhin den OPcache der alten Version. Die Site liefert danach alten Code aus, ohne Fehler, ohne Hinweis. Und eine neue PHP-Version bringt ihre php.ini auf Werkseinstellung mit: Wer memory_limit und upload_max_filesize von Hand in der globalen Datei hochgesetzt hatte, fällt beim Wechsel unbemerkt auf 128M und 2M zurück. Solche Limits gehören je Site ins Hosting-Panel, nicht in die globale php.ini.
Checkliste für den nächsten PHP-Notfall
- Alle PHP-Verbraucher finden: FPM-Pools, CGI- und fcgid-Starter, mod_php, System-CLI, Crons
- Paketquelle prüfen: Kommt PHP automatisch? Gibt es eine zweite Quelle, falls die erste ausfällt?
- Notlösung je SAPI setzen, mit Gegenprobe im laufenden Pool
- Uploads vor PHP blocken, solange die Anwendung sie nicht braucht
- Wenn es ernst wird: PHP abschalten und gegen stilles Wiederanlaufen sichern
- Patch per Changelog belegen, nicht per Versionsnummer
- Sperren und Notlösung zurücknehmen, danach Gegenprobe über alle Sites
- Logs auf Spuren prüfen: abgestürzte Worker, Speichererschöpfung, fremde Dateien, Prozesse, Verbindungen
Wer so etwas nicht selbst herleiten will, am wenigsten unter Zeitdruck an einem Mittwochnachmittag, findet die passende Unterstützung bei com:werft: PHP-Migrationen für Shopware und Laravel, und vorab die Frage, welche Version eine Anwendung wirklich verträgt. Dass auch ohne akute Lücke genug zu tun ist, stand hier schon einmal, beim stillgelegten WordPress und den Gegenmaßnahmen dazu.
Auch interessant
Entwicklung
SVG-Diagramme selbst erstellen und mit Alpine.js pimpen
Die Diagramme in meinen Tour- und Flugbeiträgen kommen ohne Diagramm-Bibliothek aus. Die Seite bringt die fertige Zeichnung schon mit, statt sie im Browser bauen zu lassen. Was das kostet, was es einbringt und wo die Grenze liegt.
Entwicklung
Was ein stillgelegtes WordPress an Angriffen anzieht
Zwei alte WordPress-Seiten, seit Jahren inhaltlich eingefroren, bekamen in vier Wochen fast 53.000 Anfragen auf Login- und Schnittstellen-Pfade. Zwei Drittel davon wurden beantwortet. Nach der Umstellung auf statische Seiten: keine einzige mehr.
Entwicklung
Zwei WordPress-Seiten an einem Nachmittag abgelöst
mageconsult.de und magelounge.de liefen seit Jahren auf WordPress, ohne dass dort noch etwas passierte. An einem Nachmittag im August habe ich beide durch statische Seiten ersetzt, mit Claude Code als Werkzeug. Was dabei schnell ging, was nicht, und warum die Code-Menge nicht der Engpass war.