CVE-2026-115560 Explained: The PHP Session Bug That Survives the PHP2Shell Patches

The CyberSec Guru

CVE-2026-115560

If you like this post, then please share it:

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Why your support matters: Zero paywalls: Keep the main content 100% free for learners worldwide.

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

A new PHP session flaw surfaces after the PHP2Shell patches

Late October 2026 has been a rough stretch for PHP security teams. On October 21, the PHP project shipped emergency patches for CVE-2026-109618 and CVE-2026-109619, two memory corruption bugs that allowed unauthenticated remote code execution across nearly every maintained PHP branch. Teams scrambled to update to PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35. Forty-eight hours later, a new and still-unpatched RCE vector showed up: CVE-2026-115560.

Disclosed in an informational advisory on October 23, this flaw doesn’t come from the same memory-corruption family as PHP2Shell. It comes from a state desynchronization between PHP’s multipart request parsing and its session serialization engine. The PHP2Shell patches do nothing to protect against it, so a specific subset of web applications stays exposed until the next regular bug-fix release.

The PHP2Shell bugs involved stale global state holding references to freed request memory, plus out-of-memory paths that led to arbitrary read and write access. CVE-2026-115560 works differently: it depends on a specific combination of server configuration, application behavior, and default feature settings. The PHP team called the vulnerable configuration pattern unlikely in ordinary deployments, which is why they’re fixing it through a standard release rather than another emergency patch. The mechanics still point to a real weakness in how the Zend Engine handles state transitions early in request processing. By exploiting the gap between initial request parsing and the application’s execution environment, an attacker can get PHP to evaluate malicious serialized objects disguised as routine session management.

How PHP session serialization actually works

PHP’s session system separates storage from format. The session.save_handler directive controls where session data physically lives: on the local filesystem, in a Redis cluster, in Memcached, wherever. The session.serialize_handler directive controls something different: the exact grammar used to turn the $_SESSION superglobal into a storable string. PHP ships several of these grammars, most notably the legacy php handler, the binary php_binary handler, and the newer php_serialize handler, and they are not compatible with each other at the byte level.

The legacy php handler uses a simple record format: key|serialized_value, with a literal pipe character separating the key from its value. That format struggles with complex keys, especially ones that contain a pipe character or certain binary data. The php_serialize handler skips this record format entirely and passes the whole session array through PHP’s native serialize() function, which lets it handle numeric indices and arbitrary characters in keys without issue.

When the same handler writes and reads the session, none of this matters. The problem appears when the writer and the reader disagree about which grammar is in use, so the decoder ends up applying the pipe-delimited legacy format to a string that was actually encoded with php_serialize.

The session upload progress feature and the pre-RINIT execution window

CVE-2026-115560 enters through the session upload progress feature, which lets applications track large file uploads by writing metadata into the session. It’s controlled by session.upload_progress.enabled, which is on by default in most PHP distributions, and it hooks into the RFC 1867 multipart callback.

When a client sends a multipart/form-data request with a PHP_SESSION_UPLOAD_PROGRESS field, PHP intercepts the data stream before the Zend Engine has finished setting up the request environment. The internal php_session_rfc1867_callback function fires during MULTIPART_EVENT_FILE_START and triggers session initialization through php_rinit_session(0). This happens in what’s called the pre-RINIT phase, meaning it runs before any application code, framework bootstrapping, or routing logic has loaded.

During this window, the callback checks the active serialization handler via the PS(serializer) pointer, pulled from php.ini or the PHP-FPM pool configuration. Whatever handler is configured, php_binary or php_serialize, the callback uses it to encode the upload metadata, taking the raw value of the PHP_SESSION_UPLOAD_PROGRESS field from the POST body and using it directly as an array key. That value isn’t sanitized or validated as an array key, so an attacker can inject arbitrary strings, including serialized object payloads and delimiter characters. The engine then calls php_session_flush(1), writing the session file to disk while the upload is still in progress, before the application has had any chance to intervene.

How a runtime config change desynchronizes session reads and writes

CVE-2026-115560 PHP session serialization mismatch and potential remote code execution flow
CVE-2026-115560 PHP session serialization mismatch and potential remote code execution flow

The part that turns this into remote code execution happens at the handoff between the pre-RINIT parsing phase and normal application execution. Once the Zend Engine finishes initializing, it runs the application’s userland code. Plenty of legacy applications, monolithic frameworks, and compatibility wrappers adjust PHP configuration at runtime to normalize their environment. If the application calls ini_set('session.serialize_handler', 'php') before session_start(), it re-points PS(serializer) to the legacy handler through OnUpdateSerializer. That creates a mismatch between the format used to write the session file during the upload and the format the application now expects when reading it.

📬 Stay Ahead of Cyber Threats

Get the latest cybersecurity news, critical vulnerabilities, threat intelligence, tutorials, and exclusive giveaways delivered straight to your inbox. No spam. Unsubscribe anytime.

Subscribe to the Newsletter →

When session_start() runs, the engine reads the file from disk and hands its contents to the decoder for the currently active handler, which is now the legacy php handler. The interpreter has no way of knowing the data on disk was written with php_serialize moments earlier. The legacy decoder scans for the first pipe character to find the boundary between the session key and its serialized value. Because the attacker put a pipe character inside their PHP_SESSION_UPLOAD_PROGRESS payload during the upload, the decoder mistakes that character for the record boundary. Everything before it gets treated as the key; everything after it, the malicious serialized object string, goes straight to the unserializer, bypassing the application’s intended session logic entirely.

