


1. PHP2Shell (CVE-2026-109618 & CVE-2026-109619) Overview
PHP is one of the most widely deployed server-side scripting languages, powering everything from small websites to large commercial applications. Because it typically runs tightly integrated with the web server, a vulnerability in the PHP runtime can affect a broad range of services regardless of the application framework or business logic built on top of it.
On October 21, 2026, the PHP project disclosed CVE-2026-109618 and CVE-2026-109619.


Collectively known as PHP2Shell, these two vulnerabilities allow an unauthenticated remote attacker to execute arbitrary code with the privileges of the PHP web worker through crafted HTTP request. Successful exploitation can expose or tamper with web application data and may serve as a foothold for lateral movement to other systems within the server environment.
PHP2Shell resides in PHP Core itself, not in any particular framework or plugin. As a result, every service running PHP is a potential target, independent of how the application is configured. Given this broad impact and the potential for remote code execution, both CVE-2026-109618 and CVE-2026-109619 have been assigned a CVSS v4.0 score of 9.2.
PoC Video
If you need to quickly determine whether your systems are affected, skip ahead to Section 6, which covers our PHP2Shell scanner and how to use it.
In this post, we analyze the root cause of PHP2Shell, identify the affected environments, and outline the actions required to respond.
2. Technical Background
Understanding the root cause of PHP2Shell requires some familiarity with how PHP manages memory internally and how the Zend Engine represents and manages data.
2.1 Request Memory and Process-Scoped State
While handling an HTTP request, PHP stores the strings, arrays, objects, and other values created during that request in per-request memory. This memory is released when the request completes. Under Apache mod_php or PHP-FPM, however, a single process serves many HTTP requests in succession, so the process itself—and part of its internal state—persists after each request ends. Internal state here does not refer to $GLOBALS in PHP code, but to values that PHP Core and extension modules maintain at the C level.
Data inside PHP can therefore be divided into two categories: request-scoped data, which is valid only for the duration of a single request, and process-scoped state, which survives across requests.
2.2 Zend Engine and Core Data Structures
The Zend Engine is the core component of PHP responsible for interpreting and executing PHP code. It compiles PHP code into opcodes and executes them, and it handles the primary functions of the PHP runtime, including function calls, variable and object management, memory allocation, and error handling.
Every value in PHP is represented by a zval data structure. A zval holds either the actual value or a pointer to another data structure, along with type information describing what kind of value it contains.
PHP arrays and several internal symbol tables are implemented as a HashTable. Each element of a HashTable is stored in a Bucket, which consists of a zval holding the value, the hash of the key, and a pointer to the string key.
A HashTable tracks the range of the Bucket array to be traversed via nNumUsed, and the number of currently valid elements via nNumOfElements. When a HashTable is destroyed, the Zend Engine iterates over the Bucket array up to nNumUsed, releasing each valid zval according to its type information and the pDestructor registered on the HashTable.
2.3 Out-of-Memory and Bailout
PHP limits the maximum amount of memory a single request may consume through the memory_limit setting.
Even when the system still has physical memory available, a request that exceeds the configured memory_limit triggers an out-of-memory (OOM) error inside PHP.
To abort execution when a fatal error occurs, the Zend Engine uses a bailout mechanism. Rather than having each C function propagate the error back to its caller in turn, bailout transfers control directly to a predefined recovery point.
The Zend Engine’s zend_try registers the current execution point as a recovery point using setjmp(). When zend_bailout() is later called from a nested function, longjmp() transfers control to the most recently registered recovery point, and execution resumes in the corresponding zend_catch block.
In this process, none of the functions between the current call site and the recovery point go through a normal return. Any remaining code in those functions is never executed—including cleanup logic that should have run before the function returned.
The next section examines how these memory management mechanisms and data structures tie into the root cause of each vulnerability.
3. Root Cause Analysis Part 1: CVE-2026-109618
This section analyzes the root cause of CVE-2026-109618, the first of the two PHP2Shell vulnerabilities.
The security advisory published by PHP describes the vulnerability as follows:

