Research/php2shell
N-dayCVE-2026-109618CriticalPublic

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-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:

Plain text
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:

Plain text
Fatal error: Allowed memory size of 8388608 bytes exhausted
(tried to allocate 4096 bytes) in Unknown on line 0

Unknown 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:

Plain text
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-WITNESS

Lab 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:

  1. A producer stores request-owned state in a worker global.
  2. The reset is skipped, or a later diagnostic repopulates it after the reset.
  3. PHP frees the request heap.
  4. The same worker takes another request.
  5. 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:

Plain text
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-WITNESS

Lab 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=-1 that applications cannot override. Low post_max_size. Bound multipart parts and headers. Do not use RLIMIT_AS or RLIMIT_DATA.
  • 619: fatal_error_backtraces=0 on 8.5. session.upload_progress.enabled=0. Drop per-request deprecated INI. pm.max_requests=1 if you can afford it.

There is no complete generic configuration mitigation for 619. An application path can still publish stale state.

Replay

Plain text
python3 php-oom-longjmp-Abraxas-Labs.py
python3 php-stale-global-uaf-Abraxas-Labs.py

or cd lab && ./run.sh in each pack. Loopback. Compose down after SUCCESS.

References

Abraxas Labs. abraxaslabs.tech. github.com/abraxas. @abraxas_null. abraxas.null@proton.me.