How PHP2Shell works
How each bug works, which server setups were tested, what the fix changes and how this checker looks for exposure. The page explains mechanisms only, with no exploit code or attack steps.
Last reviewed . Rules of .
Overview
Both bugs come from state that PHP leaves behind when it stops work early. On a fatal or out-of-memory (OOM) error, PHP takes an emergency exit called a bailout. In some places that exit leaves internal data in a state that later code still trusts.
- CVE-2026-109618 (GHSA-w8hw-w9fx-j79w): the OOM exit leaves request data half set up. Request cleanup then reads that broken data.
- CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp): data in a PHP worker keeps pointing into a request's memory after that memory is freed. The next request on the same worker can use it before PHP resets it.
The PHP ChangeLog lists a single fix for both advisories, "Avoid state corruption via bailout", in 8.2.35, 8.3.36, 8.4.27 and 8.5.12. The PHP project rates both CVEs 9.2 (Critical) on CVSS v4.0.
CVE-2026-109618: out-of-memory corruption
Before the fix, PHP left the current context with longjmp() when memory ran out. During some steps of a request, request memory is reachable before it is fully set up. If any allocation fails at that moment, PHP does not account for the early exit, even when the failed allocation has nothing to do with the half-built data. Request shutdown can then read or branch on the invalid memory. The advisory says several ways of running out of memory reach this state reliably and lead to code execution.
Ways a request can run out of memory
The advisory names three gaps in multipart request parsing. Each lets a request run out of memory while it still appears to be within PHP's limits:
- No usable declared length. A multipart request without a usable declared content length can pass the first
post_max_sizecheck and keep sending decoded body data. - Unlimited part headers. A multipart part can carry any number of headers, and inside PHP they take much more memory than on the wire. A request within
post_max_sizecan therefore use up the normalmemory_limit. - Empty file inputs. Empty file controls do not count toward
max_file_uploads, but PHP still builds the normal file metadata arrays for them. Nested field names multiply this work.
Application code can also run out of memory in its own ways, for example when attacker input causes one large allocation or repeated memory use.
Why the server setup matters
- Apache mod_php always runs request shutdown, even when request startup failed. Automatic body parsing during startup can run out of memory in the unsafe state, and shutdown then processes that state, all before any application code runs.
- PHP-FPM ends the child process when request startup fails, so the broken state from startup is never used. After a normal startup, an OOM can still happen in an unsafe place, and shutdown can then process the broken state. Code analysis shows FPM is at risk, but no complete FPM attack was shown for this CVE.
- FrankenPHP, by code analysis, always runs request shutdown, as mod_php does. That makes it likely open to attacks that need no application help.
- LSAPI (LiteSpeed) works like FPM here. Attacks that use a specific application are likely, but none was confirmed.
CVE-2026-109619: stale state across requests
A PHP worker keeps some global state between requests, such as error, session, object and extension state. Some of it points into memory that belongs to a single request, so PHP must reset it before it frees that memory. Some input, configuration, session and error steps run before the next request is fully set up, and they can read state that the previous request did not reset. That is a use-after-free. With a carefully shaped heap it can lead to full code execution.
The producer and consumer model
An attack needs two parts. The producer is a PHP operation that stores request-owned state where a later request can reach it. The consumer is a later operation that reads, frees or acts on that stale state. The advisory lists five conditions:
- A reachable operation stores request-owned state in a global place (the producer).
- The state is not reset, or another operation fills it again after its normal reset.
- PHP frees the request memory while the worker still holds the global reference.
- The same worker handles another request.
- An operation uses the stale state before normal request setup resets it (the consumer).
In the demonstrated cases, the producer and the consumer run in different HTTP requests on the same worker. More workers and worker recycling lower the success rate per request, but the condition stays.
Producers
State stays behind when a fatal error stops an operation before its matching reset. Fatal interruptions include:
- parse and compile failures;
- uncaught errors;
- a failed
require; Maximum execution time exceeded;- out of memory, when error, exception, object or extension state was already published.
A non-fatal warning from extension or server cleanup can also fill the error state again after shutdown already cleared it.
Confirmed consumers
The advisory confirms two consumers that run before normal request setup:
- INI diagnostics before request setup. A warning, notice or deprecation raised while PHP applies an invalid or deprecated setting can use the stale error state. The message does not need to be fatal, shown or logged.
- Session upload progress. It can reach session handling before normal session setup, and then use session or decoder state from an interrupted request. It is on by default and needs no
session_start()call in the application.
Early INI consumer examples
The advisory gives these examples of settings that raise a diagnostic early. On an unpatched PHP, such a setting applied before request setup can act as a consumer:
- Deprecations:
auto_detect_line_endings=1, a non-defaultfilter.default, legacyassert.*settings on PHP 8.3 and later, legacy session settings on PHP 8.4 and later, andreport_memleaks=0or a nonzerointl.error_levelon PHP 8.5. - Any explicitly set
mbstring.http_input,mbstring.http_output,mbstring.internal_encoding,iconv.input_encoding,iconv.output_encodingoriconv.internal_encoding. - Invalid or conflicting values for
zend.assertions,memory_limit,date.timezone, DBA, EXIF, mail, OpenSSL, zlib and loaded extensions. - Malformed or overflowing quantities on PHP 8.2 and later, such as an invalid
memory_limit, body size, input, timeout or stack value. - Log, mail, session or SOAP cache paths that
open_basedirrejects.
Whether this matters depends on where the setting comes from:
- mod_php applies
.htaccessand Apachephp_value,php_flagandphp_admin_*directory settings before request startup. The normalphp.iniis startup configuration and is not a consumer. - PHP-FPM is exposed only when nginx, Apache
mod_proxy_fcgior another frontend sendsPHP_VALUEorPHP_ADMIN_VALUEparameters that raise a diagnostic. FPM pool values apply when a worker starts, and.user.iniapplies after normal setup, so neither is an early consumer. - LSAPI can apply special environment INI values early. The advisory calls it a conditional candidate.
- FrankenPHP: no equivalent early configuration consumer was found in its default configuration. The advisory says this does not make it immune.
Hiding errors does not help. PHP touches the global state before it decides whether to report, show or log a warning. Changing error_reporting, using @, or turning off display_errors or log_errors gives no defence against this CVE.
Confirmed cases
- PHP 8.5 fatal error backtraces. With
fatal_error_backtraceson, fatal error reporting stores request-owned backtrace state, and shutdown always misses its reset. A diagnostic in a later request reaches the stale value. This is the strongest case and the basis of the confirmed mod_php code execution. - Session upload progress lifecycle. An upload sets up session and decoder state early. An OOM or other fatal error after that can skip the reset. A later upload progress step then treats the freed state as valid. On mod_php, both steps can happen during multipart startup. On FPM, the validated path publishes the state during application code and needs a suitable operation in the application.
- Other error, exception, object and extension state is conditional. It depends on the application, its callbacks and its extensions. The patch resets this wider state too.
Validation status per server setup
What the two advisories say about each way to run PHP. "Validated" means the researchers showed a complete attack. Everything else rests on code analysis or is unknown.
| Setup | CVE-2026-109618 | CVE-2026-109619 |
|---|---|---|
| Apache mod_php | Validated over HTTP: code execution with default input and memory limits and a normal multi-worker prefork setup | Validated: code execution through the PHP 8.5 fatal backtrace path and the session upload progress path |
| PHP-FPM | At risk by code analysis; no complete attack shown | Validated: code execution through session upload progress, with a suitable application operation. The INI path applies only when the frontend sends PHP_VALUE or PHP_ADMIN_VALUE |
| FrankenPHP | Likely open to attacks that need no application, by code analysis; no attack shown | No early INI consumer found in the default configuration; not immune |
| LSAPI (LiteSpeed) | Attacks through a specific application likely; not validated | Conditional candidate for the early INI path |
| php-cgi | Named at risk by php.net. The advisories give no separate detail. | |
| IIS | Not named. Our inference: the PHP manual runs PHP on IIS through php-cgi, so the php-cgi row applies. | |
| Caddy | Not named. Our inference: php_fastcgi passes requests to a FastCGI server, usually PHP-FPM, so the PHP-FPM row applies. Caddy with FrankenPHP: see the FrankenPHP row. | |
What the fix changes
Soft and hard out-of-memory limits
The patch stops using longjmp() for out-of-memory errors and sets two limits instead:
- A soft OOM at
memory_limit. PHP raises the error a little later, at the next point where the engine can stop cleanly, and ends the request in an orderly way. - A hard OOM at a second, higher limit. PHP writes a line that starts with
Hard memory limit ofto stderr and ends the process at once with_exit(). Shutdown handlers do not run.
Request shutdown no longer sees invalid state, and PHP still limits memory use.
memory_hard_limit_percent
The fixed releases add this INI setting, which sets the hard limit as a percentage of memory_limit. We checked it in the source of 8.5.12 and 8.2.35.
- It is unset by default, which PHP treats as
-1. - With
-1, the hard limit is 2 ×memory_limitfor limits up to 1G, andmemory_limit+ 1G for limits above 1G. - Other allowed values are 100 or more. PHP rejects anything else with a warning.
Wider state reset
For CVE-2026-109619, the patch resets the wider error, exception, object and extension state, so it cannot survive into steps that run before normal request setup.
After you update
- Restart every PHP worker, so the new code runs.
- If you set
memory_limit=-1as a stopgap, set a finite limit again. - Expect the new hard limit. A process that passes it ends without shutdown handlers. Review this for your application.
- The advisory suggests keeping
session.upload_progress.enabled=0after the patch if you do not use the feature. - Limits on input variables, multipart parts, headers per part and uploads still lower the risk of hard OOM and denial of service after the patch.
Regression note: GH-24488
php-src issue GH-24488 reports zend_mm_heap corrupted crashes in 8.4.27 and 8.5.12. The crash happens when SimpleXML element properties are materialised inside a garbage collection cycle. The report ties it to the fix for issue GH-24050. It is a crash, not a reported vulnerability. On 2026-10-22 the issue was closed with a fix in the php-src source (commit f963484725). No PHP release contains it yet.
Do not downgrade: that brings back both critical bugs. Watch php.net for a follow-up release.
How this checker detects exposure
The normal check is passive. An optional active scan, which the user starts separately, sends test requests instead. The normal check sends at most three ordinary GET requests: the submitted URL, plus redirects to the same host or its www twin. It reads the same kind of response that a normal visitor gets. The operators page lists the exact requests and limits.
What it reads
- PHP version tokens in the
X-Powered-Byheader. PHP'sexpose_phpsetting is on by default, so many installs sendX-Powered-By: PHP/plus the version. - Branch thresholds. It compares each version with the first fixed release of its branch: 8.2.35, 8.3.36, 8.4.27 or 8.5.12.
- Vendor build hints. Some distribution builds keep an old upstream number, or add a package suffix. For known cases, the checker asks you to compare the package version instead of trusting the number.
- No
Serverheader. The checker runs on Cloudflare, and Cloudflare replaces theServerheader of every response it fetches. So the checker does not read it. - Context: CDN markers, session cookie names (never values) and product markers in the same page, such as a generator tag. Product markers never change the PHP verdict.
Limits
- A hidden version gives no verdict. A missing
X-Powered-Byheader is neither a good sign nor a bad one. - Vendor backports often keep the old version number. A header cannot show whether a backport is in place.
- A CDN, cache or load balancer can hide the origin, rewrite headers, or put several servers behind one URL. The checker sees only the responses it gets.
- The checker cannot see
php.inisettings, the real server setup, other sites on the same server, or past attacks. - A "fixed version reported" result is a public signal, not proof for every server behind the URL.
Why the normal check sends no exploit-shaped requests
A checker could try to confirm these bugs by sending requests shaped like the attack. The normal check does not.
- We do not own the server. Anyone can type any URL into a public checker. Attack-shaped traffic to a site you do not own is abuse, even with good intent.
- It can break a live site. The bugs involve memory exhaustion and broken worker state, and a test of that kind can fail requests or kill workers.
- It would not change the advice. The fix is the same in every case: update PHP and restart. A version signal, or the lack of one, is enough to tell you what to check next.
So the normal check sends no request bodies, no multipart data, no uploads, no payloads and no requests to other paths.
The optional active scan is the exception, for owners who want a stronger signal. It sends a few multipart test requests from our scanner server, so the risks above apply to it. That is why it is a separate action: it needs its own confirmation that the user owns the site or is authorized to test it, a new human check, and it runs at most once per site every 10 minutes. Its result is still an indicator, not a confirmation: a crash signal or an unpatched version banner needs checking against the installed PHP package, and no signal does not show that the site is not affected.
Sources
- php-src: GHSA-w8hw-w9fx-j79w, RCE via OOM corruption (CVE-2026-109618)
- php-src: GHSA-6wmf-4rv5-p3rp, RCE via stale global state (CVE-2026-109619)
- php.net: Security advisory, 21 October 2026
- php.net: PHP 8 ChangeLog
- php-src 8.5.12: main/main.c (memory_hard_limit_percent)
- php-src 8.5.12: Zend/zend_alloc.c (hard limit)
- php-src 8.2.35: main/main.c
- php-src: issue GH-24488
- php-src: php.ini-production (expose_php)
- php.net manual: Installing PHP on IIS
- Caddy docs: php_fastcgi directive
- php2shell.com: Upgrade PHP
Check your site
See which PHP signals your site sends to the public. The check is passive. Check my site.