TL;DR
| Type | RCE via session serializer mismatch → unserialize() |
| Needs | Specific config + specific app code + a default-on feature |
| Patched? | Not yet. Fix planned for the next regular PHP release |
| Mitigation | session.upload_progress.enabled = 0 |
| Exploited in the wild? | Not reported as of the advisory |
| Severity | Some sources list vendor CVSS v4 9.2 (Critical), but PHP had not published an official score or affected-version list yet |
Background in 3 minutes
1. Session serialize handlers
PHP has to turn $_SESSION into a string to store it. The converter is the serialize handler, chosen with session.serialize_handler: php (default), php_binary, or php_serialize. The rule that matters here: the handler used to write must match the handler used to read.
2. Session upload progress
If an upload form includes a field named PHP_SESSION_UPLOAD_PROGRESS, PHP writes upload progress (file name, bytes received, etc.) into $_SESSION while the request body is still being parsed. It's enabled by default, and the stored data includes values chosen by the client (the field value and the file name).
3. Why unserialize() is dangerous
unserialize() instantiates objects named in the string and may trigger magic methods like __wakeup / __destruct. If an attacker controls the string and your app or its dependencies contain a usable "gadget" class, that can end in code execution.
The three conditions
PHP says all three must be true:
![Three conditions that must all be true: serialize_handler set to php_serialize or php_binary, application calls ini_set to php before session_start, and session.upload_progress.enabled is on]
- Config:
session.serialize_handlerisphp_binaryorphp_serialize(the default isphp, so a stock install fails this check). - Application code: the app switches it back to
phpat runtime withini_set()before callingsession_start(). - Feature: session upload progress is enabled (the default).
That combination is rare, which is why PHP is handling it in the regular release cycle rather than as an emergency.
Source-level walkthrough (simplified)
Same data, different formats
Say we store username = alice:
// "php" handler: name|serialized_value username|s:5:"alice"; // "php_serialize" handler: the whole $_SESSION as one serialized array a:1:{s:8:"username";s:5:"alice";} The crucial detail: the php handler uses the pipe character | as the delimiter between the variable name and its serialized value. When reading, it finds the first |, treats everything before it as the name, and passes everything after it to unserialize().
![A string stored in php_serialize format containing a pipe character inside an attacker-controlled value, then read back by the php handler, which splits at the pipe and unserializes the remainder]
Demo above is a made-up class for illustration. What actually happens next depends on which gadget classes exist in the target application.
The writer: progress is recorded before your script runs
Upload progress is handled while PHP parses the multipart body, i.e. before any user script executes. Conceptually:
/* Simplified pseudocode, NOT real php-src */ /* Runs during request parsing, before user code */ on field "PHP_SESSION_UPLOAD_PROGRESS": key = "upload_progress_" + (value sent by the client) /* input 1 */ open session, decode existing data on file start: progress.files[i].name = (uploaded file name) /* input 2 */ on progress update: $_SESSION[key] = progress array PS(serializer)->encode() -> write to session storage Two takeaways:
- The serializer used here is the one from php.ini (e.g.
php_serialize), because the app'sini_set()hasn't run yet. - Inputs 1 and 2 are attacker-controlled and can contain
|.
So tainted data lands in the session store, encoded in php_serialize format.
The reader: your app flips the handler
The condition-2 pattern looks like this:
<?php // php.ini: session.serialize_handler = php_serialize // e.g. for legacy compatibility, switch back right before starting the session ini_set('session.serialize_handler', 'php'); // <-- the risky bit session_start(); // decodes stored data as "php" format Now the stored string (written as php_serialize) is decoded as php. That's exactly the mismatch in the figure above:
/* Simplified pseudocode: the "php" handler's decode loop */ while (input remains): pos = index of first '|' name = input[start .. pos) value = unserialize(input[pos+1 ..]) /* <-- objects get created here */ $_SESSION[name] = value Why "stale global state" and "different requests" matter
Vendor write-ups (for example SIOS Security Blog) describe this as a stale global state issue where the "producer" and "consumer" steps happen in different HTTP requests handled by the same worker. In long-lived workers such as PHP-FPM, engine globals like the current serializer pointer (PS(serializer)) can carry over between requests if they aren't reset. A community backport patch resets that pointer between requests, which fits this picture. Exactly which global leaks and how is something only the official advisory can confirm.
Full attack flow
![Sequence diagram: Request A uploads a multipart POST that gets stored with php_serialize; Request B switches to php handler and calls session_start, which misreads the pipe as a delimiter, unserializes attacker data, and may reach RCE if a gadget exists]
Request A plants the tainted string. Request B is an ordinary page, which is why it's hard to spot from logs alone. (I've left out session-ID handling and other exploitation specifics on purpose.)
Why unserialize ≠ automatic RCE
Creating an object isn't the same as owning the server. Code execution needs a gadget: a class in your app or dependencies whose magic methods do something dangerous (file operations, command execution, and so on). So the real-world impact depends heavily on your dependency tree. Large Composer-based apps tend to have more gadget candidates.
What the community patch does (and doesn't)
For EOL PHP versions (5.4–8.1), a community project (php2shell-patches) ships an "enhanced" backport that describes a three-layer defense. This is not an official PHP patch and is not validated by the PHP Group. It's still a nice illustration of defense in depth:
| Layer | What it does | What it blocks |
|---|---|---|
| Delimiter sanitization | Strips NUL, `\ | `, CR, LF from names recorded by upload progress |
| Serializer reset | Sets PS(serializer) back to NULL between requests | Stale serializer state across requests |
| Strict decode gate | During progress decode, only __PHP_Incomplete_Class is used (no autoload, no __wakeup) | Limits damage even if unserialize runs |
The same project's "minimal" flavor simply disables upload progress, which matches PHP's official mitigation.
What you should do
1. Turn the feature off
; php.ini, PHP-FPM pool config, or web server config session.upload_progress.enabled = 0 - Restart all PHP workers afterwards (e.g.
systemctl restart php8.3-fpm). - If you already did this for PHP2Shell, you're covered. Just keep it that way.
- Don't rely on
ini_set()or.user.inifor this. PHP's guidance points to php.ini, the FPM pool, or server config. - Side effect: apps that use this feature for upload progress bars will lose them.
2. Check whether you're even exposed
# Condition 2: does any code change the handler at runtime? grep -rn "session.serialize_handler" /var/www --include=*.php # Conditions 1 and 3: what's the effective config? php -i | grep -E "session.serialize_handler|session.upload_progress.enabled" If nothing in your code touches session.serialize_handler and the config value is php, this particular path doesn't apply to you. Note that php -i shows CLI settings; check your FPM/Apache config separately.
3. Longer term
- Apply the next regular PHP release as soon as it ships. It should include the full advisory with affected and fixed versions.
- Remove runtime
ini_set('session.serialize_handler', ...)calls and standardize on one handler. - Turn off features you don't use. PHP itself suggested keeping upload progress off even after patching if you don't need it, since rarely used, lightly tested features attract attackers.
- Plan upgrades off EOL branches, which won't get an official fix.






