Der Merkzettel, der ins Leere zeigt
Zwei vergessene Zeiger in PHP führen dazu, dass der Server in längst freigegebenen Speicher liest und schreibt — und ein Angreifer daraus im schlimmsten Fall einen Befehl auf dem Server macht. Hier Schritt für Schritt erklärt, auch ohne C-Kenntnisse.
Auf einen Blick
| Komponente | Der Upload-Fortschritt von PHP-Sessions (php_session_rfc1867_progress in session.c). Er schreibt mit, wie viel eines Datei-Uploads schon angekommen ist. |
|---|---|
| Voraussetzung | Der Fortschritt muss eingeschaltet sein — die php.ini-Einstellung session.upload_progress.enabled ist standardmäßig an. |
| Auslöser | Ein Upload, bei dem der Fortschritt mehr als einmal aktualisiert wird: mehrere Dateien in einem Formular, oder eine große Datei, die in mehreren Stücken ankommt. |
| Nicht betroffen | Installationen, bei denen der Upload-Fortschritt deaktiviert ist. |
| CVE | CVE-2026-109619 (Gesamtzähler) und CVE-2026-109618 (Datei-Zähler) — zwei eng verwandte Bugs. |
| Codestand | Verweise beziehen sich auf session.c (main) und in Klammern auf PHP 8.4.4. |
| Versionen & Fix | Die genaue betroffene Versionsspanne und die gepatchte Version stehen im offiziellen PHP-Security-Advisory. Diese Übersicht erklärt den Mechanismus, sie ist keine Versionsreferenz. |
Teil A · Die Grundlagen — für alle verständlich
Wenn diese vier Wörter sitzen, versteht man den ganzen Bug. Mehr C braucht es nicht.
Zeiger
Ein Zettel, auf dem eine Speicheradresse steht — nicht der Wert selbst, nur „liegt an Stelle X“. Programme benutzen Zeiger als Abkürzung, um nicht jedes Mal neu zu suchen.
Speicher freigeben
Wenn das Programm Daten nicht mehr braucht, gibt es ihren Platz zurück. Die Daten sind weg, der Platz wird wiederverwendet — das Nächste, was anfällt, landet dort.
Referenzzähler
PHP zählt für jeden Wert mit: „Wie viele benutzen das gerade?“ Fällt der Zähler auf null, wird freigegeben. Zählt jemand falsch, wird zu früh freigegeben.
Use-after-free
„Benutzung nach dem Freigeben“. Ein Zeiger wird noch benutzt, obwohl der Platz dahinter längst zurückgegeben und neu belegt wurde. Genau das passiert hier.
Lädt ein Browser eine Datei hoch, kommt sie nicht in einem Rutsch an, sondern häppchenweise. Damit eine Seite „47 % geladen“ anzeigen kann, schreibt PHP den Zwischenstand laufend in die Session. Diese Buchführung erledigt die Funktion php_session_rfc1867_progress (benannt nach RFC 1867, dem alten Standard für Datei-Uploads).
Während ein Upload läuft, ruft PHP diese Funktion zu festen Zeitpunkten auf — man kann sie sich als drei Ereignisse vorstellen:
- FILE_START — eine neue Datei beginnt.
- FILE_DATA — ein weiteres Stück dieser Datei ist angekommen (kann sehr oft feuern).
- FILE_END — die Datei ist vollständig.
Bei jedem dieser Ereignisse müssen Zähler hochgesetzt werden: wie viele Bytes insgesamt, und wie viele für die aktuelle Datei. Und genau beim Zugriff auf diese Zähler nimmt PHP eine Abkürzung.
Statt die Zähler jedes Mal neu in der Session nachzuschlagen, merkt sich PHP einmal ihre Speicheradresse. Dafür hält die Struktur php_session_rfc1867_progress (in php_session.h) zwei rohe Zeiger, die mitten in die lebende Session-Datenstruktur hineinzeigen:
post_bytes_processed
Zeigt auf den Gesamtzähler — wie viele Bytes über den ganzen Upload insgesamt angekommen sind.
current_file_bytes_processed
Zeigt auf den Zähler der aktuellen Datei — wie viel von genau dieser einen Datei schon da ist.
Stell dir vor, du schreibst die Wohnungsnummer eines Freundes auf einen Zettel, damit du sie nicht jedes Mal nachschlagen musst. Solange dein Freund dort wohnt, spart das Zeit. Der Zettel ist aber nur so lange gültig, wie an der Adresse wirklich dein Freund wohnt.
Zieht er aus und es zieht jemand anderes ein, zeigt dein Zettel immer noch auf dieselbe Tür — nur wohnt dahinter jetzt jemand Fremdes. Genau das wird den beiden Zeigern passieren.
Teil B · Der Fehler im Detail
So läuft die Buchführung während eines Uploads ab. Wichtig ist, wer die Merkzettel schreibt und wer sie später benutzt:
- FILE_STARTMerkzettel setzen. Beim Start einer Datei notiert PHP die aktuellen Adressen der beiden Zähler (
session.c:3091bzw. 8.4.4:3316 für den Gesamtzähler,session.c:3117/ 8.4.4:3342 für den Datei-Zähler). - FILE_DATADurch die Merkzettel schreiben. Für jedes angekommene Stück schreibt PHP über die gemerkten Adressen (
session.c:3355–3356) — ohne noch einmal nachzuschlagen. - FILE_ENDNoch einmal schreiben. Am Dateiende wird über den äußeren Zeiger geschrieben (
session.c:3375). - UPDATELesen. Beim Aktualisieren des Fortschritts wird der Zeiger auch gelesen (
session.c:3188).
Solange die Session-Datenstruktur unverändert bleibt, ist das harmlos. Das Problem beginnt, wenn PHP diese Struktur zwischendurch wegräumt und neu aufbaut — denn die Merkzettel werden dabei nicht aktualisiert.
Beide Schwachstellen sind derselbe Patzer. Der einzige Unterschied: wie oft der jeweilige Merkzettel überhaupt neu geschrieben wird.
Bug 1 — post_bytes_processed
Wird nur ein einziges Mal notiert — beim allerersten FILE_START (session.c:3091 / 8.4.4:3316). Bei jedem späteren FILE_START wird er nicht aufgefrischt.
Der Gesamtzähler-Zeiger bleibt also über den ganzen Upload hinweg auf der allerersten Adresse stehen.
Bug 2 — current_file_bytes_processed
Wird bei jedem FILE_START aufgefrischt (session.c:3117 / 8.4.4:3342) — aber eben nur dann.
Innerhalb einer Datei bleibt er über alle FILE_DATA-Stücke hinweg auf derselben Adresse stehen. Das reicht für ein Zeitfenster, in dem er veraltet.
In beiden Fällen entsteht ein Moment, in dem der Merkzettel „Adresse X“ sagt, X aber nicht mehr gültig ist.
Beim zweiten update() — nachdem genug Daten für eine Aktualisierung zusammengekommen sind — liest php_session_initialize() die Session-Datei erneut ein und baut den kompletten Datenbaum neu auf. Dabei passiert dreierlei:
php_session_track_init()(session.c:244) wirft den vorherigen dekodierten Baum weg.zend_hash_update(session.c:3214) ersetzt ihn durch den frisch dekodierten Baum und ruft dabei den Aufräumer der Hash-Tabelle,zval_ptr_dtor, auf.zval_ptr_dtorgibt den Speicher des alten Baums an das System zurück. Ab jetzt ist er frei — aber die beiden Merkzettel zeigen noch hinein.
Warum wird der Speicher überhaupt freigegeben, obwohl noch jemand darauf zeigt? Das ist der Referenzzähler-Fehler: add_next_index_zval (zend_hash.c:1192) und add_assoc_zval_ex (zend_API.c) kopieren den Wert per ZVAL_COPY_VALUE — ohne den Referenzzähler zu erhöhen. Dadurch sind die inneren Arrays (current_file[] und files[]) doppelt eingehängt, aber nur mit Zählerstand 1. Wird der alte Baum abgebaut, fällt der Zähler auf null, und die Arrays werden freigegeben — obwohl ein gemerkter Zeiger noch darauf zielt.
Ein abgestürztes PHP wäre ärgerlich, aber harmlos. Das eigentliche Problem: Freigegebener Speicher bleibt nicht leer — er wird wiederverwendet. Der stale Zeiger wird später noch gelesen (session.c:3188) und beschrieben (FILE_DATA :3355–3356, FILE_END :3375). Gelingt es, die freigegebene Stelle mit anderen Daten zu belegen, dann landen diese Lese- und Schreibzugriffe nicht mehr dort, wo sie sollten.
Die Eskalation — bewusst grob gehalten
Ein Zeiger, über den man sowohl lesen als auch schreiben kann, ist in C der Schlüssel zum ganzen Prozess. In der Fachliteratur führt eine solche Kette am Ende über interne Zend-Aufrufmechanik bis zu einem Befehl auf dem Server (Remote Code Execution).
zend_call_function→system()Die Lektion: Ein use-after-free, bei dem über den baumelnden Zeiger gelesen und geschrieben wird, ist nicht nur ein Absturz-Bug — er reicht aus, um die Kontrolle über den ganzen Prozess zu verlieren. Deshalb werden beide CVEs als kritisch eingestuft.
Teil C · Einordnung & Behebung
Ausnutzbar ist das Ganze nur unter zusammentreffenden Bedingungen — aber keine davon ist exotisch:
- Der Upload-Fortschritt ist aktiv (
session.upload_progress.enabled— Standard: an). - Sessions werden benutzt (praktisch jede Anwendung mit Login).
- Ein Angreifer kann ein Upload-Formular ansprechen und den Upload so stückeln, dass der Fortschritt mehrfach aktualisiert wird.
Da die ersten beiden Punkte auf sehr viele Standard-Installationen zutreffen, ist die praktische Angriffsfläche groß. Wer den Patch nicht sofort einspielen kann, kann als Sofortmaßnahme session.upload_progress.enabled in der php.ini auf 0 setzen — das schließt beide Lücken, solange keine Anwendung die Fortschrittsanzeige zwingend braucht.
Ein zwischengespeicherter Zeiger ist ein Versprechen: „das, worauf ich zeige, lebt länger als ich.“ Hier wurde das Versprechen gebrochen. Zwei saubere Wege führen heraus:
- Immer auffrischen, sobald sich die Struktur ändern kann — also bei jedem FILE_START und nach jedem Neuaufbau des Baums, nicht nur beim ersten Mal. Das behebt beide Bugs direkt an ihrer Ursache.
- Gar nicht merken, sondern den Zähler jedes Mal frisch über seinen Schlüssel in der Session nachschlagen. Ein kaum messbarer Geschwindigkeitsverlust gegen einen ganzen Schwachstellen-Typ — ein guter Tausch.
Der eigentliche Bug ist kein exotischer Speicher-Trick, sondern eine Frequenz-Frage: Der Merkzettel wurde zu selten neu geschrieben. Wer das versteht, versteht die ganze Klasse solcher Fehler.
zval & HashTable
Ein zval ist PHPs interner Behälter für einen einzelnen Wert. Eine HashTable ist die interne Form eines PHP-Arrays — eine Tabelle, die Schlüssel auf Werte abbildet. Der „Datenbaum“ der Session besteht aus ineinander verschachtelten HashTables.
Zeiger & baumelnder Zeiger (dangling pointer)
Ein Zeiger speichert eine Speicheradresse. „Baumelnd“ heißt er, wenn die Adresse auf Speicher zeigt, der bereits freigegeben wurde — der Zettel mit der alten Wohnungsnummer.
Referenzzähler & ZVAL_COPY_VALUE
PHP zählt mit, wie viele Stellen einen Wert benutzen; bei null wird freigegeben. ZVAL_COPY_VALUE kopiert einen Wert, ohne diesen Zähler zu erhöhen — korrekt nur, wenn das Original danach verschwindet. Hier bleibt es bestehen, und der zu niedrige Zählerstand führt zur verfrühten Freigabe.
Use-after-free
Der Zugriff auf Speicher, nachdem er freigegeben wurde. Lesen liefert fremde Daten; Schreiben verändert fremde Daten. Beides zusammen ist eine der mächtigsten Fehlerklassen in C.
Remote Code Execution (RCE)
Der schwerste Ausgang: ein Angreifer bringt den Server dazu, eigenen Code oder Befehle auszuführen — hier am Ende der Kette über zend_call_function bis system().
Erklärung zu zwei Use-after-free-Schwachstellen im Upload-Fortschritt von PHP-Sessions (CVE-2026-109619, CVE-2026-109618).
Der Exploit-Teil ist bewusst nur grob skizziert. Genaue Versionsspanne und Fix: offizielles PHP-Security-Advisory.
The note that points at nothing
Two forgotten pointers in PHP let the server read and write in long-freed memory — and in the worst case let an attacker turn that into a command on the server. Explained step by step, no C knowledge required.
At a glance
| Component | PHP session upload progress (php_session_rfc1867_progress in session.c). It records how much of a file upload has arrived. |
|---|---|
| Requirement | Progress tracking must be on — the php.ini setting session.upload_progress.enabled is on by default. |
| Trigger | An upload where progress is updated more than once: several files in one form, or one large file arriving in several chunks. |
| Not affected | Installations with upload progress disabled. |
| CVE | CVE-2026-109619 (total counter) and CVE-2026-109618 (per-file counter) — two closely related bugs. |
| Code reference | References point to session.c (main), with PHP 8.4.4 lines in brackets. |
| Versions & fix | The exact affected version range and the patched release are in the official PHP security advisory. This overview explains the mechanism; it is not a version reference. |
Part A · The basics — for everyone
Get these four words and you get the whole bug. No more C than this.
Pointer
A note with a memory address on it — not the value itself, just "it lives at spot X". Programs use pointers as a shortcut so they don't have to search every time.
Freeing memory
When the program no longer needs some data, it hands the space back. The data is gone and the space gets reused — the next thing that comes along lands there.
Reference count
PHP keeps a tally for each value: "how many places use this right now?" When it hits zero, the value is freed. Count wrong and it gets freed too early.
Use-after-free
Using something after it was freed. A pointer is still used even though the space behind it was handed back and re-occupied. That's exactly what happens here.
When a browser uploads a file, it doesn't arrive all at once but in pieces. So a page can show "47% received", PHP keeps writing the running total into the session. That bookkeeping is done by php_session_rfc1867_progress (named after RFC 1867, the old standard for file uploads).
While an upload runs, PHP calls this function at fixed moments — think of them as three events:
- FILE_START — a new file begins.
- FILE_DATA — another chunk of that file has arrived (can fire very often).
- FILE_END — the file is complete.
At each of these, counters have to be bumped: how many bytes in total, and how many for the current file. And it's exactly when reaching those counters that PHP takes a shortcut.
Instead of looking the counters up in the session every time, PHP records their memory address once. For that, the struct php_session_rfc1867_progress (in php_session.h) holds two raw pointers reaching straight into the live session data structure:
post_bytes_processed
Points at the total counter — how many bytes have arrived across the whole upload.
current_file_bytes_processed
Points at the current file's counter — how much of this one file is in so far.
Imagine writing a friend's apartment number on a note so you don't have to look it up each time. While your friend lives there, it saves time. But the note is only valid as long as your friend actually lives at that address.
If they move out and someone else moves in, your note still points at the same door — only now a stranger lives behind it. That's exactly what happens to the two pointers.
Part B · The flaw in detail
Here is how the bookkeeping runs during an upload. What matters is who writes the notes and who uses them later:
- FILE_STARTWrite the notes. When a file starts, PHP records the current addresses of both counters (
session.c:3091/ 8.4.4:3316 for the total,session.c:3117/ 8.4.4:3342 for the per-file counter). - FILE_DATAWrite through the notes. For each arriving chunk, PHP writes via the recorded addresses (
session.c:3355–3356) — without looking up again. - FILE_ENDWrite once more. At the file's end it writes through the outer pointer (
session.c:3375). - UPDATERead. When refreshing the progress, the pointer is also read (
session.c:3188).
As long as the session data structure stays unchanged, this is harmless. The trouble starts when PHP tears that structure down and rebuilds it in between — because the notes are not updated when it does.
Both flaws are the same slip. The only difference: how often each note is rewritten at all.
Bug 1 — post_bytes_processed
Recorded only once — at the very first FILE_START (session.c:3091 / 8.4.4:3316). It is never refreshed at any later FILE_START.
So the total-counter pointer stays stuck on the very first address for the whole upload.
Bug 2 — current_file_bytes_processed
Refreshed at every FILE_START (session.c:3117 / 8.4.4:3342) — but only then.
Within a single file it stays on the same address across all FILE_DATA chunks. That's enough for a window in which it goes stale.
In both cases there is a moment where the note says "address X" but X is no longer valid.
On the second update() — once enough data has arrived for a refresh — php_session_initialize() re-reads the session file and rebuilds the whole data tree. Three things happen:
php_session_track_init()(session.c:244) throws away the previous decoded tree.zend_hash_update(session.c:3214) replaces it with the freshly decoded tree, calling the hash table's destructorzval_ptr_dtor.zval_ptr_dtorhands the old tree's memory back to the system. From now on it is free — but the two notes still point into it.
Why is the memory freed at all while something still points at it? That's the reference-count bug: add_next_index_zval (zend_hash.c:1192) and add_assoc_zval_ex (zend_API.c) copy the value with ZVAL_COPY_VALUE — without bumping the reference count. So the inner arrays (current_file[] and files[]) are double-referenced but only at count 1. When the old tree is torn down the count hits zero and the arrays are freed — even though a recorded pointer still aims at them.
A crashed PHP would be annoying but harmless. The real problem: freed memory doesn't stay empty — it gets reused. The stale pointer is later still read (session.c:3188) and written (FILE_DATA :3355–3356, FILE_END :3375). If the freed spot ends up holding other data, those reads and writes no longer land where they should.
The escalation — kept deliberately coarse
A pointer you can both read and write through is, in C, the key to the whole process. In the literature such a chain ultimately runs through internal Zend call machinery to a command on the server (remote code execution).
zend_call_function→system()The takeaway: a use-after-free where the dangling pointer is both read and written is not just a crash bug — it's enough to lose control of the entire process. That's why both CVEs are rated critical.
Part C · Context & remediation
Exploitation needs several conditions to line up — but none of them is exotic:
- Upload progress is on (
session.upload_progress.enabled— default: on). - Sessions are in use (virtually every application with a login).
- An attacker can reach an upload form and split the upload so progress is updated more than once.
Since the first two apply to a great many default installations, the practical attack surface is large. If you can't apply the patch right away, an immediate mitigation is to set session.upload_progress.enabled to 0 in php.ini — that closes both holes, as long as no application strictly needs the progress display.
A cached pointer is a promise: "the thing I point at outlives me." Here the promise was broken. Two clean ways out:
- Always refresh it whenever the structure can change — so at every FILE_START and after every rebuild of the tree, not just the first time. This fixes both bugs right at their root.
- Don't cache at all, and look the counter up fresh by its key in the session every time. A barely measurable speed cost against a whole class of vulnerabilities — a good trade.
The actual bug is not some exotic memory trick but a question of frequency: the note was rewritten too rarely. Understand that, and you understand the whole class of such flaws.
zval & HashTable
A zval is PHP's internal container for a single value. A HashTable is the internal form of a PHP array — a table mapping keys to values. The session's "data tree" is nested HashTables.
Pointer & dangling pointer
A pointer stores a memory address. It is "dangling" when that address points at memory that has already been freed — the note with the old apartment number.
Reference count & ZVAL_COPY_VALUE
PHP tracks how many places use a value; at zero it is freed. ZVAL_COPY_VALUE copies a value without bumping that count — correct only if the original then disappears. Here it stays, and the too-low count leads to a premature free.
Use-after-free
Accessing memory after it was freed. Reading returns foreign data; writing alters foreign data. Both together is one of the most powerful bug classes in C.
Remote Code Execution (RCE)
The worst outcome: an attacker gets the server to run their own code or commands — here at the end of the chain via zend_call_function through to system().
An explainer for two use-after-free flaws in PHP session upload progress (CVE-2026-109619, CVE-2026-109618).
The exploit part is sketched only roughly on purpose. Exact version range and fix: official PHP security advisory.