Press "Enter" to skip to content

Anatomy of This Nuclear-Grade PHP Vulnerability

It has been a wild few days in the PHP world. AI discovered a nuclear-grade RCE vulnerability that had been hiding in PHP for over a decade — even the Japanese Prime Minister posted on X to warn everyone. LOL.

I wanted to write this analysis yesterday, but since the patch wasn't out yet and the details could have helped attackers, I waited. Now that the patch is out, here it is. This post uses the publicly released PoC exploit as the running example — I won't link to it directly.

When I first saw the vulnerability report, my immediate reaction was: no human did this. It took AI, with its sheer ability to synthesize knowledge, to find this bug — and to exploit it. That morning I told a colleague: once AI starts doing offensive security, it won't stop at PHP. Every system can be broken, because all of the existing code was written by humans, and humans make mistakes. Sure enough, by that afternoon, the news broke: NodeJS had blown up too.

Why did this bug stay hidden so long? Because exploiting it combines multiple conditions and requires a fairly deep understanding of PHP internals: memory management, the Session implementation, the serialization mechanism, and how OOM is handled. Weaponizing it takes even broader systems knowledge. Before AI, very few people had all of that knowledge — and even those who did probably lacked the stamina to push it all the way. But then, AI arrived.

This post focuses on the more damaging of the two, CVE-2026-109619, which achieves RCE. It shares a common precondition with CVE-2026-109618, so once you understand one, the other follows.

In most deployments PHP runs in multi-process mode. Back when Apache was popular, it was mod_php — PHP shipped as a module compiled into Apache. After Nginx took over, it became php-fpm: PHP runs as its own multi-process service, receiving requests forwarded by Nginx over sockets.

In this model, a PHP process stays resident: one request comes in, it gets processed, then the next. To keep requests from interfering with each other, PHP runs Request INIT (RINIT) before each request and Request Shutdown (RSHUTDOWN) after it.

PHP's memory is managed by its own memory manager, Zend MM, which resets its state after every request.

Taking PHP 8.5 as our example (main/main.c):

/* php_request_startup() */
zend_activate();           /* Reset the Zend engine core state */
sapi_activate();
...
php_hash_environment();    /* Build $_POST / $_FILES — parses the HTTP body;
                              multipart reaches the rfc1867 callback (upload_progress fires here) */
zend_activate_modules();   /* Every extension runs RINIT, resetting extension state */
PG(modules_activated) = 1;
/* ... run the application script ... */

/* php_request_shutdown(), torn down in reverse order */
zend_deactivate_modules(); /* Extension RSHUTDOWN — gated by PG(modules_activated) */
zend_deactivate();         /* Zend engine cleanup: destroy the symbol table */
shutdown_memory_manager(); /* Free the entire request heap back to Zend MM (unconditional) */

Normally this machinery works flawlessly. But there is always a "Plan B" case:

Under fatal conditions — an OOM, or memory corruption caused by a bug — PHP calls zend_bailout(), which internally performs a C longjmp, skipping all cleanup code. That is the right call: if memory is corrupted, the intermediate state can't be trusted, and running cleanup on it might read garbage and produce unpredictable results.

CVE-2026-109618 exploits exactly that.

Now, in the common case, after a zend_bailout the mess just sits there — but when the next request arrives, PHP's RINIT restores the scene, re-initializes the environment, and everything is fine. But once again, there's a Plan B. There is exactly one spot in the request lifecycle that executes before PHP's RINIT:

php_hash_environment()

This is where PHP builds its superglobals, and at this point no extension has run its RINIT yet — which brings us to ext/session, the extension at the heart of this vulnerability.

Session's RINIT (php_rinit_session_globals) initializes the global variables the extension uses. But before Session's RINIT has been called, those globals still hold whatever the previous request left behind.

You might ask: if RINIT hasn't run, how does Session end up inside php_hash_environment() at all?

