DEV Community

Hirohisa Tanaka
Hirohisa Tanaka

Posted on

Investigating the Return of the PHP2Shell Nightmare

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_progress and an application-level LFI did reproduce. It worked not only with cleanup=0, but also in a 3/3 race with the default cleanup=1; RCE through an uploaded temporary file also remained with enabled=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_progress was 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:

  1. The multipart parser itself in main/rfc1867.c.
  2. 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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_processed points to a zval inside progress->data; progress itself holds a reference, so it remains alive during the update.
  • track_init / zend_hash_update free the copy decoded from the session.
  • The post read in update() happens before php_session_initialize(), while the free happens afterward.
  • The force=1 update at MULTIPART_EVENT_END skips the read entirely.
  • current_file_bytes_processed is also retained by the files[] array, and the FILE_END update 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:

  1. The attacker chooses a PHPSESSID (use_strict_mode=0).
  2. The attacker puts PHP code in PHP_SESSION_UPLOAD_PROGRESS.
  3. Before the application runs, PHP serializes the value into sess_<PHPSESSID>.
  4. An application LFI includes sess_<PHPSESSID>.
  5. 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";...}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=Off means the vulnerable code path does not run” applies only to the session callback. The multipart parser and temporary files remain. My enabled=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:

  1. Patch — fix the core root causes.
  2. Configuration — disable the primitive with settings such as enabled=Off.
  3. 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:

  1. Call php_rinit_session_globals() at MULTIPART_EVENT_START, initializing session globals before RINIT.
  2. Force unserialize/serialize during upload progress to use __PHP_Incomplete_Class. Since this happens before RINIT, it must not invoke autoload, __wakeup(), or __unserialize().
  3. If php_session_initialize() returns FAILURE, do not set session_status to active; return instead.
  4. 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));
Enter fullscreen mode Exit fullscreen mode
+    PS(in_rfc1867_update) = true;
+    if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
+        PS(in_rfc1867_update) = false;
+        return;
+    }
Enter fullscreen mode Exit fullscreen mode

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:

  1. The session.upload_progress write 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 why probe and trigger return the same results as before.
  2. 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:

  1. Application-layer security and runtime security are different things. Your code can be perfectly fine and still be affected before a request reaches it.
  2. 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.
  3. 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.
  4. session.upload_progress writes 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.
  5. Separate configuration mitigations, patches, and application-sink removal. enabled=Off blocks 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

Top comments (0)