CVE-2026-115560: PHP Session Upload Progress RCE, Your Config Betrays You
PHP ships session.upload_progress.enabled turned on by default. Every version, every installation. If your server’s php.ini uses php_serialize for sessions and any endpoint switches to the php handler at runtime before session_start(), an unauthenticated attacker can inject arbitrary PHP objects into your session file. No credentials. No cookies. One multipart POST with a pipe character in the right form field.
I was reading through the PHP2Shell patches (CVE-2026-109618/109619, dropped October 21) when I noticed the fix only covered the filename field in multipart uploads. The PHP_SESSION_UPLOAD_PROGRESS value goes through the exact same rfc1867 callback, lands in the same session file, and nobody sanitizes it either. I started writing a PoC that night.
Two days later, php.net quietly put out a mitigation advisory. No patch. The entire recommendation is “set session.upload_progress.enabled = 0 in php.ini.” For a pre-auth RCE. In 2026.
CVE-2026-115560. CVSS 9.8 Critical. Unpatched. PoC, check script, detection rules: github.com/al4n4n/CVE-2026-115560.
Background: PHP session serialization handlers
PHP has three session serialization handlers, and the mismatch between them has been a known class of bug since at least 2015. What makes this CVE different from the textbook version is the attack surface: you don’t need authentication, you don’t need the application to accept user input into sessions, and you don’t even need cookies.
The three handlers store the same session data in incompatible formats:
php (legacy): key|serialized_value. The pipe | separates the key name from the serialized value. This is the oldest format and still the default on many installations.
php_serialize: Standard serialize() for the entire session array. No pipe delimiter. The whole thing is one serialized string. Modern PHP docs recommend this one.
php_binary: Length-prefixed binary encoding. Rarely seen in the wild, but also mismatches with the others.
The fundamental problem: if one handler writes and a different handler reads, the data gets reinterpreted. When php_serialize writes a session containing upload_progress_PAYLOAD|O:9:... as a key name, the string |O:9:... is just part of that key. But when the php handler reads the same file, it scans for | as a delimiter, finds the one inside our key, and passes everything to the right of it straight into unserialize().
The pipe character is data to one handler and a delimiter to another. That’s the entire bug.
The attack surface: session.upload_progress
Handler mismatches have been known for years, but exploiting them usually required the application to write attacker-controlled data into $_SESSION. That limits the attack surface to authenticated users or specific input flows.
session.upload_progress removes that limitation entirely.
When enabled (it is, by default, on every PHP installation since 5.4), PHP’s rfc1867 multipart form handler automatically writes upload progress information into the session. The session key is upload_progress_<value> where <value> comes directly from a POST field named PHP_SESSION_UPLOAD_PROGRESS. The attacker controls that value. No sanitization, no length limit, no character filtering.
Here’s what PHP’s rfc1867 callback does during a multipart upload, before your application code even runs:
- Reads
PHPSESSIDfrom the POST body (from the body, not just cookies, regardless ofsession.use_only_cookies) - Creates or opens the session file with that ID
- Writes
upload_progress_<PHP_SESSION_UPLOAD_PROGRESS value>to the session file - Uses the server-configured handler for serialization (because application code hasn’t had a chance to call
ini_set()yet)
Step 4 is where it falls apart. The rfc1867 callback runs pre-RINIT, before any PHP userland code executes. Your ini_set('session.serialize_handler', 'php') in index.php changes nothing about what the upload handler writes. It writes with whatever php.ini says. But when your code later calls session_start(), it reads with the handler you just switched to.
Two different handlers. Same file. The attacker controls what’s in between.
The vulnerable pattern
Three conditions have to line up. Here’s what they look like in practice:
1 |
|
And the server’s php.ini:
1 | session.serialize_handler = php_serialize ; <-- writes session with php_serialize |
Important caveat: this PoC uses a synthetic LabGadget class that I planted in the lab specifically to demonstrate RCE. No real application ships a class that blindly calls shell_exec() from a property. In a real attack, you’d need to find a usable gadget chain in whatever framework the target runs. The PHPGGC project maintains chains for most major PHP frameworks, but finding one that’s autoloaded at the right moment is application-specific work.
What is real: the object injection primitive. If the three preconditions are met, arbitrary objects go into unserialize(). The gadget chain determines what happens next.
Where does the handler-switching pattern appear in the wild? Legacy codebases that migrated from php to php_serialize at the config level but kept ini_set() in their bootstrap code. Framework configurations where the server and application disagree on the handler. Multi-tenant setups where the vhost config uses one handler and the application uses another. It’s not universal, but it’s not rare either.
Walking the exploit
The exploit is a race between two threads hitting the same session file.
Thread 1 (producer): sends a slow multipart POST to the handler-switching endpoint. The POST body carries three fields:
1 | ------FormBoundary |
Why the trickle? PHP deletes upload progress data from the session when the upload finishes (session.upload_progress.cleanup = 1). By sending the file body one 16KB chunk at a time with 150ms between them, we hold the session file open for ~2.5 seconds. That’s our race window.
Why PHPSESSID in the POST body? The rfc1867 handler reads it from the multipart fields before session.use_only_cookies takes effect. The attacker picks the session ID, no cookies needed.
The rfc1867 callback writes this to /tmp/sess_attacker_controlled_session_id using php_serialize:
1 | a:1:{s:65:"upload_progress_|O:9:"LabGadget":1:{s:3:"cmd";s:2:"id";}";a:5:{...}} |
To php_serialize, the | is just part of the string. Nothing special.
Thread 2 (consumer): fires rapid GET requests to the same endpoint with Cookie: PHPSESSID=attacker_controlled_session_id. Each request runs:
1 | ini_set('session.serialize_handler', 'php'); // switch to legacy handler |
Now the php handler reads the file. It scans left to right for |, finds the one we planted, and splits there. Everything to the right goes into unserialize():
1 | O:9:"LabGadget":1:{s:3:"cmd";s:2:"id";} ← unserialize() parses this |
PHP creates a LabGadget instance with $cmd = "id". The deserialization itself fails partway through (the rest of the session data doesn’t parse cleanly under the php handler), but that doesn’t matter. PHP_VAR_UNSERIALIZE_DESTROY runs destructors on every object created during the attempt, even on failure. __destruct() fires, shell_exec("id") runs, and the output lands in a web-accessible drop file.
The consumer thread fires maybe 40-60 requests during the 2.5-second window. Only one needs to hit while the session file exists on disk.
The proof
Against the lab target (PHP 8.4.27-apache with the synthetic LabGadget class and the handler-switching index.php):
1 | $ python3 poc.py http://127.0.0.1:8094 'id' |
That uid=33(www-data) is the output of id running as the Apache worker process inside the container. No credentials sent. No cookies (the session ID came from the POST body). One multipart form, one pipe character.
Reading arbitrary files:
1 | $ python3 poc.py http://127.0.0.1:8094 'cat /etc/passwd' --rounds 1 |
To be clear about what this proves: the object injection primitive (getting attacker-controlled data into unserialize() without authentication) is real and not specific to the lab. The RCE part depends on the LabGadget class that I put there. A real target would need a real gadget chain.
Negative control
The mitigated container has session.upload_progress.enabled = 0:
1 | $ python3 check.py http://127.0.0.1:8097 |
The handler mismatch still exists, but the rfc1867 callback never fires, so there’s no way to inject data into the session pre-RINIT. The exploit fails on every round.
The noswitch.php endpoint (same gadget class, no ini_set() switch) is also immune. When the same handler reads and writes, the pipe character stays inside the serialized string where it belongs.
Impact
Let me be honest about the blast radius. This is not “every PHP server is vulnerable.” Three things have to line up:
- Server-side handler set to
php_serializeorphp_binaryin php.ini. Many modern setups do this (the PHP docs recommendphp_serialize), but it’s a configuration choice, not the default. - Application code switches the handler to
phpat runtime viaini_set()beforesession_start(). This is the narrowest requirement. It shows up in legacy codebases and certain framework configurations, but most applications don’t do this. session.upload_progress.enabled = 1. This is the default. Almost nobody turns it off.
If all three are present, the attacker gets arbitrary object injection without authentication. The actual impact then depends on a fourth factor: whether there’s a usable gadget chain (a class with an exploitable __destruct() or __wakeup()) autoloaded at the time of deserialization.
In the lab, that’s LabGadget, a class I wrote specifically for this PoC with shell_exec() in __destruct(). It doesn’t exist in the real world. For real targets, the PHPGGC project catalogs gadget chains for Laravel, Symfony, WordPress, Drupal, Magento, and dozens of other frameworks. Finding one that works is application-specific, but it’s a solved problem for many popular stacks.
| Claim | Status | Evidence |
|---|---|---|
| Serialize handler mismatch | Proven | Server: php_serialize, runtime: php |
| Pre-auth object injection | Proven | Zero credentials, session ID from POST body |
| RCE via synthetic gadget | Proven | id output extracted from lab drop file |
| Race condition exploitable | Proven | Producer/consumer threads, ~47 hits in 2.5s window |
| Mitigation effective | Confirmed | upload_progress=0 prevents exploitation |
| RCE on real-world apps | Not proven | Lab gadget is synthetic; real apps need real chains |
| Handler switch pattern in the wild | Not proven | Known to exist but prevalence not measured |
Detection
Network-level (Suricata): look for multipart POST requests where PHP_SESSION_UPLOAD_PROGRESS contains |O: (pipe followed by serialized object notation). The detection rule also flags PHPSESSID delivered via POST body alongside upload progress fields, which is unusual in legitimate traffic.
Host-level (Sigma): PHP error logs containing Failed to decode session object after session_start() indicate the handler mismatch fired. This happens on every exploit attempt, successful or not.
Both rule sets are in the detection/ directory.
The fix (or lack of one)
There is no patch. As of this writing, the only mitigation is:
1 | session.upload_progress.enabled = 0 |
This goes in php.ini or a conf.d file. You cannot set it via ini_set() in your application code, because the rfc1867 callback registers before your code runs. By the time PHP reaches your ini_set(), the upload handler has already processed the multipart body and written to the session file. The same pre-RINIT timing that makes this exploitable also makes the obvious application-level fix impossible.
PHP2Shell (CVE-2026-109618 and CVE-2026-109619, patched October 21, 2026) exploited the filename field in multipart uploads. CVE-2026-115560 uses the PHP_SESSION_UPLOAD_PROGRESS value field instead, which goes through the same rfc1867 callback but a different code path. The PHP2Shell patches don’t touch it.
A real fix would need to do one of two things: sanitize the upload progress value before writing it to the session (strip or escape characters that could be misinterpreted by other handlers), or tag session files with the handler that wrote them and refuse to read with a different one. Neither approach exists in any released or proposed patch.
Timeline
| Date | Event |
|---|---|
| 2026-10-21 | CVE-2026-109618/109619 (PHP2Shell) patched in PHP 8.4.27 |
| 2026-10-23 | CVE-2026-115560 disclosed, php.net publishes mitigation advisory |
| 2026-10-23 | BleepingComputer reports on the advisory |
| 2026-10-24 | This analysis and PoC published |
Reproduction
1 | git clone https://github.com/al4n4n/CVE-2026-115560.git |
Readers can verify every claim in this post by running the commands above. The lab uses Docker, the exploit uses Python stdlib only, and the entire thing takes under a minute.