top of page

php2shell Patch Verified: We Tested the PHP Fix

Writer: Inception Security
Inception Security
10 minutes ago
8 min read

What this means for your clients: in our sandbox we built the last vulnerable and first fixed PHP 8.5 releases and sent the advisory's own trigger to both under Apache mod_php. On the vulnerable build the Zend memory manager flagged its own heap as corrupted and the worker aborted. On the fixed build the same input was handled cleanly and the worker stayed up. That is strong evidence the update works, not proof against every attack path, so patch now.


Advisories tell you a patch exists. We wanted to see it hold. So we built two copies of PHP from the official source, the last vulnerable release on the 8.5 branch, PHP 8.5.11, and the first fixed one, PHP 8.5.12, and sent the same hostile request to each in a sandbox. On 8.5.11 the engine reached a state where its own heap integrity check fired and the worker aborted. On 8.5.12 the same request finished cleanly and the worker kept serving.


This post walks through why the bug works, what The PHP Group changed, what the diffs show, what our test does and does not prove, and the order to patch in.


Why the old out of memory path was dangerous


php2shell is the name for two critical PHP remote code execution bugs, CVE-2026-109618 and CVE-2026-109619. Both share one root cause. When PHP ran out of memory, the Zend memory manager called zend_bailout, a longjmp out of whatever code was running.


If that jump fired while a request was only half set up, or while a worker still held pointers into memory that had not been reset, PHP skipped cleanup. Request shutdown, or the startup of the next request on the same worker, then ran on corrupt or stale state. That is a use-after-free, and with a crafted heap layout it becomes remote code execution. The corruption window and the path to code execution are two separate things: reaching corrupt state is one step, grooming the heap so the corruption lands as code execution is a further step. The advisory names Apache mod_php and FrankenPHP as SAPIs where request shutdown runs even when startup failed, so the corruption window opens before application code runs.


CVE-2026-109618 is the half-initialized request case. CVE-2026-109619 is the stale-pointer case a worker keeps across requests. The advisory trigger is a multipart request with a very large number of part headers under a tight memory_limit, so the engine hits out of memory while parsing the body.


What the fix commit changes


One upstream commit fixes both bugs: 31c0ea7d, titled "Avoid state corruption via bailout". The core change is in Zend/zend_alloc.c. The PHP Group removed the out of memory longjmp path. Out of memory now defers a clean fatal to a safe point, or exits the worker past a hard limit.


Soft out of memory no longer longjmps: the engine records a pending error and raises the fatal at the next safe point so cleanup still runs. Hard out of memory past a second ceiling ends the process with exit code 137, so no corrupted state is processed.


Globals a worker keeps between requests are reset before request memory is freed, closing the stale-pointer window for CVE-2026-109619. The multipart parser is hardened so the same paths fail cleanly instead of bailing mid-parse.


What the source diff and binary diff show


We compared php-8.5.11 to php-8.5.12 from the official php-src tags. The core change sits in Zend/zend_alloc.c, with supporting resets in main, session and related files, plus new out of memory regression tests.


The Zend allocator is linked into the php executable, so the symbol table is a useful check. In PHP 8.5.11 the old handler zend_mm_safe_error is present. In PHP 8.5.12 that symbol is gone, and new symbols appear for the deferred soft path and the hard-limit path, including zend_mm_delayed_verror and zend_mm_hard_limit_error. The changed function is the Zend memory manager out-of-memory handler. We then had REA reproduce the comparison with its objdump tool, and it returned the same symbol-level delta. REA's Ghidra auto-analysis timed out on these large unstripped binaries with debug symbols, so this is a symbol-level confirmation, not a decompiled before and after. The source diff and the binary diff tell the same story.


What we ran, and what it does not prove


We built both versions in a sandbox and aimed the advisory's own trigger at each, this time under Apache 2.4.69 with mod_php, a single prefork worker, on stock non-debug release builds. We also turned on fatal error backtraces and set a per-request INI value that Apache reapplies before request startup, which is the diagnostic that opens this particular next-request window. These are the builds, the SAPI and the settings where the shutdown corruption window actually opens.


The sequence follows the advisory's shape. Request one, the producer, is a multipart body with about 80,000 part headers under memory_limit=8M. Request two, the consumer, is a plain GET on the same worker.


On PHP 8.5.11, request one hit out of memory mid-parse and returned the fatal error with a stack trace. Request two then dropped its connection: the worker aborted. The Apache error log carried the line zend_mm_heap corrupted, and the process exited with status 134, which is a SIGABRT. Under GDB the abort pins to zend_mm_panic in Zend/zend_alloc.c, called from zend_mm_get_next_free_slot, the freelist integrity check. In plain terms, request one's bad out of memory path left the allocator's freelist metadata inconsistent, and request two's first allocation tripped the check.


On PHP 8.5.12 the identical input completed. Request one returned a normal application response, request two returned a normal response from the same worker process id, and there was no corruption message and no abort. We also forced a real soft out of memory on 8.5.12, with a large form body rather than the header trigger, and the worker still reported the fatal cleanly and served the next request on the same process without corruption.