The answer is one of PHP's "thoughtful" features: Session Upload Progress (session.upload_progress). Introduced in PHP 5.4 and enabled by default, it lets frontends show a progress bar during file uploads. The implementation idea: while PHP is parsing the multipart upload data, progress information is written into the session file in real time.

Note why the design has to work this way: a progress bar written once your script starts running would be useless — the upload is already over by then. It has to be written while the body is being parsed. So PHP hung a callback on the multipart parser — and when the first part carrying a file appears, that callback initializes the session ahead of schedule: it reads PHPSESSID, opens the session file, and attaches $_SESSION to the symbol table.

All of which happens before RINIT.

figure1.png

Now the puzzle pieces are all on the table. Picture two requests hitting the same PHP worker process:

Request A (poisoning): the attacker sends a carefully crafted multipart upload. A file part appears, and the upload progress callback initializes the session ahead of time — at this moment, the Session extension's globals hold PS(id), pointing to a zend_string on the request heap, and PS(http_session_vars), pointing to a $_SESSION array on the request heap. Note that the memory these pointers reference belongs to request A.

Immediately after, the attacker makes request A hit OOM mid-parse (how — that comes later). The zend_bailout() longjmp leaps over the multipart END callback — where session cleanup would normally happen — and because PG(modules_activated) is still 0 at this point, the Session RSHUTDOWN in the request shutdown sequence is skipped entirely as well. But shutdown_memory_manager() executes unconditionally — request A's heap, together with the session id string and the $_SESSION array inside it, is released wholesale.

Notice: the PHP worker itself is still alive, but its global state now holds two dangling pointers — the pointers are still there, pointing at memory that has been handed back to Zend MM, waiting to be claimed by the next allocation.

Request B (detonation): the attacker sends another multipart request. This one carries no file — just two ordinary form fields: PHPSESSID= and PHP_SESSION_UPLOAD_PROGRESS=. Since there is no file part, the upload progress initialization path never triggers, and request B walks straight into the multipart END callback. That callback contains a cleanup function which calls php_session_initialize() — and so PHP follows that never-reset dangling pointer into memory that has been freed and just re-occupied by request B's own data.

Here is the key insight: every field name and field value in request B causes PHP to emalloc once, and the allocation size is determined by the field length. And since PHP, for allocation efficiency, serves same-sized requests from small free lists, an attacker can precisely design these lengths so that one of request B's allocations claims exactly the block request A left dangling. Once reclaimed, what is in that memory? The attacker's bytes from the multipart body.

In other words: PHP, holding onto the stale memory that "this is a legitimate session structure", goes on to interpret a block of memory whose contents are fully attacker-controlled. This is a textbook use-after-free.

So how do you turn a UAF into an RCE?

The public PoC lays out the complete answer. The whole attack runs in four stages, pure HTTP all the way:

Stage one: turn OOM into a controllable interruption primitive. To trigger the mechanism above, the attacker needs to produce a fatal inside the window where the session is initialized but cleanup hasn't run. Three amplifiers make this trivial under default configuration: a chunked multipart with no Content-Length slips past the post_max_size pre-check; a single part can carry an unlimited number of headers, whose in-memory representation is far larger than the wire bytes; and an empty file control (filename="") doesn't count toward max_file_uploads, yet all six $_FILES metadata arrays still get built. The result: a request body of a few megabytes can blow through a 128MB memory_limit — with the OOM landing at a position the attacker chose.

Stage two: leak. The dangling PS(id) is steered toward a reclaimed block of memory, where the attacker has forged a zend_string length field. When PHP generates the Set-Cookie header, it faithfully emits that many bytes — and the response header carries 4KB of heap contents. One pass of scanning on the attacker's side yields the heap base. Do it again with a different setup, and this time the leak captures the pDestructor field of some HashTable — a function pointer into libphp's code segment. At that moment ASLR is dead: the known function offset subtracted from the leaked pointer gives libphp's load base.