Based on this description, the conditions required for CVE-2026-109618 can be summarized as follows:
Memory becomes temporarily reachable before it has been fully initialized.
While the state in (1) persists, PHP performs a memory allocation.
The allocation in (2) triggers an OOM error, and
longjmp()aborts the current execution flow.PHP fails to properly handle the early exit caused by (3), leaving request memory in an incomplete state.
During the subsequent request shutdown, reading this incomplete memory or branching on its contents can result in memory corruption.
Next, we walk through the vulnerable code prior to the patch to see how these conditions arise in practice and how they lead to memory corruption.
3.1 Multipart Input Registration
To trace the conditions above through the code, we first need to look at how a multipart request is registered into PHP’s internal data structures.
PHP automatically parses POST requests with a multipart/form-data body before any application code runs. During request initialization, a POST handler is selected based on the Content-Type; for multipart/form-data, the registered handler is rfc1867_post_handler().
This handler parses the regular fields and uploaded file information contained in the multipart request and populates $_POST and $_FILES. Notably, even a single file input produces multiple entries such as tmp_name, error, and size, and nested field names are converted into multi-level PHP arrays. The resulting values are ultimately registered into a Zend Engine HashTable via php_register_variable_ex().
String components of multipart field names become keys in these HashTable structures. In addition, a large number of headers, empty file inputs, and nested field names can significantly inflate memory consumption during request processing.
An attacker can exploit these characteristics to trigger an OOM error while PHP is registering input values, thereby triggering the vulnerability remotely.
php_register_variable_ex() in main/php_variables.c parses input names such as file[metadata][name] and creates a nested array for each index. If the current HashTable does not contain the given index, it creates a new array and inserts it into the HashTable via zend_symtable_str_update_ind().
The nested structure an attacker specifies in a multipart field name is therefore translated into a sequence of HashTable insertions that repeatedly create new arrays and string keys. When a string key is inserted, _zend_hash_str_add_or_update_i() is called internally.
3.2 Incomplete Bucket Initialization
The actual problem arises in the path where _zend_hash_str_add_or_update_i() adds a new Bucket.
When determining the index of the new Bucket, this code increments nNumUsed and nNumOfElements first. From that point on, the new Bucket falls within the range that the HashTable manages and traverses.
However, the Bucket’s key, h, and val have not yet been initialized. The new Bucket is now tracked by the HashTable, but its contents are incomplete—this is where the first condition identified earlier is established.
Next, zend_string_init() allocates memory to create the string key to be stored in the Bucket. This corresponds to the second condition.
In the normal case, the code following zend_string_init() executes and completes the Bucket initialization. But if an OOM error occurs while allocating the string key, bailout aborts the current execution flow. Since the function never goes through a normal return, the incremented nNumUsed is not rolled back, and the remaining Bucket fields—including p->val—are never initialized.
3.3 Invalid Bucket Processing During Request Shutdown
When bailout is triggered by an OOM error, the rest of the current function does not execute. Apache mod_php, however, still calls php_request_shutdown() to run the request shutdown sequence even when request initialization has failed.
During request shutdown, PHP destroys the HTTP superglobals, including $_POST and $_FILES, in order.
Because $_FILES is an array-type zval, destroying it ultimately leads to zend_array_destroy(). This function uses nNumUsed to compute the end of the Bucket range to traverse in the HashTable.
In the earlier HashTable insertion, nNumUsed was incremented before the new Bucket was initialized. As a result, even though the Bucket initialization was interrupted by the OOM error, the Bucket still falls within the traversal range computed by zend_array_destroy().
Depending on the state of the HashTable, zend_array_destroy() either releases the val of the incomplete Bucket or checks whether the type of val is IS_UNDEF. When string keys are in use, it also inspects and releases key.
But the val and key of this Bucket were never initialized at the time of the OOM error. PHP ends up reading uninitialized data as if it were zval type information or a pointer, and decides whether to invoke a destructor based on that data.
If the incomplete val is interpreted as a value of a reference-counted type, such as a string, array, or object, i_zval_ptr_dtor() treats the pointer inside it as a valid address and may modify its reference count or invoke the type-specific destructor. Likewise, if the uninitialized key happens to look like a valid pointer, zend_string_release_ex() treats that address as a string object.
In summary, the root cause of CVE-2026-109618 can be described as follows:
4. Root Cause Analysis Part 2: CVE-2026-109619
We now turn to the root cause of the second PHP2Shell vulnerability, CVE-2026-109619.
The security advisory published by PHP describes the vulnerability as follows:

As a result, a pointer that was valid in the previous request becomes a dangling pointer referencing freed memory in the next request, and reusing it leads to a use-after-free (UAF).
CVE-2026-109619 can surface through several internal states, including those of error handling, sessions, objects, and extension modules.
Among the paths identified in the PHP security advisory, we focus on how session state leaks across the request boundary via session upload progress.
4.1 Session State Initialization During Multipart Parsing
The session path of CVE-2026-109619 also begins during multipart request processing.
PHP’s session upload progress feature receives events from the multipart parser in order to record file upload progress in the session.
This feature is enabled by default. When a multipart request contains a PHP_SESSION_UPLOAD_PROGRESS field along with a valid session ID, php_session_rfc1867_callback() begins tracking upload progress.
This callback can run not only before any application code, but also before the session module’s normal request initialization. For that reason, it initializes the session state it needs directly inside the callback.
php_rinit_session(0) initializes the session module’s per-request state, and the code that follows stores the session ID found in the request into PS(id). PS() is global state managed by the session module at the C level and persists for the lifetime of the process, but the zend_string that PS(id) points to is allocated in the current request’s memory.
Recording upload progress into the session initializes session state through the following call path.
php_session_track_init() creates the array corresponding to $_SESSION and stores it in PS(http_session_vars).
Here, the newly created session array and its zend_reference are allocated in the current request’s memory, but the value pointing to them is stored in the session global PS(http_session_vars).
Consequently, once session upload progress runs, PS(id) and PS(http_session_vars) point into the current request’s memory before the session module’s normal initialization has taken place.
In a normal request, this state is cleaned up during multipart processing or session module shutdown. The problem arises when request handling is interrupted before that cleanup occurs.
4.2 Interrupted Request and Stale Global State
In the normal case, php_rshutdown_session_globals() is called at MULTIPART_EVENT_END to clean up the session state and release the upload progress data.
php_rshutdown_session_globals() frees the request memory pointed to by PS(http_session_vars) and PS(id), then resets each value.
The problem occurs when an OOM or another fatal error is raised after the session state has been created but before MULTIPART_EVENT_END is reached. If bailout aborts multipart processing immediately, the cleanup code above never runs.
Under Apache mod_php, multipart requests are processed in php_hash_environment(). The normal per-request initialization of extension modules begins in the subsequent zend_activate_modules(), and PG(modules_activated) is set only after that initialization completes.
If bailout occurs during multipart processing, zend_activate_modules() is never reached, so PG(modules_activated) remains unset.
The request shutdown sequence checks this flag to decide whether to invoke the RSHUTDOWN handlers of extension modules.
As a result, the session module’s RSHUTDOWN is not executed either.
PHP then continues with the rest of the request shutdown sequence and releases request memory via shutdown_memory_manager().
The memory that PS(id) and PS(http_session_vars) pointed to is thus freed, but the pointer values stored in the session globals are never reset. After the request ends, both values remain as dangling pointers referencing freed request memory.
4.3 Stale State Reuse in the Next Request
Even after request memory has been released, the Apache worker does not exit and remains available to handle the next HTTP request. If the next request is routed to the same worker, the session globals left over from the previous request are carried over as-is.
Normally, when the session module’s RINIT runs, php_rinit_session_globals() resets PS(id) and PS(http_session_vars). However, since session upload progress runs before normal module initialization, the leftover state can be accessed before it is reset.
If the second multipart request contains a session ID and a PHP_SESSION_UPLOAD_PROGRESS field but no file input, MULTIPART_EVENT_FILE_START is never fired.
As a result, the request reaches MULTIPART_EVENT_END without passing through the php_rinit_session(0) call in FILE_START, and php_session_rfc1867_cleanup() is invoked without the existing session globals having been reset.
php_session_rfc1867_cleanup() calls php_session_initialize() directly, without first resetting the session globals.
php_session_initialize() treats PS(id) as the valid session ID for the current request if it is non-NULL, and reads the string data via ZSTR_VAL(PS(id))[0]. But PS(id) is a dangling pointer to a zend_string that was freed in the previous request, so this access touches freed memory and triggers a UAF.
The UAF is already established at this point, but the session globals still hold another piece of stale state. php_session_track_init(), called next, attempts to clean up the existing value of PS(http_session_vars) by calling zval_ptr_dtor() on it if it is not IS_UNDEF.
However, PS(http_session_vars) also points to a zend_reference that was freed in the previous request. zval_ptr_dtor() treats this value as a valid zval and attempts to modify its reference count or destroy its inner value, resulting in a UAF.
In summary, the root cause of CVE-2026-109619 can be described as follows:
5. Course of Action
The PHP project advises that any environment handling web requests with PHP may be affected by these vulnerabilities. Internet-facing PHP-based services—including those running on PHP-FPM, Apache mod_php, php-cgi, FrankenPHP, and LiteSpeed—should be identified first and updated as a priority.
5.1 Affected Systems and Versions
Every internet-exposed asset, from services you operate to third-party products and solutions you have deployed, should be checked for PHP usage. It is important to verify against what is actually running rather than relying solely on the asset inventory.
Where PHP is managed by a hosting provider or product vendor, confirm with that vendor whether the security update has been applied.
The fixed versions released by the PHP project are as follows.
PHP Branch | Affected Versions | Patched Version |
|---|---|---|
PHP 8.5 | 8.5.11 and earlier | 8.5.12 |
PHP 8.4 | 8.4.26 and earlier | 8.4.27 |
PHP 8.3 | 8.3.35 and earlier | 8.3.36 |
PHP 8.2 | 8.2.34 and earlier | 8.2.35 |
PHP 8.1 and earlier have reached the end of regular security support and should be migrated to a supported version. Security support for PHP 8.2 is also scheduled to end on December 31, 2026, so migrating to PHP 8.3 or later is recommended where possible.
Linux distributions and LTS vendors may backport the security fix while keeping the existing PHP version number. In such cases, do not rely on the version string alone; check the distribution’s or vendor’s security advisory and package changelog as well.
5.2 Apply Security Updates
Starting with internet-facing servers, update to the following versions or later:
PHP 8.5.12
PHP 8.4.27
PHP 8.3.36
PHP 8.2.35
After updating, restart every service that runs PHP, including PHP-FPM, Apache mod_php, and php-cgi. Without a restart, the old PHP code already loaded in memory may continue to be used.
In containerized environments, rebuild application images on top of the patched PHP base image and redeploy the existing containers. Once the update is complete, verify that the PHP runtime actually serving web requests now reports the new version.
Note that php -v run on the server may point to the CLI build of PHP, so confirm that it matches the version used by Apache or PHP-FPM.
5.3 Temporary Mitigations
If immediate updating is not feasible, the following interim measures can reduce exposure to the confirmed attack paths.
For PHP 8.5, also set:
memory_limit=-1 must be applied through php.ini, the php_admin_value directive of the PHP-FPM pool, or the web server’s administrative configuration so that the application cannot override it. Because this setting removes the per-request memory limit, overall memory usage must be bounded separately via containers or cgroups.
post_max_size should be set to the smallest value the service requires. The PHP project’s security advisory suggests 8M as a reference when file uploads are not needed and 32M when they are, and recommends keeping upload_max_filesize below post_max_size.
Request size, the number of multipart parts, input fields, headers, and nesting depth should also be limited at the web server, reverse proxy, or WAF. For services that do not require file uploads, multipart requests can be blocked before they reach PHP.
These settings only restrict specific attack paths and do not fully resolve the vulnerabilities. After changing the configuration, restart all PHP request-handling processes and apply the security update as soon as possible.
6. PHP2Shell Free Scanner Release
Before the technical details of these vulnerabilities were disclosed, a large number of purported PoCs circulated, most of which turned out not to work. Furthermore, PoCs of unknown origin published online may contain malicious code, and the dynamic verification process for PHP2Shell itself can cause OOM errors and abnormal termination of PHP processes.
In production environments, vulnerability status should be determined based on the actual PHP runtime version and whether the vendor’s patch has been applied—not by running PoCs of unknown origin. Where technical verification is required, it should be performed only in a test environment isolated from the business network.
ENKI WhiteHat provides the OFFen ASM (Attack Surface Management) solution and an emergency PHP2Shell scanner, enabling help organizations assess their exposure of these vulnerabilities.
OFFen ASM PHP2Shell Vulnerability Scanner

7. References

人気の記事
関連記事








