Im PHP-Kern: Wie der RFC1867-Upload-Callback Dangling Pointer erzeugt (CVE-2026-109619)
Im PHP-Kern: Wie der RFC1867-Upload-Callback Dangling Pointer erzeugt (CVE-2026-109619)
<time datetime="2026-10-23">Geprüft am 23. Oktober 2026</time> · Betrachtete Dateien: ext/session/session.c, main/rfc1867.c
CVE-2026-109619 gehört zu den subtilsten Use-After-Free-Fehlern der Zend Engine. Ursache ist eine Lebensdauer-Diskrepanz zwischen dem langlebigen Worker-Prozess und den request- beziehungsweise session-gebundenen Heap-Allokationen. Ein PHP-FPM-Worker verarbeitet tausende Requests; seine globalen Variablen überleben jeden einzelnen davon. Die Session-HashTable und ihre zval-Einträge hingegen werden beim Session-Reset freigegeben. Ein Zeiger, der die eine Lebensdauer überlebt und auf die andere zeigt, wird zwangsläufig dangling.
1. Anatomie der RFC1867-Verfolgung
Der Multipart-Parser in main/rfc1867.c ruft bei jedem erkannten Upload-Fortschrittsfeld einen in ext/session/session.c registrierten Callback auf. Dieser legt einen Fortschrittseintrag in der Session-HashTable an und speichert die Adresse des internen zval in einer prozessweiten statischen Variablen.
/* ext/session/session.c – vereinfachtes Modell */
static zval *cached_post_bytes;
static zval *cached_file_bytes;
static int php_session_rfc1867_callback(unsigned int event,
void *event_data,
void **extra)
{
switch (event) {
case MULTIPART_EVENT_START: {
zval *progress = php_session_get_progress_entry();
ZVAL_LONG(&progress->value.bytes_processed, 0);
/* FEHLER: Rohadresse eines internen zval global cachen */
cached_post_bytes = &progress->value.bytes_processed;
cached_file_bytes = &progress->value.files[0].bytes_processed;
break;
}
case MULTIPART_EVENT_FILE_DATA:
Z_LVAL_P(cached_post_bytes) += chunk_len;
break;
}
return SUCCESS;
}
Es fehlen sämtliche Schutzmechanismen, die man von einer sicheren C-API erwarten würde: kein Z_TRY_ADDREF, keine Generationennummer, kein Invalidierungs-Callback, keine Weak-Reference. Der Zeiger ist aus Sicht des Zend-Speichermodells nicht besessen und nicht verfolgt. Das ist die Wurzel des Fehlers.
2. Speicherzustandsmaschine
[ PHASE 1: ALLOKATION UND ZEIGER-CACHE ]
┌─────────────────────────────────────────────────────────┐
│ Zend-MM-Heap-Bucket │
│ ┌──────────────────────┐ │
│ │ session_ht Entry │ <─── cached_post_bytes (global)│
│ │ [bytes_processed] │ zeigt auf gültiges zval │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
│ Session-Reset / neuer Request (session_reread)
▼
[ PHASE 2: FREIGABE OHNE INVALIDIERUNG ]
┌─────────────────────────────────────────────────────────┐
│ Zend-MM-Heap-Bucket │
│ ┌──────────────────────┐ │
│ │ [ FREIGEGEBEN ] │ <─── cached_post_bytes (STALE) │
│ │ zurück im FreeBin │ Dangling Pointer entsteht │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
│ Angreifer belegt Bucket neu
▼
[ PHASE 3: HEAP-BELEGUNG UND UAF-WRITE ]
┌─────────────────────────────────────────────────────────┐
│ Zend-MM-Heap-Bucket │
│ ┌──────────────────────┐ │
│ │ Angreifer-Daten │ <─── *cached_post_bytes += len │
│ │ Fake-zval-Struktur │ vorsätzliche Korruption │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Zend MM verwaltet Freispeicher in Größenklassen (Bins) und gibt Chunks LIFO zurück. Für einen Angreifer ist diese Determinismus ein Vorteil: Der zuletzt freigegebene Chunk wird als Nächstes wiederverwendet. Kann er die Freigabe auslösen und unmittelbar danach eine gleich große Allokation mit kontrollierten Daten erzwingen, landet sein Inhalt genau an der Stelle, auf die der globale Zeiger noch zeigt.
3. Ablauf des Dangling-Pointer-Triggers
- Session-Invalidierung:
session_reread()oderzend_hash_update()ersetzt den bestehenden Eintrag. - Freigabe ohne Benachrichtigung:
zval_ptr_dtorgibt den Chunk an Zend MM zurück. - Globale Zeiger bleiben:
cached_post_bytesundcached_file_byteswerden nicht zurückgesetzt. - UAF-Write: Der nächste Multipart-Chunk addiert
chunk_lenauf die freigegebene Adresse.
*cached_post_bytes += chunk_len; /* Schreiben in freigegebenen Speicher */
Auf den ersten Blick ist das nur eine additive Operation. Wenn der Angreifer den 16-Byte-Slot jedoch mit einer gefälschten zval-Struktur belegt hat, verändert die Addition type_info und gegebenenfalls value.ptr. Sobald die Zend VM diesen Slot als Objekt interpretiert, greift sie über handlers auf Funktionszeiger zu – der Übergang von Speicherkorruption zu Kontrollflussübernahme.
4. CVE-2026-109618: der parallele OOM-Pfad
CVE-2026-109618 nutzt keinen globalen Zeiger, erreicht aber denselben Zustand über die Fehlerbehandlung bei Speichererschöpfung.
- PHP verwendet bei Überschreitung von
memory_limiteinenlongjmp()-Bailout statt eines geordneten Return. - Während des RFC1867-Streamings werden Zwischenstrukturen schrittweise initialisiert; Allokationen und Initialisierung sind nicht atomar.
- Trifft ein OOM in eine teilinitialisierte Struktur, gibt die Cleanup-Routine Felder frei, die noch nicht vollständig gesetzt sind.
- Die ZMM-Bin-Metadaten geraten dadurch in einen inkonsistenten Zustand. Nachfolgende Allokationen können überlappen oder falsche Größenklassen erhalten.
Gemeinsam ist beiden CVE der gültige Schreibpfad in freigegebenen Speicher. Der Unterschied liegt im Auslöser: Session-Reset (UAF) versus Speichererschöpfung (OOM-Korruption).
5. Warum session_start() keine Rolle spielt
Viele Betreiber argumentieren, ihre Anwendung verwende keine Sessions und sei deshalb nicht betroffen. Das ist ein Trugschluss. Der Callback wird registriert, sobald die SAPI den Request-Body parst – unabhängig davon, ob Anwendungscode später session_start() aufruft. Ist session.upload_progress.enabled=On, bereitet PHP die Fortschrittsverfolgung bereits beim Eintreffen des Requests vor. Statische Seiten, API-Endpunkte und 404-Handler sind damit gleichermaßen exponiert.
6. Sichere Muster und der Upstream-Fix
Der Upstream-Fix beseitigt den globalen Cache und bindet die Lebensdauer an einen Request-Kontext mit Generationenprüfung.
/* konzeptionelle Fix-Richtung */
typedef struct {
zend_string *session_id;
zend_ulong generation;
zval *progress;
} php_rfc1867_ctx;
static void php_rfc1867_write(php_rfc1867_ctx *ctx, size_t chunk_len)
{
if (!ctx || !ctx->progress ||
ctx->generation != php_session_generation()) {
return; /* invalidierter Kontext: kein Write */
}
Z_LVAL(ctx->progress->value.bytes_processed) += chunk_len;
}
Für Betreiber, die nicht sofort patchen können, bleibt die Deaktivierung:
session.upload_progress.enabled = Off
php -v # muss 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 oder neuer sein
php-fpm -t
7. Beobachtbarkeit: Wie erkennt man einen UAF-Versuch?
Ein UAF hinterlässt selten saubere Logeinträge. Belastbare Signale entstehen erst durch Kombination mehrerer Quellen.
# ungewöhnlich kurze, wiederholte Multipart-POSTs
sudo tail -f /var/log/nginx/access.log | \
grep -E '"POST .*HTTP/(1\.1|2)" (200|404|413)' | \
awk '{print $1, $4, $7}' | sort | uniq -c | sort -rn | head
# abnormale Worker-Beendigungen
journalctl -u php8.3-fpm --since "1 hour ago" | \
grep -E 'segfault|SIGSEGV|SIGABRT|signal'
# Core-Dumps
coredumpctl list php-fpm --since "1 hour ago" 2>/dev/null
Auf Netzwerkebene helfen Suricata-Regeln auf PHP_SESSION_UPLOAD_PROGRESS; auf Host-Ebene erkennt Falco, wenn ein php-fpm-Kindprozess sh, curl oder wget startet. Die Signatur allein ist schwach – die Korrelation mit Worker-Abstürzen und ausgehenden Verbindungen macht sie belastbar.
8. Allokator-Details: Warum die Größenklasse entscheidend ist
Zend MM unterscheidet kleine Allokationen (Small Bins), mittlere (Large) und sehr große (Huge). Die Small Bins sind in 30-Byte-Schritten organisiert; ein zval mit 16 Byte fällt in die kleinste Klasse. Für den Angreifer ist das günstig, weil sich diese Slots mit Strings, Array-Einträgen oder Session-Werten leicht neu belegen lassen. Entscheidend ist die exakte Größe: Weicht die neue Allokation nur um wenige Bytes ab, landet sie in einer anderen Klasse und der freigegebene Slot bleibt unberührt.
Daraus folgen zwei praktische Beobachtungen für die Verteidigung. Erstens erhöht eine stark fragmentierte Heap-Landschaft die Erfolgswahrscheinlichkeit, weil der Angreifer viele gleich große Löcher findet. Langlebige Worker mit hoher Fragmentierung sind daher riskanter als kurzlebige. Zweitens reduziert pm.max_requests die Wahrscheinlichkeit, dass ein Angreifer über viele Requests hinweg eine stabile Belegung aufbaut. Ein Wert von 300 bis 500 ist ein sinnvoller Kompromiss zwischen Performance und Angriffsfläche. Bei sehr speicherintensiven Anwendungen kann ein niedrigerer Wert jedoch zu häufigen Neustarts und kalten OPcache-Segmenten führen.
Ein weiterer Aspekt ist die Rolle von session.save_path. Liegt das Session-Verzeichnis auf einem Netzwerkdateisystem mit hoher Latenz, verlängert sich das Zeitfenster zwischen Freigabe und Neubelegung. Das macht die Ausnutzung nicht unmöglich, erhöht aber die Streuung und damit die Anzahl notwendiger Versuche. Für Hochsicherheitsumgebungen kann ein lokales, flüchtiges Session-Backend (Redis mit kurzer TTL, tmpfs) die Vorhersagbarkeit verringern. Wichtig ist, die Session-ID nach jedem Login zu rotieren und absolute Timeouts zu setzen, damit ein Angreifer keine langlebige Session als Anker nutzen kann.
Schließlich sollte die Überwachung des Allokators nicht unterschätzt werden. Zend MM bietet in Debug-Builds zusätzliche Prüfungen, die freigegebene Chunks mit einem Muster füllen und Doppelfreigaben erkennen. In Produktions-Builds sind diese Prüfungen deaktiviert, weil sie Performance kosten. Für Staging-Umgebungen, in denen PHP2Shell-ähnliche Angriffe getestet werden, ist ein Debug-Build jedoch wertvoll: Er verwandelt einen stillen UAF in einen lauten Abbruch mit reproduzierbarer Stelle. Kombinieren Sie das mit USE_ZEND_ALLOC=0 und Valgrind, um den Schreibzugriff eindeutig zuzuordnen. Diese Werkzeuge ersetzen keinen Patch, liefern aber belastbare Beweise für Incident-Reports und für die Priorisierung von Gegenmaßnahmen.
9. Fazit
- Die Ursache ist ein global gespeicherter
zval*, der die Freigabe des Session-Eintrags überlebt. - Der
longjmp-Bailout bei OOM zerstört die Bin-Metadaten und erzeugt überlappende Chunks. - Beide CVE wirken vor der Ausführung von Anwendungscode; Applikationsfilter sind machtlos.
- Wirksam sind nur Patch (8.5.12 / 8.4.27 / 8.3.36 / 8.2.35) oder
session.upload_progress.enabled=Offsowie die Beobachtung über Netzwerk, Runtime und Kernel.
Wer die Ursache versteht, erkennt auch, warum reine WAF-Regeln und Body-Größenlimits scheitern: Der Angriff findet unterhalb der Ebene statt, auf der diese Kontrollen arbeiten.