Stage three: forge. The attacker writes a carefully laid-out 167-byte structure into a multipart field: a fake HashTable (whose destructor pointer, pDestructor, is set to the address of zif_system), a fake execute_data (with an argument count of 1), and a fake zend_string (containing the command to run). One more allocation moves this structure to exactly the address the dangling pointer references, overwriting the location PS(http_session_vars) points to with a fake reference aimed at the forged HashTable.

Stage four: detonate. Request B reaches the END callback, and PHP's cleanup code starts destroying what it believes is session data, following the dangling pointer: zval_ptr_dtor → zend_reference_destroy → zend_array_destroy → zend_hash_destroy — that last step invokes pDestructor for every element of the HashTable. And this pDestructor is the attacker's zif_system. The command runs, output is redirected into the web directory, and the attacker fetches the result with one more plain GET request.

The whole thing returns HTTP 200. The worker doesn't crash. The application logs are spotless. Even a PHP page containing nothing but

<?php echo "OK"; ?>

is fair game — because from start to finish, the attack happens before any application code runs.

figure2.png

There's an even more "elegant" variant worth a mention — one that requires no memory corruption at all. If the attacker can find a way to write a malicious serialized object into a session file, for example:

O:3:"CMD":1:{s:3:"cmd";s:2:"id";}

then upload progress, reading the session before RINIT, deserializes it into a real object — and the object's __destruct() gets invoked by PHP, "legitimately". If the target application happens to have a chain of magic methods that relays into system() (a gadget class — ecosystems like WordPress and Laravel have never been short of them), RCE is achieved. This path never touches memory layout at all, and it's terrifyingly reliable.

By now you should also see why this bug is so hard to defend against:

It requires no authentication — the attack request is an ordinary HTTP POST. It runs before application code — routing, middleware, WAF rules, ini_set() all arrive too late. Its entry point is enabled by default — upload_progress has been On since PHP 5.4, and it doesn't need the application to call session_start(). Even session.use_only_cookies=1 doesn't help, because the attacker can put PHPSESSID in a form field.

And on top of that, it's silent — no crash, no 500, nothing in the error log.

Finally, back to the topic of AI.

What struck me most about this bug isn't the bug itself — I've been writing PHP internals for many years, and I've always known that "state cleanup at request boundaries" is risky territory — it's how it was found. The security researcher who discovered it said it themselves: no special harness, no private models. Just a researcher armed with twenty years of PHP vulnerability history (Stefan Esser found the first memory-limit bug back in 2004), deliberately steering AI to do variant analysis. The AI is like a tireless auditor that has read every historical vulnerability, sweeping the pattern "publish state → fallible work → commit" across a million lines of code, over and over — while the human's job is to ask the right question.

So "will AI replace security researchers" is the wrong question. AI can't discover problems you don't know how to ask about — but for the questions you do know to ask, it can take them to a depth and breadth human effort will never reach. Knowledge settles in a person's mind as a sense of direction; AI unrolls that sense of direction into a carpet-bombing search.

This time it was PHP; that same afternoon, NodeJS. The underlying C libraries, the Python interpreter, the JVM... all the code "written by humans, running for decades" is now under the same microscope. Infrastructure software has no "secure by default" — only "exposed by default". That one goes out to everyone still running PHP 7.x.

Upgrade. Today.

Of course, there is one measure that kills the RCE at the root: turn off PHP's upload progress feedback. It doesn't address the possible DDOS the OOM primitive enables, but it does eliminate the RCE:

session.upload_progress.enabled=Off

Remember this has to go in php.ini, not through ini_set — you know why.

And of course, the clever among you might think: since the attack spans two requests, there's one more radical option — let each worker handle exactly one request. For FPM:

pm.max_requests = 1

For Apache:

MaxRequestsPerChild 1

But this method is brutal — burning the village to save it. Use your own judgment.

Note: This post was auto-synced to this blog by Jarvis (the author's AI assistant) from the WeChat official account "风雪之隅".


WeChatRead the original on WeChat: 《讲解一下这次PHP核弹级别漏洞的原理》

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.