Wie im letzten Beitrag angekündigt, war mein Blog offline, bis das PHP-Update für die beiden kritischen Lücken auf dem Server eingespielt ist. Das ist inzwischen passiert, der Blog ist wieder erreichbar. Seit dem Nachmittag des 21. Oktober sind auch die technischen Details öffentlich, heise security hat sie im Artikel php2shell: Kritische Sicherheitslücken erlauben Codeeinschleusung in PHP zusammengefasst. In diesem Beitrag geht es weniger um die Lücken selbst als darum, wie ich PHP am Tag der Veröffentlichung auf FreeBSD gepatcht habe, über welche Fallen ich dabei gestolpert bin und wie ich geprüft habe, ob vorher schon jemand drin war.
Was hinter php2shell steckt
Unter dem Namen php2shell laufen zwei Fehler im Kern von PHP, beide mit CVSS 9.2 bewertet. CVE-2026-109618 steckt in der Speicherverwaltung. Läuft ein Skript in das eingestellte memory_limit, bricht PHP es ab und räumt danach auf. Mit einem passend gebauten Upload oder tief verschachtelten Arrays gerät genau dieses Aufräumen durcheinander, und am Ende kann ein Angreifer eigenen Code mit den Rechten des Webservers ausführen. CVE-2026-109619 sitzt in der Fehlerbehandlung. Hier bringt eine erste Anfrage PHP in einen kaputten Zustand, den eine zweite Anfrage an denselben Worker ausnutzt.
Die Anwendung selbst muss dafür keinen Fehler haben, und eine Anmeldung braucht der Angreifer auch nicht. Ein Angriff klappt zwar nicht beim ersten Versuch, nach einigen tausend bis zehntausend Anfragen aber zuverlässig. Für automatisierte Massenscans ist das kein Hindernis. Laut Wiz ist jede PHP-Version der letzten zehn Jahre von mindestens einer der beiden Lücken betroffen, das PHP-Team spricht sogar von allen Versionen seit PHP 5. Die reparierten Versionen sind 8.5.12, 8.4.27, 8.3.36 und 8.2.35.
Mein WordPress läuft mit PHP 8.3 als PHP-FPM hinter nginx, zum Zeitpunkt der Meldung in Version 8.3.33. pkg audit -F hat das Paket direkt für beide CVEs als verwundbar gemeldet. Laut Advisory ist Apache mit mod_php nachweislich per HTTP angreifbar, FPM gilt nach Analyse des Quellcodes ebenfalls als anfällig, auch wenn dafür noch kein vollständiger Exploit gezeigt wurde. Darauf wollte ich nicht wetten. Also habe ich nginx gestoppt, noch bevor es einen Patch gab. PHP-FPM lauscht bei mir nur auf einem Unix-Socket und ist ohne nginx davor von außen nicht erreichbar. Damit war die Angriffsfläche weg, ohne dass ich an PHP selbst etwas ändern musste.
Ports statt Paket
Der erste Reflex auf FreeBSD ist pkg upgrade. Nur lag im Paket-Repository auch nach pkg update -f noch PHP 8.3.35, und das ist verwundbar. Im Ports-Tree sah es anders aus: Der Commit lang/php83: Update version 8.3.35=>8.3.36 war wenige Minuten nach dem Upstream-Release in main und kurz darauf auch im Quartalszweig 2026Q4. Bis daraus ein Binärpaket im Repository wird, vergeht deutlich mehr Zeit. Wer einen Port sauber bauen kann, muss bei solchen Lücken also nicht warten.
Ist das wirklich der richtige Fix?
Dass ein Port eine neue Versionsnummer hat, heißt noch nicht, dass darin der richtige Fix steckt. Bevor ich gebaut habe, habe ich deshalb zwei Dinge geprüft. Erstens die Prüfsumme: Die SHA256-Summe von php-8.3.36.tar.xz aus der distinfo des Ports muss mit der Angabe auf php.net übereinstimmen.
cat /usr/ports/lang/php83/distinfo curl -sS 'https://www.php.net/releases/index.php?json&version=8.3.36'
Beide Quellen nennen dd54693aa334c5c4d72e9aee96b082c2473750a18a6c1d105789c93a3b1692fd. Zweitens den Changelog: Der offizielle Changelog zu 8.3.36 enthält genau einen Eintrag, „Core: Fixed GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp (Avoid state corruption via bailout)“. GHSA-w8hw-w9fx-j79w ist genau das Advisory, auf das auch heise verweist, und „Bailout“ ist der Abbruchpfad, den PHP bei einem Fatal Error nimmt. Das passt zu beiden Lücken. Die dritte Bestätigung kam nach der Installation: pkg audit -F meldet für PHP nichts mehr, die VuXML-Datenbank von FreeBSD stuft 8.3.36 also ebenfalls als repariert ein.
Bauen und tauschen
Vor dem Umbau habe ich einen ZFS-Snapshot der Jail angelegt, damit ich bei Problemen in Sekunden zurück kann. Danach ging es grob so weiter, hier vereinfacht ohne den jexec-Präfix:
# Rollback-Punkt zfs snapshot zroot/jails/<jail>@pre-php2shell-$(date +%Y%m%d-%H%M) # Ports-Tree auf denselben Zweig wie das Paket-Repository cd /usr/ports && git fetch origin && git checkout 2026Q4 && git pull --ff-only # Optionen gegen das installierte Paket abgleichen pkg query '%Ok=%Ov' php83 make -C /usr/ports/lang/php83 showconfig # Bauen, dann tauschen make -C /usr/ports/lang/php83 -DBATCH clean build make -C /usr/ports/lang/php83 -DBATCH deinstall install
Wichtig waren dabei die Extensions. Auf FreeBSD sind die PHP-Kern-Extensions eigene Pakete wie php83-mysqli, php83-curl oder php83-opcache. Technisch hätten die alten Extensions auch mit dem neuen Kern funktioniert, weil sich die Modul-API innerhalb von 8.3 nicht ändert. Der Sprung von 8.3.33 auf 8.3.36 enthält aber auch alle Korrekturen aus 8.3.34 und 8.3.35, und die stecken zu einem großen Teil in Extensions wie mysqlnd, openssl, phar, soap und filter. Nur den Kern zu tauschen hätte diese Lücken offen gelassen. Ich habe deshalb alle 43 Kern-Extensions aus demselben Tree neu gebaut. PECL-Extensions wie redis, APCu oder imagick haben eigene Versionsnummern und blieben, wie sie waren.
for o in $(pkg query -g '%o %v' 'php83-*' | awk '$2 ~ /^8\.3\.33/ {print $1}' | grep -v '^lang/php83$'); do
make -C /usr/ports/$o -DBATCH clean build && make -C /usr/ports/$o -DBATCH deinstall install clean
done
Zum Schluss der Punkt, den man leicht vergisst: Ein neues Binary auf der Platte heißt nicht, dass der laufende Dienst es auch benutzt. PHP-FPM hatte noch das alte Programm im Speicher. Also neu starten und nachsehen, welches Binary der Prozess tatsächlich ausführt:
service php_fpm restart procstat -b $(cat /var/run/php-fpm.pid)
Drei Stolperfallen
Ganz glatt lief es nicht. Die drei Probleme, die mir begegnet sind, dürften auch auf anderen Systemen auftauchen.
Build-Abhängigkeiten aus den Quellen. Der erste Versuch wollte cmake-core aus den Quellen bauen, eine reine Build-Abhängigkeit von re2c, und brach dabei ab. Solche Abhängigkeiten laufen nur während des Bauens und müssen nicht gepatcht sein. Mit make missing sieht man, was fehlt, und kann es einfach als Binärpaket per pkg install nachziehen.
deinstall install entfernt das PHP, das der Port selbst braucht. Beim Erzeugen der Paketliste ruft der Port ein php aus dem PATH auf. Nach dem deinstall gab es keins mehr, die Installation brach mit php: not found ab, und PHP war für ein paar Minuten gar nicht installiert. Da nginx ohnehin aus war, hatte das keine Außenwirkung. Gelöst habe ich es, indem ich das frisch gebaute CLI-Binary für diesen einen Aufruf vorne in den PATH gestellt habe:
PATH=/usr/ports/lang/php83/work/php-8.3.36/sapi/cli:$PATH make -C /usr/ports/lang/php83 -DBATCH install
Beim nächsten Mal lasse ich zuerst make stage laufen, solange das alte PHP noch installiert ist, und tausche erst danach.
Andere OpenSSL-Basis, anderes CA-Bundle. Das war die unangenehmste Falle, weil alle Prüfungen grün waren. Das alte PHP aus dem Paket-Repository war gegen das OpenSSL des Basissystems gebaut und nutzte dessen Zertifikatsspeicher /etc/ssl/cert.pem. Mein selbst gebautes PHP ist wegen DEFAULT_VERSIONS+=ssl=openssl40 in der /etc/make.conf gegen das OpenSSL aus den Ports gebaut und sucht seine CA-Zertifikate unter /usr/local/openssl/cert.pem. Diese Datei gab es nicht. Die Folge war, dass jede ausgehende TLS-Verbindung aus PHP mit Zertifikatsprüfung scheiterte. Aufgefallen ist das an einer PHP-Anwendung, die per TLS mit einem IMAP-Server spricht und nur noch „Connection to storage server failed“ meldete. nginx -t, php -m und php-fpm -t waren alle zufrieden, erst der Funktionstest hat es gezeigt.
php -r 'var_dump(openssl_get_cert_locations());' # default_cert_file => /usr/local/openssl/cert.pem ln -s /etc/ssl/cert.pem /usr/local/openssl/cert.pem
Alternativ lässt sich der Pfad auch per openssl.cafile in der php.ini setzen.
War schon jemand drin?
Nach dem Patch bleibt die Frage, ob die Lücke vorher schon ausgenutzt wurde. Hier hilft, dass der Angriff laut ist: Er braucht tausende Anfragen, die jeweils einen memory_limit-Abbruch oder einen Fatal Error auslösen. Selbst ein am Ende erfolgreicher Angriff hinterlässt also vorher viele Fehlversuche. Ich habe den gesamten vorhandenen Log-Zeitraum von 15 Tagen durchsucht, rotierte und mit bzip2 gepackte Logs eingeschlossen:
# Abstürze von FPM-Workern for f in /var/log/php-fpm.log*; do bzcat -f "$f"; done | grep -E 'signal|SIGSEGV|core dumped' # Speicher- und Fatal-Errors, PHP-FPM ohne eigenes error_log schreibt ins nginx-Errorlog for f in /var/log/nginx/*error.log*; do bzcat -f "$f"; done | grep -aE 'Allowed memory size|PHP Fatal' # Neue oder geänderte PHP-Dateien im Webroot find /usr/local/www -type f \( -name '*.php*' -o -name '*.phar' -o -name '.user.ini' \) -newermt 2026-10-14
Das Ergebnis:
- Keine abgestürzten FPM-Worker, kein
SIGSEGV, kein Core-Dump. - Kein einziges
Allowed memory sizeund keinPHP Fatal, weder im FPM-Log noch in den nginx-Errorlogs. - Keine Häufung von 5xx-Antworten und keine einzelne Quelle mit auffällig vielen POST-Anfragen. Die meisten POSTs kamen von der ActivityPub-Federation und von
wp-cron. - Keine fremden Prozesse und keine unbekannten ausgehenden Verbindungen des Webserver-Benutzers, die Crontab enthält nur bekannte Einträge.
- Im Webroot nur fünf neue WordPress-Sprachdateien. Ihr Zeitstempel passt zu einem
wp-cron-Lauf im Access-Log, und der Inhalt ist das übliche<?php return [...]ohneeval,base64_decodeoder Zugriffe auf Superglobals. In den Upload-Verzeichnissen liegt gar keine PHP-Datei.
Der einzige Treffer mit Exploit-Charakter war ein Massenscanner, der den alten pearcmd-Trick gegen PHP-CGI ausprobiert hat. Das ist eine ganz andere, alte Lücke, hat mit php2shell nichts zu tun und läuft gegen PHP-FPM ins Leere.
Zur Ehrlichkeit gehören auch die Grenzen dieser Prüfung. Access-Logs enthalten keine POST-Bodies, ein einzelner, gezielter Treffer mit wenigen Anfragen wäre so nicht zu erkennen. Wegen der nötigen Menge an Anfragen halte ich das aber für unwahrscheinlich. Außerdem reichen meine Logs nur 15 Tage zurück. Eine erfolgreiche Codeausführung hätte mit den Rechten des Webserver-Benutzers stattgefunden, die Logs gehören root und hätten von dort aus nicht verändert werden können. Mein Fazit ist deshalb: Es gibt keinen Hinweis auf Ausnutzungsversuche oder eine Kompromittierung. Das heißt „sehr unwahrscheinlich“, nicht „bewiesen ausgeschlossen“.
Was du prüfen solltest
Das PHP-Team hat zusätzlich Konfigurationsänderungen veröffentlicht, die den Angriff erschweren sollen, etwa memory_limit=-1, ein kleines post_max_size, ein niedriges max_input_nesting_level und bei FPM ein niedriges pm.max_requests. Das Team nennt das selbst eine temporäre Krücke. Diese Einstellungen können Funktion oder Leistung kosten, und keine davon schließt die Lücke. Komplett hilft nur das Update. Denk dabei auch an PHP, das du nicht direkt siehst:
- Docker-Container bringen ihr eigenes PHP mit. Ein Update des Hosts hilft da nicht, das Image muss neu gezogen und der Container neu gestartet werden.
- Geräte mit Weboberfläche wie pfSense, OPNsense, NAS-Systeme von Synology oder QNAP und viele Router nutzen PHP für ihre Verwaltung. Hier bist du auf Updates des Herstellers angewiesen. Bis dahin gehört so eine Oberfläche erst recht nicht ins Internet.
- Gehostete Seiten hängen vom Hoster ab. Schau nach, ob er das Update schon eingespielt hat oder einen Termin nennt.
- Alte PHP-Versionen bekommen teilweise Backports, das ist aber eine gute Gelegenheit, mindestens auf PHP 8.3 zu wechseln. PHP 8.2 bekommt nur noch bis Ende 2026 Sicherheitsupdates.
- Auf FreeBSD lohnt sich bei kritischen Lücken der Blick in den Ports-Tree, bevor man auf das Paket wartet. Prüf dann aber auch, gegen welches OpenSSL der Port gebaut wird.
Mein Fazit
nginx abzuschalten war die einfachste Maßnahme, die garantiert wirkt, und ein paar Stunden ohne Blog tun niemandem weh. Das eigentliche Patchen hat mich mehr gelehrt als die Lücke selbst: Eine neue Versionsnummer ist noch kein Beweis für den richtigen Fix, ein neues Binary auf der Platte noch kein gepatchter Dienst, und eine grüne Konfigurationsprüfung noch kein funktionierendes System. Erst der Funktionstest und der Blick in die Logs geben am Ende ein ehrliches Bild.