From grammar collision to unconditional object destruction

This grammar collision forces the Zend Engine to instantiate arbitrary PHP objects from the attacker’s payload, which is a classic object injection primitive. Here’s what the byte-level manipulation looks like. A session file written by the php_serialize handler containing the attacker’s payload:

a:1:{s:56:"upload_progress_|O:9:"LabGadget":1:{s:3:"cmd";s:2:"id";}";a:5:{s:10:"start_time";i:1792774742;…}}

When the legacy php handler processes this string, it finds the pipe character the attacker injected inside the string literal. It treats a:1:{s:56:"upload_progress_ as the session key and hands everything from O:9:"LabGadget"... onward to the unserializer, which successfully instantiates the LabGadget class in memory.

You’d expect the trailing session data (the real upload progress metadata, still in php_serialize format) to cause the decode to fail safely before the object is fully built. It doesn’t work out that way. The internal variable destruction routine, PHP_VAR_UNSERIALIZE_DESTROY(var_hash), runs cleanup on both the success and failure paths of a decode. After the object is instantiated, the unserializer hits the trailing quote and array metadata, which breaks the expected grammar, throws a warning, and fails the overall unserialize() call. But because the object already exists in memory, its __destruct() method still gets called during cleanup, regardless of the decode failure. That unconditional destructor call is what turns a session corruption bug into a working RCE vector, letting code execute silently inside the destructor’s scope.

Why exploiting this bug requires a race condition

Pulling this off in practice takes more than one malicious request. It requires a concurrency race tied to PHP’s session cleanup behavior. By default, session.upload_progress.cleanup purges the upload progress metadata from the session as soon as multipart parsing finishes, at MULTIPART_EVENT_END. That means the payload only sits in the session file for the brief window while the upload is still in flight.

To exploit it, an attacker needs two connections: a producer that starts a large file upload, and a consumer that repeatedly tries to start the session and trigger the decode failure. The producer has to trickle the upload bytes slowly to keep the payload resident for as long as possible, sometimes several minutes.

This works because of how the default files save handler locks the session file. PHP only holds an exclusive flock(LOCK_EX) lock during the brief write in php_session_flush(1), then releases it immediately. The consumer can keep polling the file and triggering session_start() while the producer is still sending data. There’s also a quirk in MULTIPART_EVENT_FORMDATA: it accepts PHPSESSID directly from the POST body without checking session.use_only_cookies. That lets the producer set the exact session ID without a valid cookie, so both connections hit the same session file and the race has a real chance of succeeding.

Strict mode and gadget chains: the remaining constraints

session.use_strict_mode adds another obstacle. With strict mode on, PHP rejects any incoming session ID that doesn’t already exist in storage. Since the producer is creating a new session through the POST body PHPSESSID, strict mode discards that ID and generates a random one instead, which sends the consumer to a different session file and breaks the exploit. In practice, this attack needs session.use_strict_mode=0.

Even with the race won and object injection achieved, you still need a usable gadget chain somewhere in the application’s dependency tree: existing classes and methods that can be chained through serialization to do something harmful, like writing files, running commands, or changing application state. A minimal application with few third-party dependencies might just destroy the injected object harmlessly, limiting the impact to a denial of service or session corruption. Applications built on Monolog, Guzzle, Laravel, Symfony, or similar frameworks with complex lifecycle methods are a different story: those dependency trees tend to have gadget chains that attackers can weaponize for full system compromise.

How to mitigate CVE-2026-115560 (and why runtime fixes don’t work)

The official fix is to disable the session upload progress feature entirely by setting session.upload_progress.enabled = 0 in php.ini or the relevant PHP-FPM pool config, then restarting or reloading all PHP workers. This has to happen at the server level.

Setting ini_set('session.upload_progress.enabled', '0') inside application code does not work. The RFC 1867 callback runs in the pre-RINIT phase, so the upload metadata is processed and written to the session file long before the application gets a chance to run that ini_set() call. Security teams should confirm the active configuration through command-line tools or server-level diagnostics, not application-level debug output, since that can reflect post-RINIT state changes that don’t match what actually happened.

Beyond the immediate fix, this bug is a good argument for cutting down on runtime ini_set() calls that touch serialization handlers, memory limits, or other core interpreter settings. Mutating those values at runtime is exactly what creates state desynchronization bugs like this one. A code audit that flags runtime configuration changes to the session engine, combined with locking the PHP runtime at build time in containerized deployments, closes off this entire class of problem. WAFs won’t help much here either: the payload is just a normal multipart form submission with a manipulated field name, and the actual exploitation happens server-side during session decoding, not anywhere a WAF can inspect.

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Your contribution powers free tutorials, hands-on labs, and security resources.

Why your support matters:
  • Writeup Access: Get complete writeup access within 12 hours
  • Zero paywalls: Keep the main content 100% free for learners worldwide

Perks for one-time supporters:
☕️ $5: Shoutout in Buy Me a Coffee
🛡️ $8: Fast-track Access to Live Webinars
💻 $10: Vote on future tutorial topics + exclusive AMA access

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

If you like this post, then please share it:

Exploits

Discover more from The CyberSec Guru

Subscribe to get the latest posts sent to your email!

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from The CyberSec Guru

Subscribe now to keep reading and get access to the full archive.

Continue reading