Here is what this proves and what it does not. We showed detector-visible heap corruption on the vulnerable build and its absence on the fixed build. The detector is Zend's own freelist integrity check, not AddressSanitizer, so this is not a classic use-after-free stack trace and it is not a weaponized shell. Reaching corrupt state is one thing, and turning it into code execution needs a crafted heap layout we did not build. So what we can say is that the vulnerable build reaches real heap corruption on this path and the fixed build does not, in these tests. We did not capture a use-after-free trace and we did not demonstrate code execution.


Why debug builds hide this, and why that matters


This is the part that confuses people. On our debug builds, including our earlier AddressSanitizer builds with ZEND_DEBUG enabled, the corruption does not show, because the full shutdown closes the window. It is easy to conclude from that the patch changed nothing or that there is nothing to patch. That conclusion is wrong, and here is why.


A debug build forces a full request shutdown that destroys the retained fatal error backtrace before the request heap is recycled, which closes the window before the next request can touch the stale state. Our earlier AddressSanitizer builds also ran that debug shutdown, so they closed the same window. AddressSanitizer has a second blind spot on top of that: freed request memory stays inside Zend's own memory chunks and is never handed back to the system allocator, so ASan never sees the free and never flags the reuse. The command line and CGI interpreters wrap each request in a guard that catches the old bailout, so they do not expose the window either.


We saw the corruption on a stock non-debug build under Apache mod_php with the settings above. What exposes it is a SAPI that runs request shutdown after a failed startup, or that reapplies per-request configuration early, and mod_php is a common one in production. So a negative result from a debug or AddressSanitizer build does not clear the bug. It often means the test ran in a build configuration that cannot expose it. We ran GDB on the non-debug build, which is the configuration that can.


One more note on tooling. REA ran a symbol-level comparison with its objdump tool on these binaries. It did not produce decompiled function bodies on these large unstripped binaries with debug symbols, so the binary side is a symbol-level confirmation.


Patch order by version


The PHP Group shipped fixed releases on each supported branch:


  • PHP 8.5.12

  • PHP 8.4.27

  • PHP 8.3.36

  • PHP 8.2.35


For 7.4, 8.0 and 8.1 The PHP Group posted minimal source backports, not official releases. The documented PHP Group source backports cover 7.4, 8.0 and 8.1. Distros and LTS vendors may ship their own packages, so check vendor packages for the CVE IDs as well as the version string. Anyone on 8.1 or older should apply the source backport where one exists or upgrade, and consider isolation or shutdown until patched.


What to change this week


Treat this as patch management with a short clock. Inventory every server, site and appliance that runs PHP, including control panels and bundled copies. Ask each host for the version per site and when they will update.


Back up each site and its database, then patch in order of exposure: shops and payment sites first, then portals and control panels, then internal tools. Sites on 8.1 or older need a vendor backport, The PHP Group source backport where one exists, an upgrade, isolation or to be taken offline.


The stopgaps in our first php2shell post help in the meantime, but they do not replace the update. After you patch, confirm the running version is at or above the fixed line for that branch, and rotate passwords, API keys, session secrets and TLS keys on any host that was exposed before the fix. If you are an MSP, run the same inventory across clients, including shared hosting and appliances that stay on old PHP longest.


Hunt impact: existing hunts still stand


This patch verification changes nothing about the network picture. There is no new network artifact, and the hunts in our php2shell advisory post remain correct. They key on post-exploitation behavior: PHP, PHP-FPM or Apache spawning shells, new .php files under upload or cache paths, and the session upload progress marker in access logs. Keep those hunts. Run them before and after you patch.


One optional add for patched fleets: a spike of PHP workers exiting with exit code 137 can flag someone hammering the out of memory trigger on a host that already has the fix. That is a post-patch tuning idea, not a replacement for the exploit hunts. Keep threat hunting for any web shell left behind before the update landed.


Optional, patched fleets only: PHP worker exits with status 137 above baseline


process_name IN ("php-fpm", "php", "httpd", "apache2")
AND exit_code = 137
| count by host, process_name, 15m
| where count > baseline_for(host, process_name)

This is pseudocode. Map process_name, exit_code and host to your process-exit telemetry, and set baseline_for to a per-host average from a quiet week. Confirm that your tooling collects exit codes before you read anything into an empty result. Treat status 137 as a tuning signal, not proof of exploitation.


What This Means for Your Team


Confirm every PHP install runs at least the first fixed version for its branch: 8.5.12, 8.4.27, 8.3.36 or 8.2.35. For a source backport, verify that the fix covers both CVEs. Re-run the existing php2shell hunts before and after the update. Treat an empty hunt on a host with no endpoint coverage as a gap, not a clean bill of health. Do not take the version string on faith. Spot-check one patched host per client this week and confirm the running PHP version.


Sources



Inception Protection


Inception Protection is the Inception Security managed detection and response service, and it works with the Microsoft licenses you already have. We watch Defender and sign-in data in your Microsoft environment, dig into anything odd and work through containment with your team. Web hosting sits outside that environment, so its patching and logs stay with you or your host.


Inception Foresight


A website lives outside your Microsoft tenant, but the SMTP passwords, Microsoft 365 app passwords and API keys it holds can reach back in. To find out whether your Microsoft environment would catch suspicious activity, try the free Inception Foresight M365 Assessment: https://www.inceptionsecurity.com/m365assessment.



bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page