Security Research

PHP2Shell: Eine technische Analyse

Zwei Fehler im PHP-Interpreter, nicht in der Anwendung. Was die Advisories zu CVE-2026-109618 und CVE-2026-109619 wirklich sagen.

Der Interpreter ist die Fläche

PHP2Shell ist kein Plugin-Bug und kein vergessenes eval. Es sind zwei Fehler in PHP selbst, beide mit CVSS v4.0 9.2, beide als Remote Code Execution beschrieben. Ich lese hier die öffentlichen Advisories, nicht ein Labor-Protokoll von mir.

  1. Oktober 2026 · dotbinKing

Was der Name meint

PHP2Shell ist die öffentliche Bezeichnung für zwei am 21. Oktober 2026 veröffentlichte Lücken im PHP-Interpreter:

Beide bewertet PHP mit CVSS v4.0 9.2. Beide sitzen unter der Anwendung. Ein sauberes Laravel, WordPress oder ein eigenes Framework schützt nicht, solange der Interpreter die betroffene Version ist.

Das Update ist der einzige vollständige Fix. Die Config-Schalter schließen einzelne Wege, nicht die Klasse.

Quellen, auf die ich mich stütze: die Security-Archive-Notiz auf php.net vom 21. Oktober 2026 und die beiden GHSA-Advisories im php-src-Repository.


Zwei Nummern, eine Klasse

Es sind zwei Zeitpunkte, nicht ein Mechanismus mit zwei Namen.

CVE-2026-109618 bleibt im selben Request. longjmp() bei einem Out-of-Memory lässt halb initialisierten Request-Speicher stehen. Das Shutdown dieses Requests behandelt den Rest als gültig.

CVE-2026-109619 reicht in den nächsten Request. Ein Fatal Error lässt Zeiger auf freigegebenen Request-Speicher stehen. Der nächste Request desselben Workers benutzt sie wieder.

CVE Advisory-Titel GHSA Wann es schiefgeht
CVE-2026-109618 RCE via OOM corruption GHSA-w8hw-w9fx-j79w Shutdown desselben Requests nach longjmp()
CVE-2026-109619 RCE via stale global state GHSA-6wmf-4rv5-p3rp Nächster Request, derselbe Worker

Das ist kein Fehler in Anwendungs-PHP. Es ist Speicherverwaltung im Interpreter. Die Advisories ordnen beide als CWE-416 (Use After Free) ein.


CVE-2026-109618: das Limit mitten im Aufbau

memory_limit bricht einen Request ab, sobald das Limit erreicht ist. Das ist Absicht. Das Problem ist, wie der Abbruch den Speicher verlässt.

Trifft das Limit mitten in der Initialisierung, springt PHP per longjmp() aus dem Aufbau. Der Request-Speicher ist dann nur teilweise initialisiert. Das Shutdown desselben Requests läuft trotzdem und behandelt diesen Rest als gültigen Zustand. Es gibt hier keinen zweiten Request, der die Reste erbt.

Wo der Weg liegt

Das Advisory beschreibt den Weg so:

Vereinfacht, als Klasse und nicht als Ablauf zum Nachbauen:

/* Klasse: longjmp bei OOM, Shutdown desselben Requests. */
void on_oom(void) {
    /* Request-Speicher nur halb initialisiert. */
    longjmp(bailout, 1);
}

void shutdown_same_request(void) {
    /* Cleanup behandelt den halben Zustand als gültig. */
    cleanup_as_if_initialized(request_state);
}

Daraus macht das Advisory Remote Code Execution, und je nach Lage auch ein Leaken von Speicher. Beides folgt aus dem halbfertigen Zustand im selben Shutdown, nicht aus einem Folge-Request.


CVE-2026-109619: der nächste Request erbt den Müll

Hier ist der Auslöser ein Fatal Error, nicht zwingend das Speicherlimit. Der Fehler hinterlässt Zeiger, die auf Speicher zeigen, der zum Request gehörte und danach freigegeben wird. Der Worker bleibt. Der nächste Request auf diesem Worker benutzt dieselben globalen Felder wieder.

Ein bestätigter Produzent in den Advisories ist session.upload_progress.

session.upload_progress läuft in der Pre-Init-Phase, bevor die Anwendung Code ausführt. Endet der vorige Request fatal, zeigen request-eigene Globals noch auf Speicher, der mit dem Heap freigegeben wird. Der nächste Request auf demselben Worker kann diese Globals in genau dieser frühen Phase lesen. Das Advisory nennt dafür session.upload_progress und INI-Diagnosen als Konsumenten.

Auch hier nur die Klasse:

/* Klasse: der nächste Request sieht einen Zeiger aus dem vorigen. */
static void *stale;

void on_fatal(void) {
    stale = request_owned_pointer; /* Request-Speicher wird danach frei. */
}

void next_request_on_same_worker(void) {
    use_as_if_valid(stale);
}

Deshalb reicht es nicht, in der Anwendung keine Sessions zu benutzen. Der Schalter sitzt im Interpreter und ist vor dem ersten Zeile Anwendungscode schon aktiv.


Was die Releases ändern

Offizielle Fixes, laut php.net und den GHSA-Texten:

Zweig Fix
8.5 8.5.12
8.4 8.4.27
8.3 8.3.36
8.2 8.2.35

PHP 8.1 und älter bekommen kein neues Release auf php.net. Für 7.4, 8.0 und 8.1 gibt es nur minimale Source-Backports. Wer diese Zweige noch betreibt, hängt an der Distribution oder muss selbst bauen. Ein apt upgrade ohne gepatchtes Paket ändert daran nichts.

Die beiden Patches tun nicht dasselbe.

  1. CVE-2026-109618 nimmt longjmp() beim OOM weg. Stattdessen kommt ein weiches OOM am nächsten sicheren Punkt der VM, und ein hartes _exit() an einer zweiten Schwelle. Der halbe Zustand erreicht das Shutdown desselben Requests nicht mehr auf dem alten Weg.

  2. CVE-2026-109619 setzt request-eigene Globals zurück, bevor der Heap freigegeben wird. Ein späterer Request kann sie in der Pre-Init-Phase nicht mehr benutzen. session.upload_progress und INI-Diagnosen sind die Konsumenten, die das Advisory nennt.

Danach ist der Worker wieder ein Worker und nicht ein Träger von Resten. Deshalb gehört nach dem Paket-Update ein Neustart der PHP-FPM-Pools und der Apache-Worker dazu. Ein laufender Prozess lädt die alte libphp nicht von selbst neu.


Was die Schalter nicht ersetzen

PHP nennt Abschwächungen und sagt dazu, dass sie einzeln unvollständig sind. Ich würde sie nur als Brücke bis zum Paket behandeln, und nur dort, wo die Direktive vor dem Request-Parsing gilt. ini_set() in der Anwendung kommt zu spät: der Interpreter hat den betroffenen Pfad dann schon angefasst.

; Wirkt nur, wenn es vor dem Request gilt: php.ini oder php_admin_value.
; Nicht per ini_set() in der Anwendung.
session.upload_progress.enabled = 0

; Nur PHP 8.5, ebenfalls vor dem Request, danach Worker neu starten.
fatal_error_backtraces = 0

Was jeder Schalter schließt

Was das jeweils schließt, und was nicht:

pm.max_requests=1 bei FPM verkürzt die Lebensdauer eines Workers. Für CVE-2026-109619 bricht das die Bedingung „derselbe Worker, nächster Request“. CVE-2026-109618 braucht diesen zweiten Request nicht. Der Schalter senkt den Durchsatz und bleibt ein Teilweg.


Wen ich danach anfasse

Alles, was HTTP nach PHP reicht: mod_php, PHP-FPM, php-cgi, FrankenPHP, LiteSpeed. PHP selbst stuft mod_php als höchstes Risiko ein, weil der erste CVE dort vor dem Anwendungscode liegt. FPM ist nicht ausgenommen.

Praktisch heißt das für mich:

Öffentliche Meldungen sprechen von Angriffsversuchen rund um die Veröffentlichung. Eine bestätigte, flächige Kompromittierung habe ich daraus nicht als eigene Feststellung übernommen. Der vollständige Fix bleibt das Update.


Fazit

PHP2Shell sind zwei Fehler in der Speicherverwaltung des Interpreters. CVE-2026-109618: longjmp() bei einem OOM lässt halb initialisierten Speicher stehen, und das Shutdown desselben Requests behandelt ihn als gültig. CVE-2026-109619: nach einem Fatal Error zeigen Globals auf freigegebenen Request-Speicher, den der nächste Request desselben Workers in der Pre-Init-Phase wiederverwendet. Beide sind CVSS v4.0 9.2, beide sind RCE in den Advisories, beide sind mit 8.5.12, 8.4.27, 8.3.36 und 8.2.35 geschlossen.

Die Schalter session.upload_progress.enabled=0 und, auf 8.5, fatal_error_backtraces=0 sind Brücken. Sie gelten nur, wenn sie vor dem Request-Parsing sitzen, und erst nach einem Worker-Neustart. Der vollständige Fix ist die gepatchte Version.