PHP2Shell: CVE-2026-109618 and CVE-2026-109619
Independent loopback labs of PHP2Shell. CVE-2026-109618 is an OOM longjmp during multipart parse before application code. CVE-2026-109619 is a same-worker UAF on a stale fatal backtrace. Last-affected Apache mod_php vs patched 8.4.27 / 8.5.12. Advisory RCE inferred. Lab class is OOM abort and UAF/Crash.
- Name
- PHP2Shell: CVE-2026-109618 and CVE-2026-109619
- Type
- N-day analysis
- CVE
- CVE-2026-109618
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- PHP Group
- Affected
- PHP 8.2 before 8.2.35, 8.3 before 8.3.36, 8.4 before 8.4.27, 8.5 before 8.5.12. Lab pins php:8.4.26-apache and php:8.5.11-apache. Patched in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 on 21 Oct 2026.
- Published
- 24 Oct 2026
- Updated
- 24 Oct 2026
- Tags
- n-day, php, php2shell, cve-2026-109618, cve-2026-109619, oom, uaf, cwe-416, unauthenticated, remote
the engine under the app
I am @abraxas_null. Two loopback labs, one public pair. CVE-2026-109618 lives at abraxas/php-oom-longjmp (loopback client). CVE-2026-109619 lives at abraxas/php-stale-global-uaf (loopback client). Authorized lab only. Both stacks talk to loopback.
Someone on the internet can talk to a PHP site the same way a browser does. No password. No plugin. No "please log into WordPress first." On last-affected PHP the engine itself can be pushed into a bad memory state while it is still reading the request, or while it is still holding leftovers from the request before. The PHP Group showed that state can become remote code execution as the web worker. I did not land a shell. I proved the two gadgets the advisories name, on official Apache images, against the patched builds that shipped a day later.
This is PHP2Shell. PHP rated both CVSS v4.0 9.2 Critical (AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H). CWE-416. Fixed in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 on 21 Oct 2026. Credit for the bugs is Calif, Wiz, OpenAI, and the PHP security team. This post is an independent lab, not a first-finder writeup.
Highest-risk SAPI is Apache mod_php. FrankenPHP is likely for the OOM path. FPM is in the 619 advisory with a different producer. I labbed Apache because that is where PHP demonstrated the application-independent cases.
Why this pair
I already had a php-src pin. Tag php-8.5.11 (678778973bb4d4185c06ef1320de50f1a975d3c8), twenty hunts, skip list of the 8.5.11 GHSA wave. Default php-fpm / apache2handler / LiteSpeed returned nothing unpublished at High. One leftover survived: php-cgi PATH_INFO underflow, Crash, not this story.
Then PHP shipped an emergency release. Two Criticals. Same tree I had just finished calling clean on unpublished default SAPIs. That is the interesting kind of "clean." The unpublished hunt asked whether new bugs were sitting next to the 8.5.11 GHSAs. PHP2Shell is older than that hunt's mental model. longjmp on OOM goes back to PHP 5.4. Session upload progress has been "please do not use this" folklore for a decade. 8.5 added fatal_error_backtraces and forgot to reset it.
I wanted the last-affected official images versus the patched ones. Docker Hub had php:8.4.26-apache / php:8.4.27-apache and php:8.5.11-apache / php:8.5.12-apache the day after the release. That is a better oracle than building php-src twice and hoping I compiled the same flags as Debian.
I wanted Critical. Remote. I did not want a reverse shell in the pack. The oracle is a witness string, a fatal in Unknown on line 0, or zend_mm_heap corrupted. If the patched worker stays up and the last-affected worker does not, the gadget is real. Advisory RCE stays inferred until someone shows RIP in this lab. I did not try.
Pinning last-affected
618 is the OOM longjmp during request parse. PHP demonstrated it on mod_php before application code. 8.4.26 is the last 8.4. 8.4.27 is the fix. Finite memory_limit=8M. Chunked multipart so Content-Length does not save you at the post_max_size gate.
619 is the stale worker-global. The strongest confirmed producer is PHP 8.5 fatal_error_backtraces. Reset is always missed at shutdown. 8.5.11 vs 8.5.12. Prefork pinned to one child so request A and request B are the same process. Consumer is a per-dir INI diagnostic (auto_detect_line_endings, report_memleaks) applied before php_request_startup().
Ports: 18220/18221 for 618, 18222/18223 for 619. Compose projects cve-2026-109618 and cve-2026-109619. Both stacks came down after SUCCESS.
The skip list still matters
8.5.11 already closed a wave. I am not re-filing those:
- CVE-2026-91768 FPM IPv6 ACL
- CVE-2026-91769 TLS CN leftover
- CVE-2026-91767 OpenSSL wildcard heap
- CVE-2026-6103 Phar TAR overflow
- SOAP recursion/HTTP overflow, HTTP wrapper Location, convert.* OOB, Windows reserved names
CVE-2026-115560 (23 Oct) is an informational leftover on session.serialize_handler switching under upload progress. PHP asked people to keep session.upload_progress.enabled=0 until the next regular release. I did not lab it.
php-cgi PATH_INFO underflow leftover of CVE-2019-11043 is still a Crash on php-cgi FastCGI. Stock php-fpm has the length guard. Different SAPI. Different pack.
Public fake "PHP2Shell exploit" repos showed up the same week. Wallpaper pranks. I did not copy them.
CVE-2026-109618: the jump
PHP used longjmp() when an allocation hit memory_limit. That is a nonlocal exit. Request memory can already be reachable while it is still being initialized. Shutdown then treats the invalid state as live.
The HashTable insert is the picture I keep:
idx = ht->nNumUsed++;
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* may zend_bailout() */The live count moves before the key exists. Teardown walks nNumUsed. A fatal in zend_string_init leaves a bucket that was never written.
You reach that insert from ordinary POST registration. URL-encoded bodies go through php_std_post_handler. Multipart goes through rfc1867_post_handler. Both run during php_request_startup(), before the target PHP file. Nested empty file controls and unbounded part headers are how you spend memory_limit while the wire stays under post_max_size. Chunked transfer, or no usable Content-Length, is how you walk past the first post_max_size check.
Apache always calls php_request_shutdown even when startup failed. FPM tends to exit the child on startup failure, which is why PHP did not show a complete FPM exploit for this CVE. Post-startup OOM is still a source-analysis candidate. I did not spend the lab on FPM.
The patch (31c0ea7) stops using longjmp() for OOM. Soft OOM waits for a VM safe point. Hard OOM _exit()s past a second threshold. Shutdown no longer sees the half-built table. PHP still has a memory limit. That is the whole trick.
Lab 618
Source of truth: lab/. Images php:8.4.26-apache vs php:8.4.27-apache. Ports 18220 / 18221.
The trigger that distinguished the builds was GHSA strategy 2: chunked multipart/form-data, one part, 3000 extra X-Pad-* headers of 2500 bytes. About 7.5 MB on the wire. memory_limit=8M. No Content-Length.
8.4.26 answered HTTP 200 with:
Fatal error: Allowed memory size of 8388608 bytes exhausted
(tried to allocate 4096 bytes) in Unknown on line 0Unknown on line 0 is the tell. The script never ran. index.php prints CVE-2026-109618-ALIVE. That marker is absent on the fatal. Apache logged the same OOM as PHP Fatal error from pid 18.
8.4.27 on the same bytes stayed up. Body CVE-2026-109618-ALIVE php=8.4.27 ... files=1. Soft OOM. The app ran. GET after the trigger was still 200 on both sides. The last-affected worker did not SIGSEGV for me. It did not print zend_mm_heap corrupted. ASan was not required for the version differential.
Witness CVE-2026-109618-OOM-WITNESS. Last line:
SUCCESS CVE-2026-109618 vuln-crash=yes patched-ok=yes sapi=apache2handler
vuln-image=php:8.4.26-apache patched-image=php:8.4.27-apache
how=oom-longjmp-startup-fatal trigger=header-bomb-chunked asan=no
CVE-2026-109618-OOM-WITNESSLab class is OOM longjmp / request-startup abort. The vuln-crash=yes token in that line is the OOM fatal, not a dead worker. I am not going to inflate it to RCE because the advisory can.
CVE-2026-109619: the leftover pointer
A PHP worker is a long-lived process. Some globals point at request-owned memory: error state, session decoder state, 8.5 fatal backtraces, extension leftovers. Those pointers have to be cleared before the request heap is released. A fatal can skip the matching reset. A warning during the next request's early INI apply can consume the dangling value before normal initialization replaces it.
The recipe is boring once you write it down:
- A producer stores request-owned state in a worker global.
- The reset is skipped, or a later diagnostic repopulates it after the reset.
- PHP frees the request heap.
- The same worker takes another request.
- A consumer reads that pointer before
php_request_startup()has rebuilt the world.
PHP 8.5 fatal_error_backtraces is the confirmed strongest producer. The backtrace is never reset at shutdown. Session upload progress is the other confirmed arm, on mod_php and FPM, default-on, no session_start() required. I used the backtrace arm because 8.5.11/8.5.12 official Apache images exist and the consumer is a one-line .htaccess.
The INI consumer is not php.ini. Startup config is applied once. Apache .htaccess / php_value / php_flag / php_admin_* run per request, before executor init. auto_detect_line_endings=1 is a deterministic deprecation. report_memleaks=0 is an 8.5 diagnostic. Changing error_reporting, display_errors, or @ does not help. The global is touched before PHP decides whether to print the warning.
FPM pool values and .user.ini are the wrong time. FPM needs PHP_VALUE / PHP_ADMIN_VALUE from nginx or mod_proxy_fcgi to get the same early consumer.
Lab 619
Source of truth: lab/. Images php:8.5.11-apache vs php:8.5.12-apache. Ports 18222 / 18223. Prefork StartServers / MinSpare / MaxSpare / MaxRequestWorkers / ServerLimit all 1. KeepAlive on so producer and consumer can share a connection.
Producer: GET /producer.php. Twelve frames, then trigger_error(..., E_USER_ERROR) with a repeated blob. fatal_error_backtraces=On. Both builds return 200 with a fatal. That is intended.
Consumer: GET /consumer.php with Directory php_flag auto_detect_line_endings on and php_flag report_memleaks off.
8.5.11: consumer Remote end closed connection without response. Apache log zend_mm_heap corrupted. Same pid as the producer.
8.5.12: consumer body consumer-ok pid=18. Worker stays up. GET after still 200.
Witness CVE-2026-109619-UAF-WITNESS. Last line:
SUCCESS CVE-2026-109619 vuln-uaf=yes vuln-crash=yes patched-ok=yes
sapi=apache2handler pin=same-worker
vuln-image=php:8.5.11-apache patched-image=php:8.5.12-apache
how=backtrace crash=connection-reset CVE-2026-109619-UAF-WITNESSLab class is UAF / Crash. Advisory RCE as www-data is inferred. I did not spray the heap. I did not ship a .so.
Wrong turns, in order
Docker Hub DNS failed the first time I asked whether php:8.4.26-apache existed. The tags were there. Pull them.
from __future__ import annotations after print_abraxas_banner() is a SyntaxError. The banner has to be the first executable code. Drop the future import. 3.11 does not need it for X | None.
618 with USE_ZEND_ALLOC=0 and MALLOC_PERTURB_ still did not kill the worker. The OOM fatal was enough to tell 8.4.26 from 8.4.27. I stopped there. An ASan php-src build would be a different lab if someone wants the shutdown UAF in the log.
619 with trigger_error(E_USER_ERROR) on 8.5 prints a deprecation and then the fatal. The deprecation is noise. The backtrace still publishes. Patched 8.5.12 eats the same producer and survives the consumer. That is the control I wanted.
FPM for 618 would have been a comfort object. PHP already said the child exits on startup failure. I did not duplicate that sentence in Docker.
What an attacker can do
CVE-2026-109618
| Method | Sink | Privilege |
|---|---|---|
Unauthenticated POST multipart/form-data, Transfer-Encoding: chunked, many extra part headers, finite memory_limit |
rfc1867 parse / zend_bailout during php_request_startup |
Availability (lab-proven): abort request startup before index.php on 8.4.26 (Unknown on line 0). Integrity (inferred): shutdown walks a half-initialized $_POST/$_FILES; vendor showed RCE as the Apache worker. Recon (inferred): leftover request-heap disclosure. |
| Same trigger on 8.4.27 | delayed soft OOM | Closed (lab-proven): HTTP 200 alive marker, files=1. |
| GET after the trigger | worker still alive | Lab-proven on this pin: no SIGSEGV, no zend_mm_heap corrupted. |
| Login, application route, reverse shell | n/a | Closed in this lab. |
CVE-2026-109619
| Method | Sink | Privilege |
|---|---|---|
Unauthenticated GET producer fatal, then GET consumer INI diagnostic, same prefork child |
8.5 fatal_error_backtraces never reset; INI diagnostic before php_request_startup() |
Availability (lab-proven): kill that child (zend_mm_heap corrupted, connection reset) on 8.5.11. Integrity (inferred): dangling backtrace; vendor showed RCE as www-data. Recon (inferred): leftover request-heap disclosure. |
| Same two-phase on 8.5.12 | complete state reset | Closed (lab-proven): consumer-ok. |
error_reporting / display_errors / @ |
warning still consumes stale state | Closed as a mitigation. |
FPM .user.ini |
applied after normal init | Closed as this early consumer. Session upload progress is a second vendor-confirmed arm. |
No HTTP rate limiter on the SAPI parse. The lab observed no 429.
The fix
Update. 8.5.12, 8.4.27, 8.3.36, or 8.2.35. PHP 8.2 reaches end of life on 31 December 2026. 7.4 / 8.0 / 8.1 got source-only backports.
Until then, partial only:
- 618: admin
memory_limit=-1that applications cannot override. Lowpost_max_size. Bound multipart parts and headers. Do not useRLIMIT_ASorRLIMIT_DATA. - 619:
fatal_error_backtraces=0on 8.5.session.upload_progress.enabled=0. Drop per-request deprecated INI.pm.max_requests=1if you can afford it.
There is no complete generic configuration mitigation for 619. An application path can still publish stale state.
Replay
python3 php-oom-longjmp-Abraxas-Labs.py
python3 php-stale-global-uaf-Abraxas-Labs.pyor cd lab && ./run.sh in each pack. Loopback. Compose down after SUCCESS.
References
- php.net 21 Oct 2026 security release
- GHSA-w8hw-w9fx-j79w CVE-2026-109618
- GHSA-6wmf-4rv5-p3rp CVE-2026-109619
- php/php-src
31c0ea7Avoid state corruption via bailout - CVE-2026-109618
- CVE-2026-109619
- Labs: abraxas/php-oom-longjmp, abraxas/php-stale-global-uaf
- Credit: Calif, Wiz, OpenAI, PHP security team
Abraxas Labs. abraxaslabs.tech. github.com/abraxas. @abraxas_null. abraxas.null@proton.me.