The public PoC claiming “RCE via a PHP core UAF” did not work in my lab. After reading the official patches, the actual causes turned out to be state corruption during OOM handling and state left behind across requests—not a zval UAF within a single request.
What this article concludes
I built a local Docker lab to investigate the PHP vulnerability making headlines as “php2shell,” compared the public PoC with PHP source code, and then read the patch diffs published with the official releases. The result: the “PHP core UAF” described by the public PoC did not reproduce, and the official root causes were different. However, a chain combining the session.upload_progress write primitive with an application-level LFI did work.
- On October 21, 2026, PHP released 8.5.12, 8.4.27, 8.3.36, and 8.2.35. CVE-2026-109618 is “OOM corruption,” and CVE-2026-109619 is “stale global state across requests.” Both have a CVSS v4.0 score of 9.2, and the official advisories explicitly call them RCE vulnerabilities.
- The public PoCs for 618 and 619 did not reproduce a UAF or RCE in PHP 8.4.4 and 8.5.4 ASAN builds, PHP 5.6.40, with ASLR disabled, or with a poisoned call address.
- The patches do not primarily fix the alleged “zval corruption on the second update” in
ext/session. They redesign the OOM bailout path, initialize session state before RINIT, and reset state left behind by earlier requests. The public PoC identified the wrong root cause. - A chain using
session.upload_progressand an application-level LFI did reproduce. It worked not only withcleanup=0, but also in a 3/3 race with the defaultcleanup=1; RCE through an uploaded temporary file also remained withenabled=0. The official advisory also identifies Session Upload Progress as a confirmed consumer for 619. - So the accurate conclusion is neither “the PHP core gives you universal RCE” nor “PHP2Shell is a hoax.” The core CVEs are real, and upgrading is the only complete fix for them. But an application-level LFI chain can remain after upgrading. These are two separate layers.
| Test | Result |
|---|---|
| Public PoCs 618/619 (8.4.4 ASAN) | Not reproduced |
| Public PoCs 618/619 (8.5.4 ASAN / 5.6.40) | Not reproduced |
LFI with cleanup=0
|
uid=33(www-data) |
Race with default cleanup=1
|
uid=33(www-data) in 3/3 attempts |
Temporary-file include with enabled=0
|
uid=33(www-data) |
use_strict_mode=1 with Set-Cookie follow-up |
uid=33(www-data) |
| mod_php (Apache) | uid=33(www-data) |
The test code and raw logs are available in the repository.
Here is the most important point upfront:
“The public PoC does not work” and “the vulnerability does not exist” are different claims. The public PoC may help explain source locations, but it does not prove RCE. On the other hand, the session.upload_progress primitive is real, and combining it with an application-level LFI can reach code execution without authentication. Do not assert the core root cause until you have read the official patch diffs. That is the minimum standard for avoiding being misled by information circulating during an embargo.
This article expands my PHP2SHELL investigation with source reading and experiment logs that I could not cover in my talk. It is for people who develop or operate PHP applications and want to distinguish vulnerability claims from observed behavior.
1. What happened
On October 20, 2026, PHP’s official website posted a short notice titled “Upcoming Critical PHP Security Release.” It said that fixes for two critical vulnerabilities would be released the following day, October 21, at 14:00 UTC (23:00 JST). Both had a CVSS v4.0 score of 9.2. Details, affected versions, and fixed versions would be published at release time.
The notice was only about four lines long. But information leaked before the notice was followed by the release. Fragments such as possible CVE numbers, session.upload_progress, impact on PHP 5.4 and later, and CVSS 10.0 circulated on social media and Hacker News. Researchers and security companies posted that the vulnerabilities were RCE and that they had reproduced them. None of the official PHP notice mentioned a CVE number, “RCE,” or “Session Upload Progress.”
That abnormal order is the central theme here. Leaked information and secondhand claims spread before the official facts were confirmed. Some of those claims later raised questions about whether the public PoC was reproducible.
Criticizing the situation would not tell me what was actually happening, so I built a local Docker lab. I compiled PHP with ASAN, ran the public PoC, read the PHP source, and checked which claims were supported by evidence.
I wrote the first draft based only on the advance notice. The official release was published by 22:45 JST on October 21, and confirmed the CVE numbers, causes, affected versions, and fixed versions. To keep the facts available at announcement time separate from what became known after release, I have kept this section as an account of what was known from the notice and summarized the release results in §10.
2. What the official notice did—and did not—confirm
At the time of the notice, the official source confirmed five things:
- A security release was scheduled for October 21, 2026, at 14:00 UTC (23:00 JST).
- There were two critical vulnerabilities.
- Both had a CVSS v4.0 score of 9.2.
- Details, affected versions, and fixed versions would be published with the release.
- The release might be moved earlier if details became public or active exploitation was observed.
The official notice did not say:
- The CVE numbers were CVE-2026-109618 and CVE-2026-109619.
- The issues were classified as RCE.
-
session.upload_progresswas involved. - Which versions were affected. “PHP 5.4 and later” was a leak claim. After release, the official fixes covered 8.2 and later; source backports were also provided for 7.4, 8.0, and 8.1.
- Which version numbers would contain the fixes. 8.5.12, 8.4.27, 8.3.36, and 8.2.35 were unverified guesses at the time. All four were later confirmed.
| Item | Official notice | Researchers / leaks | My lab |
|---|---|---|---|
| Two issues / CVSS 9.2 / date | Confirmed | Consistent | — |
| CVE numbers | Not listed | Claimed | — |
| RCE | Not listed | Claimed | Core UAF not reproduced |
| Session Upload Progress | Not listed | Claimed | Primitive confirmed |
| PHP 5.4+ | Not listed | Leak claim | Primitive confirmed on 5.6.40 |
| Fixed versions | Not published | Guessed | Not tested yet |
The October 21 release filled in every “not listed” and “not published” cell. The CVE numbers were real, the official advisories called the issues RCE, and all four guessed fixed versions were correct. I summarize the difference between the notice and the release in §10.
The disagreement over CVSS is especially illustrative. PHP’s official score was 9.2. Meanwhile, the CEO of watchTowr told the press, “Both of them are CVSS10.” This is not about deciding which score is right. It shows how a secondary claim of 10.0 can spread even while the official source says 9.2. That was the information environment around this incident.
3. What Session Upload Progress does
session.upload_progress is a PHP feature that writes file-upload progress into a session. If a multipart/form-data POST contains a field named PHP_SESSION_UPLOAD_PROGRESS, PHP writes progress data to $_SESSION["upload_progress_<value>"]. The progress data can include start_time, content_length, bytes_processed, done, and files[].
The important detail is that progress is written before the application script runs. PHP writes it while parsing the POST body, before the application’s authentication or input validation. The PHP engine does this even when the application does not use a progress display.
sequenceDiagram
participant A as Attacker
participant W as Web server
participant P as PHP multipart parser
participant S as ext/session
participant App as Application
A->>W: POST multipart (PHP_SESSION_UPLOAD_PROGRESS=...)
W->>P: Request body
P->>S: MULTIPART_EVENT_START / FORMDATA
S->>S: progress->key = "upload_progress_" + value
P->>S: FILE_START / FILE_DATA
S->>S: Write to session file before app runs
P->>App: Run script
App->>App: session_start() exposes progress data
The defaults matter too: session.upload_progress.enabled is On, session.upload_progress.cleanup is On (progress is removed at the end of the request), and session.use_strict_mode is 0 (an attacker can specify a session ID). These defaults lead into why changing a single setting is not enough.
4. Reading php-src: two layers, a callback and a parser
Reading php-src shows that this feature has two layers:
- The multipart parser itself in
main/rfc1867.c. - The progress callback in
ext/session/session.c.
main/rfc1867.c has a global function pointer named php_rfc1867_callback. The parser calls it for START, FORMDATA, FILE_START, FILE_DATA, FILE_END, and END events. During MINIT, ext/session registers php_session_rfc1867_callback by assigning it to php_rfc1867_callback, thereby hooking into the parser.
The callback starts with this early return:
if (!PS(rfc1867_enabled)) {
return retval;
}
So setting session.upload_progress.enabled=0 stops only this callback. The parser in main/rfc1867.c still runs for multipart POST requests regardless of the setting.
The progress key is created during FORMDATA:
smart_str_appendl(&progress->key, PS(rfc1867_prefix).c, PS(rfc1867_prefix).len);
smart_str_appendl(&progress->key, *data->value, value_len);
After that, php_session_rfc1867_update() runs php_session_initialize() → zend_hash_update() → php_session_flush(), writing sess_<PHPSESSID> before the application script runs. The session ID is read early from the Cookie or GET parameter by php_session_rfc1867_early_find_sid(). With use_strict_mode=0, an attacker can choose an arbitrary ID.
flowchart TD
A[multipart POST] --> B[main/rfc1867.c]
B --> C{php_rfc1867_callback}
C --> D[ext/session callback]
D --> E{enabled?}
E -- 0 --> F[Early return]
E -- 1 --> G[Create progress->key]
G --> H[Write session file]
H --> I[Run application]
B --> J[Parser continues running]
What we can conclude from this is the structure: the setting disables only the session callback. At the time of the first draft, it was not enough to say “therefore the second vulnerability is in the parser itself.” I give the answer in §10, but the official explanation was not “a UAF in the parser.”
5. Testing the public PoC’s “core UAF” claim
The public bundle Zer061/CVE-2026-109618 says that php_session_rfc1867_progress caches two zval * pointers, and that a second update() breaks the tree when the session file is decoded again and the old tree is freed by zval_ptr_dtor. It then claims that 149 t[] fields reclaim the freed memory, allowing a forged zend_fcall_info to call zend_call_function and reach system().
I built PHP 8.4.4 with ASAN and set USE_ZEND_ALLOC=0 so ASAN would also observe the Zend heap. I sent PoCs for 618 and 619 through PHP-FPM + nginx, and checked for ASAN reports, uid=, and worker crashes.
The result: I could not reproduce either the UAF or RCE. The progress-store path was reached: I could see bytes_processed=258 and files[0].bytes_processed=1. But there was no ASAN report, no uid=, and no worker crash. I got the same result with an ASAN build of PHP 8.5.4 (the build assumed by the public PoC’s DELTA_CALL), PHP 5.6.40, ASLR disabled, the actual address of zend_call_function, and the poisoned address 0x4141414141414141. A poisoned address not causing a crash is evidence that the stale pointer was not being used as a function pointer.
Instrumenting the source made the reason clearer:
-
post_bytes_processedpoints to a zval insideprogress->data;progressitself holds a reference, so it remains alive during the update. -
track_init/zend_hash_updatefree the copy decoded from the session. - The
postread inupdate()happens beforephp_session_initialize(), while the free happens afterward. - The
force=1update atMULTIPART_EVENT_ENDskips the read entirely. -
current_file_bytes_processedis also retained by thefiles[]array, and theFILE_ENDupdate changes an existing key.
The public bundle’s own VALIDATION.md says, “ASAN crash not observed locally.” The image attached to its tweet is a screenshot explaining the alleged mechanism, not execution evidence showing uid= or an ASAN report. The distributed tarball’s SHA-256 did not match the hash listed on its page (a29d… versus 0c254d…). The code was identical to the GitHub mirror, but anyone running it should pin a commit.
This does not prove that the CVE does not exist. It only means that the public PoC, at least as provided, did not reproduce RCE. In the first draft, I wrote, “The core root cause cannot be confirmed until the official patch diff is available.” The patch is now public, and §10 provides the answer. In short, the official root cause was different from the PoC’s story.
6. A chain that did work: multipart + LFI
The core UAF in the public PoC did not reproduce, but a chain combining the session.upload_progress primitive with an application-level LFI did.
The flow is:
- The attacker chooses a
PHPSESSID(use_strict_mode=0). - The attacker puts PHP code in
PHP_SESSION_UPLOAD_PROGRESS. - Before the application runs, PHP serializes the value into
sess_<PHPSESSID>. - An application LFI includes
sess_<PHPSESSID>. - PHP code embedded in the key string (
<?php ... ?>) executes.
sequenceDiagram
participant A as Attacker
participant P as PHP
participant L as LFI app
A->>P: PHPSESSID + PHP_SESSION_UPLOAD_PROGRESS
P->>P: Write sess_<id> before app runs
A->>L: ?file=/tmp/sess_<id>
L->>L: include(sess_<id>)
L-->>A: uid=33(www-data)
For the test, I used a local-only application that intentionally calls include($_GET['file']). The result was uid=33(www-data). The session file contained the PHP code embedded in the progress key:
upload_progress_<?php /* ... */ ?>|a:5:{s:10:"start_time";...}
The nginx access log also showed the following sequence:
"POST /index.php HTTP/1.1" 200 5
"GET /index.php?file=%2Ftmp%2Fsess_...&0=id HTTP/1.1" 200 414
This clarifies what people meant by “a few more vulnerabilities.” The central requirement is an application-level LFI or another sink that reads the session file. Other contributing factors can include SID fixation due to use_strict_mode=0, a race when cleanup=1, predictable session.save_path, and including an upload temporary file that remains available even when enabled=0.
This is not universal RCE from the PHP core alone. It requires an application-level sink. Mixing this with the alleged core UAF in the public PoC blurs two separate chains.
7. The default-setting race, enabled=0, strict mode, and mod_php
The default cleanup=1 does not make this safe. It removes progress at the end of a request, but progress remains during the upload. I sent the first chunk (6,000 bytes) to satisfy FPM’s FILLUNIT=5120, stalled for 1.5 seconds, and requested the LFI during that interval. The result was uid=33(www-data) in all 3 attempts.
enabled=0 stops progress from being written to the session. But multipart uploads continue, and $_FILES['f']['tmp_name'] is still created. If the application includes that file, another RCE path remains. Indeed, an include of the temporary file returned uid=33(www-data) even with enabled=0.
use_strict_mode=1 rejects an unknown SID chosen by an attacker. LFI with the chosen SID failed. However, the upload response’s Set-Cookie header contained a new SID. If the response can be read, the attacker can follow that SID. With my test’s --follow-cookie option, the result was uid=33(www-data).
The same chain also worked with mod_php (php:8.4.4-apache). This chain is independent of the SAPI.
| Condition | Result |
|---|---|
cleanup=0 |
uid=33(www-data) |
Default cleanup=1 + race |
uid=33(www-data) in 3/3 attempts |
enabled=0 + temporary-file include |
uid=33(www-data) |
use_strict_mode=1 + chosen SID |
Failed |
use_strict_mode=1 + follow Set-Cookie
|
uid=33(www-data) |
| mod_php | uid=33(www-data) |
8. How to read the shared impact assessment
I was also sent an impact assessment for a web application running PHP 5.6.40. I cannot disclose the application name or names of credential keys, so I have generalized the details.
The assessment did a good job of listing exposure conditions: PHP 5.6.40 is EOL and outside the scope of the official fixes; the session extension is installed; upload_progress is enabled by default; file_uploads is on; and all requests reach PHP. Together, these establish an attack surface. It was also practical to describe impact conditionally as the “blast radius if RCE is achieved.”
There are several caveats:
- “
enabled=Offmeans the vulnerable code path does not run” applies only to the session callback. The multipart parser and temporary files remain. Myenabled=0+ temporary-file include test is a counterexample. - “This is RCE” was a researcher/leak claim at the time of the first draft, not yet confirmed by PHP. The October 21 release explicitly confirmed RCE. The assessment itself rated confidence as medium.
- “PHP 5.6 is affected” was a leak claim. Official fixes cover 8.2 and later, with backports for 7.4, 8.0, and 8.1; PHP 5.6 remains outside the official scope and unconfirmed.
- If an assessment does not mention LFI, it does not prove that RCE is achievable. The chain in my lab requires an application-level LFI.
- Do not commit the assessment as-is to a public repository if it contains internal application names or secret-key names.
For an article, the accurate statement is that “the exposure conditions were present in a PHP 5.6.40 web application.” It would not be accurate to say “RCE was confirmed.” The assessment was about impact if RCE were achieved.
9. Mitigations and what they do—and do not—stop
Mitigations need to be evaluated by what they stop:
| Mitigation | What it stops | What remains |
|---|---|---|
session.upload_progress.enabled=Off |
Progress writes to the session | Multipart uploads / temporary files |
session.upload_progress.cleanup=1 |
Progress after request completion | Race during the upload |
session.use_strict_mode=1 |
Attacker-chosen SID | Following the SID if Set-Cookie is readable |
| WAF / multipart restrictions | Some automated attacks | Legitimate uploads, other SAPIs, other sinks |
| Patch | The root causes identified by PHP | Application-level LFI / configuration issues |
| Least privilege / isolation | Limits impact after compromise | RCE itself |
enabled=Off is the strongest mitigation for the session write primitive. However, multipart uploads and temporary files remain. If the application has a sink that includes uploaded files, that must be fixed separately.
cleanup=1 only removes progress after the request ends, so it does not prevent a race during upload. use_strict_mode=1 prevents the attacker from choosing a SID, but progress is still written. If the response’s Set-Cookie can be read, the SID can be followed.
That is why mitigations should be separated into three layers:
- Patch — fix the core root causes.
-
Configuration — disable the primitive with settings such as
enabled=Off. - Remove application sinks — eliminate LFI, includes of uploaded files, and code that reads session files.
Do not treat “set one option and you are done” as a complete response.
The official advisories also list concrete mitigations for the core issues: force memory_limit=-1 in administrator configuration (and prevent applications from overriding it), reduce post_max_size, use cgroup/container memory limits, set fatal_error_backtraces=0 on PHP 8.5, and set pm.max_requests=1 to prevent workers from being reused. The advisories explicitly say not to use RLIMIT_AS or RLIMIT_DATA, because they can cause mmap() failures and trigger the vulnerable path. These measures reduce attack paths, but PHP says there is no complete mitigation; upgrading is the only safe fix.
10. What the official release and patch diffs showed
The official release was already available at 22:45 JST, before the announced 23:00 JST time. I read it and checked my first-draft predictions one by one.
10.1 Facts confirmed by PHP
| Item | Details |
|---|---|
| Fixed versions | PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 |
| CVE-2026-109618 | RCE via OOM corruption (GHSA-w8hw-w9fx-j79w) |
| CVE-2026-109619 | RCE via stale global state (GHSA-6wmf-4rv5-p3rp) |
| Severity | CVSS v4.0 9.2 for both |
| Scope | All PHP handling web requests: FPM, mod_php, php-cgi, FrankenPHP, LiteSpeed |
| Most dangerous setup | mod_php; a demonstrated attack works on an unauthenticated request before application code runs |
| 8.1 and earlier | Outside official support. Minimal source backports only for 7.4 / 8.0 / 8.1 |
| Mitigation | “There is no complete mitigation. Upgrading is the only safe fix.” |
The official release’s explicit “RCE” classification superseded the first draft’s statement that PHP had not yet called it RCE. That statement was true only at announcement time. The CVE numbers and fixed versions also matched the leaked guesses.
10.2 The root causes differed from the public PoC’s explanation
The official GHSAs describe two causes.
CVE-2026-109618 (OOM corruption) abuses PHP’s use of longjmp() to bail out of processing when it runs out of memory. If an allocation fails while request memory is only partially initialized, PHP does not correctly account for the early bailout and leaves request memory in an invalid state. Request shutdown later reads or branches on that corrupted state. There are several ways for multipart parsing to hit memory_limit even while staying within post_max_size: multipart requests without Content-Length, unlimited headers inside parts, empty file controls not counted by max_file_uploads, and nested field names.
CVE-2026-109619 (stale global state) occurs when request-owned state remains in a worker global after the request memory has been freed. A fatal error or OOM interrupts processing before the corresponding reset runs. The request heap is released, but a worker global still points into that memory. If the next request reads the stale value before normal initialization, it triggers a use-after-free. PHP says both a producer (the operation that leaves the state behind) and a consumer (the operation that reads stale state) are required. In demonstrated cases, separate HTTP requests must be assigned to the same worker.
The official advisory names two consumers. One is INI diagnostics before request initialization (the path where .htaccess or FPM PHP_VALUE emits a warning or deprecation). The other is Session Upload Progress. It touches session/decoder state before normal session initialization. The sequence is that a previous request exits on a fatal error and leaves state behind, then the next upload reads it. session_start() is not needed, and the feature is enabled by default.
This is the decisive difference from the public PoC. The PoC said that a second update() within one request corrupts a zval in post_bytes_processed. The official root cause is state that was not reset across requests. It is not a UAF confined to one request.
10.3 Reading the patch diffs
The fix is commit 31c0ea7d7ff1d1fccb6492e52dab544011628065, titled “Avoid state corruption via bailout.” It names both GHSAs as fixes. I fetched the php-8.5.11 and php-8.5.12 tags and diffed the relevant files.
In ext/session/session.c, four things changed:
- Call
php_rinit_session_globals()atMULTIPART_EVENT_START, initializing session globals before RINIT. - Force unserialize/serialize during upload progress to use
__PHP_Incomplete_Class. Since this happens before RINIT, it must not invoke autoload,__wakeup(), or__unserialize(). - If
php_session_initialize()returns FAILURE, do not setsession_statusto active; return instead. - Do not run the callback through
request_parse_body()(PG(modules_activated)).
case MULTIPART_EVENT_START: {
+ /* This runs before RINIT, so we need to clear these early */
+ php_rinit_session_globals();
progress = ecalloc(1, sizeof(php_session_rfc1867_progress));
+ PS(in_rfc1867_update) = true;
+ if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
+ PS(in_rfc1867_update) = false;
+ return;
+ }
On the main/rfc1867.c side, the patch adds ZEND_HAS_OOM() checks and replaces allocations that could fail. The parser now keeps only Content-Disposition and Content-Type headers, narrowing the route for exhausting memory through part headers. In Zend/zend_alloc.c, the OOM longjmp() is removed and replaced with a deferred soft OOM; a second hard limit exits via _exit().
So, the public analysis was right that php_session_rfc1867_callback() was among the code being fixed. But the fix is not “prevent zval corruption on the second update.” It initializes session state that runs before RINIT and prevents user code from running pre-RINIT. Getting the function name right is not the same as getting the root-cause explanation right.
10.4 Why my lab did not reproduce the official issue
My initial lab followed the public PoC’s story: it tried only to trigger two updates in one request and corrupt a zval. The official 619 path uses a producer and consumer in separate requests, which must be assigned to the same worker. A single request cannot satisfy those conditions.
Also, the 619 producer requires a fatal error or OOM to skip a reset. My lab’s trigger exited normally, so it was not a producer. For 618, the key is that shutdown runs even when request startup fails in mod_php. The main lab used FPM, where the child exits if startup fails, so it does not follow the same path.
Therefore, “I could not reproduce it in my lab” does not prove the CVE does not exist or that PHP’s official root cause is wrong. I was testing different attack conditions. The same distinction applies to the public PoC: “this PoC does not prove RCE” and “the CVE is a hoax” are not the same statement. That boundary from my first draft turned out to be correct.
10.5 Which first-draft predictions were right?
| First-draft statement | Result |
|---|---|
| Fixed versions would be 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 | Correct |
| PHP had not yet called it RCE | True at announcement time; PHP explicitly called it RCE at release |
Root cause was a zval UAF in php_session_rfc1867_callback()
|
Incorrect. PHP identified OOM and cross-request stale state |
session.upload_progress was involved |
Partly correct. It is a confirmed consumer for 619 |
| Every PHP version from 5.4 onward was affected | Still unconfirmed. Official fixes cover 8.2+; backports for 7.4–8.1 |
enabled=Off is not a complete mitigation |
Correct, though the reason also involves lifecycle paths beyond the multipart parser |
| The multipart + LFI chain is real | Correct. The application-level sink remains after the patch |
10.6 The lab after patching
I built an ASAN version with PATCHED_VERSION=8.5.12 and reran the same trigger and LFI chain. The build was PHP 8.5.12, and the web SAPI was fpm-fcgi.
| Test | Result on 8.5.12 |
|---|---|
probe (normal multipart) |
upload_progress=present; file parsing OK |
trigger (258 bytes) |
bytes_processed=258, done=true
|
poc618 / poc619
|
HTTP 200, no ASAN report, no uid=
|
| LFI chain (8084) | uid=33(www-data) |
| FPM log | NOTICE messages for startup/shutdown only; no ASAN / crash |
Two things can be concluded:
-
The
session.upload_progresswrite primitive still works after the patch. This is a feature, not a vulnerability. The official patch fixes state that was not reset across requests, not the act of writing upload progress to a session. That is whyprobeandtriggerreturn the same results as before. -
The application-level LFI chain still works on 8.5.12. The official patch closes the core CVEs, but it does not remove an application sink that includes
sess_<id>or an uploaded temporary file. That must be fixed separately.
To be precise, poc618 / poc619 not crashing on 8.5.12 does not prove the patch worked. Those PoCs also did not crash in this lab on 8.5.11 before the patch. I verified the patch’s effect by reading the GHSA descriptions and diffs. The lab verified only that the feature and the application-level chain remain after patching.
10.7 Remaining uncertainties
- This lab did not reproduce the actual producer/consumer path for the official CVEs. It is an investigation based on the patch diffs and GHSA descriptions. Although PHP says RCE was demonstrated on mod_php, I did not reproduce RCE on my own machine; I keep those claims separate.
- The GHSA says the FPM path for 619 “depends on appropriate application actions.” Which actions produce the state depends on the application and extension combination.
- Impact on PHP 5.4–7.3 is not confirmed because those versions are outside the official scope. Backports exist for 7.4–8.1, but these are not supported updates.
- I did not check for in-the-wild (ITW) attacks after the release.
11. Conclusion
There are five takeaways from this investigation:
- Application-layer security and runtime security are different things. Your code can be perfectly fine and still be affected before a request reaches it.
- The CVEs are real, and upgrading is the only complete fix. Upgrade to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35. Configuration cannot fully replace the patch.
- The causes are OOM state corruption and state left behind across requests. This is not a zval UAF within one request. The first issue lets shutdown touch memory left in an invalid state during initialization; the second skips a reset after a fatal error/OOM, so the next request reads stale state.
-
session.upload_progresswrites to the session before the application runs. This primitive is real, and combining it with an application-level LFI can lead to RCE. PHP also confirms it as a consumer for 619. -
Separate configuration mitigations, patches, and application-sink removal.
enabled=Offblocks the confirmed Session Upload Progress path for 619, but other paths and application-level LFI can remain.
I chose the title “The Return of the PHP2Shell Nightmare” because the incident felt familiar from previous PHP RCE scares. But this time, the nightmare was created less by a single UAF and more by information getting mixed up with things overlooked at the application layer. Reading the official patch revealed both that the public PoC’s explanation was wrong and that the CVEs were real. Incorrect public information does not mean there is no vulnerability; a real vulnerability does not mean every public PoC is correct. Test what you can reproduce in a local lab, then check the result against official primary sources. That is the most reliable approach, both during an embargo and after it ends.
References
- PHP: News Archive 2026 — The October 20 notice, October 21 security advisories, and fixed releases.
- GHSA-w8hw-w9fx-j79w — RCE via OOM corruption — Primary source for CVE-2026-109618, the OOM path, and mitigations.
- GHSA-6wmf-4rv5-p3rp — RCE via stale global state — Primary source for CVE-2026-109619, producers/consumers, and Session Upload Progress.
- php/php-src commit 31c0ea7d7f — “Avoid state corruption via bailout,” the fix for both CVEs.
-
PHP Manual — Session Upload Progress — Progress data is written before application execution; details of
PHP_SESSION_UPLOAD_PROGRESS. -
php/php-src — ext/session/session.c —
php_session_rfc1867_callback, theenabled=0early return, and session writes. - php/php-src — main/rfc1867.c — Multipart parser and global callback.
- GMO Flatt Security Blog — php2shell — Japanese secondary analysis that labels its connection to Session Upload Progress as a hypothesis.
- Sansec — php2shell — Summary of the disclosure and mitigations.
- Huntress — php2shell Rapid Response — Situation at disclosure and detection perspective.
-
JackalSyn — PHP-UP-2026 — Analysis of the public bundle; its
VALIDATION.mdsays no ASAN crash was observed. - Zer061/CVE-2026-109618 — Public PoC code; I could not reproduce 618/619 in my lab.
-
Investigation report — Experiment logs, nginx/Apache access logs, session files, and
uid=evidence. - Post-patch comparison (8.5.12) — Results of rerunning the trigger, PoCs, and LFI chain on an 8.5.12 ASAN build.
Top comments (0)