CVE
CVE-2026-109618, CVE-2026-109619
CWE
CWE-416, CWE-908, CWE-843
Affected Surface
- PHP (php-src) 8.2 before 8.2.35, 8.3 before 8.3.36, 8.4 before 8.4.27, and 8.5 before 8.5.12
- End-of-life PHP 8.1 and older lines, which Wiz says are affected back to 5.4 and which get no upstream release
- Apache `mod_php` (confirmed pre-application RCE), PHP-FPM (session-lifecycle RCE validated by the PHP security team), FrankenPHP (likely susceptible), and LiteSpeed LSAPI (conditional)
- Any PHP application on those runtimes, including WordPress, Drupal, Joomla, MediaWiki, and Magento; the bug is in the engine and does not depend on application code
- Official and derived `php`, `wordpress`, `drupal`, and `joomla` container images, and distribution PHP packages until vendor backports land
Most PHP security advisories are about a function or an extension: unserialize(), phar://, mb_*, GD. PHP2Shell is about the engine’s error model. For decades PHP has handled “allowed memory size exhausted” by longjmp()ing out of whatever C code was running, then tearing down the request heap. When that jump lands in the middle of request startup, worker globals can still point at freed or half-initialized request memory. The next request on the same worker reads them.
The PHP security team split the fallout into two CVEs, both CVSS v4.0 9.2:
| CVE | Advisory | Root cause | Demonstrated impact |
|---|---|---|---|
| CVE-2026-109618 | GHSA-w8hw-w9fx-j79w | OOM longjmp() interrupts initialization while request memory is reachable but not yet valid. Shutdown then reads or branches on it. | Unauthenticated RCE over HTTP against mod_php before application code runs. Remote worker memory disclosure through PHPSESSID. |
| CVE-2026-109619 | GHSA-6wmf-4rv5-p3rp | Worker globals (error, session, object, extension state) still point into a freed request heap. A later request uses them before initialization resets them. | Cross-request use-after-free leading to RCE on mod_php and FPM. Wiz built Drupal and WordPress chains from the fatal-error backtrace and OPcache variants. |
Fixed releases are PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12. Wiz says every PHP version from the last decade, back to 5.4, has at least one of the bugs. Lines 8.1 and older are end-of-life, and Aikido reports that the PHP security team has published minimal source backports for 7.4, 8.0, and 8.1.
Why this is already a 0-day problem
Disclosure did not go to plan. Aikido reports that the PHP team published an advance notice on 20 October after details leaked before the embargo ended, and then moved the release up to 21 October. Cloudflare had WAF rules live for all plans from 03:02 UTC on the 21st, roughly nine hours before public disclosure. It says it saw attack traffic against customers and watched attackers mutate payloads to get around the rules. watchTowr says its honeypot network saw exploitation on 21 October and that it reproduced CVE-2026-109618 itself. Neither CVE was in CISA KEV as of the catalog’s 21 October release, but PHP2Shell should be handled as exploited now.
CVE-2026-109618: OOM during multipart startup
PHP parses multipart/form-data bodies (RFC 1867) during php_request_startup(), before any script runs and before normal module request initialization has finished. The advisory lists three ways to push that parser into OOM while staying inside the limits PHP thinks it’s enforcing:
- a multipart request with no usable
Content-Lengthcan pass thepost_max_sizecheck and keep feeding decoded body data - a single part can carry any number of headers, and PHP’s in-memory representation of them is much larger than the bytes on the wire
- empty file controls don’t count toward
max_file_uploadsbut still build the usual$_FILESmetadata arrays, and nested field names multiply that work
Before the fix, hitting memory_limit went through zend_mm_safe_error() in Zend/zend_alloc.c, which raised a fatal error and then jumped straight out:
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
const char *format,
size_t limit,
...
size_t size)
{
heap->overflow = 1;
zend_try {
zend_error_noreturn(E_ERROR, format, limit, ... size);
} zend_catch {
} zend_end_try();
heap->overflow = 0;
zend_bailout(); /* longjmp() to the nearest zend_try */
exit(1);
}
zend_bailout() doesn’t care what the caller was halfway through. The allocation that fails doesn’t even need to be related to the state left invalid. It only has to happen while some request structure has been published but not finished.
The SAPI decides whether that state gets read. Apache mod_php always runs request shutdown, even when request startup failed, so the corrupted state is processed before any PHP file is opened. That is why Wiz describes even a “Hello World” page as exploitable. FPM kills the child when startup fails, which closes the startup path, but an OOM after normal startup can still leave invalid state for shutdown. The advisory treats FPM as susceptible without a complete FPM exploit for this CVE. It describes FrankenPHP as likely susceptible in the same way as mod_php, and LSAPI as following the FPM model.
Memory disclosure through PHPSESSID
Wiz’s write-up includes a non-RCE primitive that matters for triage. Session upload-progress handling (session.upload_progress.enabled, on by default) can initialize session state during multipart startup and publish a session ID in PS(id). If OOM aborts the request at that point, the request heap is freed and PS(id) keeps pointing into it. On a later multipart request to the same worker, the upload-progress cleanup path runs before normal session initialization resets the pointer. PHP treats the stale pointer and length as a live zend_string and URL-encodes the reclaimed heap into the PHPSESSID response cookie.
This works even if the application never calls session_start(). The attacker controls how much memory comes back, and Wiz reports that the disclosed region contained data from other requests. That can include passwords, tokens, API keys, and certificates. An oversized or malformed PHPSESSID cookie in a response is one of the more useful detection signals.
CVE-2026-109619: stale globals across requests
The second advisory generalizes the problem as a producer and a consumer:
- a reachable operation stores request-owned state in a worker global (the producer)
- a fatal error skips the matching reset, or a later diagnostic repopulates the global after it was cleared
- PHP frees the request heap while the global still points into it
- the same worker takes another request
- something reads, frees, or branches on that global before normal request initialization replaces it (the consumer)
Fatal interruptions include OOM, parse and compile errors, uncaught errors, a failed require, and Maximum execution time exceeded. The advisory confirms two early consumers.
The first is any INI diagnostic raised before request initialization. With mod_php, .htaccess and Apache php_value, php_flag, and php_admin_* directives are applied before php_request_startup(). A deprecation as ordinary as auto_detect_line_endings=1 is enough. error_reporting, @, display_errors, and log_errors don’t help, because PHP touches the stale error state before it decides whether to report anything. FPM is exposed in the same way when nginx or mod_proxy_fcgi passes PHP_VALUE or PHP_ADMIN_VALUE.
The second is session upload progress, which reaches session and unserializer state before ordinary session initialization.
Wiz reported four bugs to PHP. Besides the PHPSESSID leak, three became RCE chains. Wiz files the first under CVE-2026-109618 and the other two under CVE-2026-109619, but all three follow this producer and consumer pattern:
- Upload-progress unserializer use-after-free. Session decoding during upload progress stores bookkeeping in
BG(unserialize). An OOM leaves that published after the heap is destroyed. Combined with thePHPSESSIDleak to defeat ASLR, this produced pre-auth RCE againstwordpress:latest,drupal:latest, andjoomla:latest. - Fatal-error backtrace use-after-free (PHP 8.5). With
fatal_error_backtracesenabled,EG(last_fatal_error_backtrace)is never reset during shutdown. A diagnostic early in the next request dereferences it. The PHP team calls this the strongest demonstrated case, and Wiz used it for a Drupal RCE. - OPcache type confusion.
zend_accel_script_persist()advances the process-localCG(map_ptr_last)before it publishes the shared frontier. An allocation failure between those two steps leaves persisted objects referring to slots another worker will reuse for a different type. Wiz used this for a WordPress RCE.
What the fix changes
All four branches take the same change, php-src commit 3584a98bda0f (“Avoid state corruption via bailout”, Arnaud Le Blanc with Ilija Tovilo, Jakub Zelenka, Nora Dossche, and Nicolas Grekas). It does two things.
First, it removes longjmp() from the soft OOM path. zend_mm_safe_error() is gone. Exceeding memory_limit now queues a delayed error and sets the VM interrupt flag, so the engine raises the fatal error at the next safe point instead of in the middle of an allocation:
static ZEND_COLD void zend_mm_delayed_verror(const char *format, va_list args)
{
if (ZEND_HAS_OOM()) {
return;
}
...
EG(delayed_oom).message = zend_string_init(buf, strlen(buf), /* persistent */ true);
EG(delayed_oom).filename = script_filename;
EG(delayed_oom).lineno = script_lineno;
EG(delayed_oom).state = ZEND_OOM_STATE_PENDING;
zend_atomic_bool_store_ex(&EG(vm_interrupt), true);
}
So that a delayed error can’t become unbounded allocation, the patch adds a second, higher hard limit. Crossing it skips cleanup entirely and terminates the process:
if (!heap->fallible && add_size > heap->hard_limit - heap->real_size) {
zend_mm_hard_limit_error(heap, add_size ZEND_FILE_LINE_RELAY_CC); /* ... _exit(137); */
}
zend_execute_scripts() checks for a pending OOM after zend_execute(), and zend_deactivate() releases EG(delayed_oom). The commit also adds dozens of OOM regression tests under Zend/tests/, such as oom_array_fill_*, oom_json_decode_*, oom_hard_limit_exceeded.phpt, and oom_interrupt_unfinished_call_frame_fiber.phpt.
Second, it hardens the session upload-progress path. php_session_rfc1867_update() and php_session_rfc1867_cleanup() used to call php_session_initialize() and force the status to active whatever happened. Now they set a guard flag, bail out on failure, and decode objects only as __PHP_Incomplete_Class, so no autoloaders, __wakeup(), or __unserialize() run before RINIT:
- php_session_initialize();
- PS(session_status) = php_session_active;
+ /* Runs before RINIT: no user code may be invoked, see php_session_unserialize_init() */
+ PS(in_rfc1867_update) = true;
+ if (php_session_initialize() == FAILURE || PS(session_status) != php_session_active) {
+ PS(in_rfc1867_update) = false;
+ return;
+ }
static void php_session_unserialize_init(php_unserialize_data_t *var_hash)
{
PHP_VAR_UNSERIALIZE_INIT(*var_hash);
if (PS(in_rfc1867_update)) {
php_var_unserialize_set_incomplete_classes_only(*var_hash, true);
}
}
The RFC 1867 callback also returns early if modules are already active, which covers request_parse_body() calls that could otherwise corrupt a live session. php_rinit_session_globals() now resets PS(in_rfc1867_update), and the old TODO noting that these globals should always be reset at shutdown has been deleted.
Finding exposure
Check the runtime itself and don’t trust the image tag. Distributions often backport without bumping the upstream version, so on distro packages look for the CVE IDs in the changelog:
php -r 'echo PHP_VERSION, PHP_EOL;'
php -i | grep -E '^(Server API|session.upload_progress.enabled|memory_limit|fatal_error_backtraces) '
# Debian / Ubuntu
apt changelog php8.2 2>/dev/null | grep -E 'CVE-2026-10961[89]'
# RHEL / Fedora / Alma / Rocky
rpm -q --changelog php-common 2>/dev/null | grep -E 'CVE-2026-10961[89]'
Inventory PHP base images in Dockerfiles and manifests. Anything below the fixed patch versions, or any floating wordpress, drupal, joomla, or php tag built before 21 October, needs a rebuild:
rg -n '^\s*FROM\s+(\S+/)?(php|wordpress|drupal|joomla)(:|@|\s|$)' --glob 'Dockerfile*' .
rg -n 'image:\s*\S*(php|wordpress|drupal|joomla)' --glob '*.y*ml' .
Aikido notes that the Remi repository (rpms.remirepo.net) would not have updates on release day. If you depend on it, switch to distribution packages or build from source for now.
In logs, the signals named by Wiz, Aikido, and the PHP team include:
- multipart POSTs followed by
Allowed memory size of ... bytes exhaustedor bursts of HTTP 500s - oversized or malformed
PHPSESSIDvalues in response headers - unexplained PHP worker crashes and restarts
- repeated deprecation or warning lines that appear before the target script runs
None of these prove the server is clean. The bugs run below the application, and a failure can happen before PHP writes anything.
If you can’t patch today
Upgrading is the only complete fix. The advisories list partial mitigations that cover different variants, so apply them together, set them administratively (php_admin_value or the main php.ini, not .user.ini), and restart every worker:
; CVE-2026-109618: removes the predictable per-request OOM the confirmed exploit relies on
memory_limit = -1
; CVE-2026-109619: removes the demonstrated upload-progress producer and consumer
session.upload_progress.enabled = 0
; PHP 8.5 only: removes the fatal-error backtrace producer
fatal_error_backtraces = 0
memory_limit=-1 trades the RCE for a denial-of-service risk, so pair it with a frontend cap on decoded body size and a conservative post_max_size. Lower max_input_nesting_level, and bound input variables, multipart parts, and per-part headers. If the application doesn’t take multipart uploads, block multipart/form-data at the edge. Do not use RLIMIT_AS or RLIMIT_DATA as a substitute. They make mmap() fail, and that sends PHP back down the vulnerable longjmp() path. For the stale-state class, the advisory also suggests removing per-request INI overrides that emit diagnostics, and pm.max_requests=1 on FPM pools that can absorb the cost.
Prioritize internet-facing mod_php first, since that is where the application-independent chain was confirmed. Then rotate secrets on any public PHP host that was unpatched while exploitation was active. The PHPSESSID leak can return memory from other users’ requests without ever reaching code execution.
From research to remediation
Check whether this pattern exists in your codebase
Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.
References
- PHP advisory GHSA-w8hw-w9fx-j79w (CVE-2026-109618)
- PHP advisory GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)
- php-src fix commit 3584a98: Avoid state corruption via bailout
- oss-security: CVE-2026-109618 PHP 8.2 to 8.5 RCE via OOM corruption
- NVD: CVE-2026-109618
- NVD: CVE-2026-109619
- Wiz Research: PHP2Shell
- Cloudflare: protections against php2shell
- watchTowr: PHP2Shell FAQ
- Aikido: what you need to know about PHP2Shell