June 22, 2026
The PHP Deserialization Bug Class That Keeps Coming Back
One unserialize() call. One attacker cookie. One file written to disk. The PHP bug class that won’t die, with verified working code.

By Ann R.
19 min read
Every few months, a high-profile PHP application gets compromised through the same bug class. Sometimes it's a CMS plugin. Sometimes it's a popular framework. Sometimes it's a custom internal application that nobody thought was interesting until somebody read its cookies. The mechanism is always similar: untrusted data flows into unserialize() or one of its sneakier cousins, magic methods fire on attacker-controlled objects, and a chain of innocent-looking classes ends with arbitrary code execution. The pattern has been documented since at least 2009. PHP added mitigations in PHP 7 (the allowed_classes option) and again in PHP 8 (PHAR metadata handling changes). The vulnerability class keeps producing new CVEs anyway.
This article walks through why. With verified working code that demonstrates the actual exploit mechanics — not theoretical "attackers could potentially…" hand-waving, but a script that successfully writes a file to disk through nothing but a deserialization call. The mitigations are also demonstrated working, along with their limits. The goal is to give developers enough mental model that the bug class becomes visible during code review and during architecture decisions, rather than only after a CVE makes it visible the hard way.
The honest framing: this isn't a "PHP is insecure" article. Java, Python, Ruby, and .NET all have equivalent deserialization vulnerabilities with their own histories. The pattern is universal across languages that allow runtime class instantiation through serialized data. PHP just happens to be the language this article focuses on because the practical mitigations and detection strategies are PHP-specific.
TL;DR Speedrun
unserialize()on attacker-controlled data is RCE-equivalent. Treat it with the same caution aseval(). The mechanism: magic methods (__wakeup,__destruct,__toString) fire on the reconstructed objects, and chains of classes can be combined to reach dangerous side effects.- Verified working POP chain: an attacker payload of
O:10:"FileLogger":2:{...}callingunserialize()creates an arbitrary file on disk with attacker-chosen path and contents. No call tofile_put_contents()exists in the vulnerable application — the class library does it during destruction. - The PHAR vector is sneakier: filesystem functions (
file_exists,fopen, etc.) used to deserialize PHAR metadata automatically. PHP 8 partially mitigated this butPhar::getMetadata()and legacy code paths still expose the attack surface. unserialize($data, ['allowed_classes' => false])blocks object instantiation entirely (verified). This is the primary mitigation when serialized data must be processed at all.allowed_classes => [...]with a list still allows POP chains within that list.- HMAC-signing serialized data prevents tampering (verified working with three test cases) but only if the secret key stays secret. The Laravel APP_KEY exposure pattern (CVE-2018–15133) is a documented case where signature verification failed because the key was leaked.
- JSON is the safe alternative for most use cases. JSON cannot represent PHP objects with magic methods; even attacker-injected
"__class"payloads decode as plain associative arrays, no methods fire.
What You'll Learn
- Why deserialization is dangerous in PHP, with verified magic-method behavior
- A working POP chain demonstration that creates an arbitrary file on disk
- The PHAR deserialization vector and what PHP 8 changed about it
- Mitigations (
allowed_classes, HMAC signing, alternative formats) with their limits - Why this bug class keeps producing new CVEs despite available mitigations
- Code-review patterns to detect deserialization vulnerabilities
The Mechanism: Magic Methods
PHP's unserialize() reconstructs object graphs from a string representation. During and after reconstruction, certain magic methods fire automatically. Verified on PHP 8.3:
class Logger {
public string $logfile;
public string $message;
public function __construct(string $logfile, string $message) {
// NOT called by unserialize
}
public function __wakeup() {
// CALLED IMMEDIATELY after unserialize reconstructs the object
}
public function __destruct() {
// CALLED when the object goes out of scope or script ends
}
public function __toString(): string {
// CALLED if the object is used as a string
return $this->message;
}
}class Logger {
public string $logfile;
public string $message;
public function __construct(string $logfile, string $message) {
// NOT called by unserialize
}
public function __wakeup() {
// CALLED IMMEDIATELY after unserialize reconstructs the object
}
public function __destruct() {
// CALLED when the object goes out of scope or script ends
}
public function __toString(): string {
// CALLED if the object is used as a string
return $this->message;
}
}The crucial fact: __construct() does NOT fire during unserialize(). This means attackers can create instances of classes in any state they want, bypassing whatever validation the constructor normally enforces. Object properties become whatever the attacker put in the serialized payload.
This alone wouldn't be exploitable — having an object with attacker-controlled properties is useless if the object never gets used. But __wakeup() and __destruct() fire automatically, without the application code ever explicitly invoking them. Any code inside those methods runs with attacker-controlled object state.
If __destruct() writes a log file, the attacker controls the log file path and contents. If __wakeup() reconnects to a database, the attacker controls the connection string. If __toString() substitutes template variables, the attacker controls the template. Each of these is an attack surface waiting for a way to actually trigger it.
A Working POP Chain
The exploit class — POP (Property-Oriented Programming) chain — combines multiple classes to achieve a useful effect from unserialize() alone. Here's a working chain that creates an arbitrary file on disk:
// An "innocent" logger class that writes on destruction
class FileLogger {
public string $path = '';
public string $content = '';
public function __destruct() {
if ($this->path !== '') {
file_put_contents($this->path, $this->content);
}
}
}
// Attacker's payload (delivered via cookie, query param, form field, etc.)
$payload = 'O:10:"FileLogger":2:{s:4:"path";s:18:"/tmp/exploited.txt";s:7:"content";s:35:"OWNED at 2026-06-17T06:58:38+00:00\n";}';
// Vulnerable application code
$data = unserialize($payload);
unset($data); // forces __destruct// An "innocent" logger class that writes on destruction
class FileLogger {
public string $path = '';
public string $content = '';
public function __destruct() {
if ($this->path !== '') {
file_put_contents($this->path, $this->content);
}
}
}
// Attacker's payload (delivered via cookie, query param, form field, etc.)
$payload = 'O:10:"FileLogger":2:{s:4:"path";s:18:"/tmp/exploited.txt";s:7:"content";s:35:"OWNED at 2026-06-17T06:58:38+00:00\n";}';
// Vulnerable application code
$data = unserialize($payload);
unset($data); // forces __destructVerified result:
/tmp/exploited.txt was created with content:
---
OWNED at 2026-06-17T06:58:38+00:00
---/tmp/exploited.txt was created with content:
---
OWNED at 2026-06-17T06:58:38+00:00
---The application never called file_put_contents(). The application never called any method on the deserialized object explicitly. The FileLogger's destructor fired automatically when the object went out of scope, with attacker-controlled path and content fields.
This is the simplest possible POP chain — one class, one magic method, one side effect. Real-world POP chains often involve multiple classes from libraries the application depends on. A class in Symfony or in a logging library or in a cache library might have a __destruct that does something useful to the attacker. The attacker doesn't need to find the chain in the application's own code; any class that the application can autoload becomes part of the available chain.
Tools like PHPGGC (PHP Generic Gadget Chains) maintain databases of known POP chains for popular PHP libraries. An attacker with unserialize() access can often find a working chain in seconds by trying PHPGGC payloads against the target's dependency list. This is why "we don't have dangerous code in our app" isn't a defense — the app's dependencies probably do.
The Sneakiest Vector: PHAR
The vulnerabilities documented so far require the application to call unserialize() on attacker-controlled data. That's a visible bug — code review can find it. The PHAR (PHP Archive) deserialization vector is sneakier because the application never calls unserialize() explicitly. The PHP engine does it implicitly when processing PHAR files.
PHAR archives store their metadata as a serialized PHP value in the file header. Historically, any PHP function that opened a file through the phar:// stream wrapper would deserialize that metadata automatically. The list of affected functions was long: file_exists(), file_get_contents(), fopen(), is_file(), stat(), filesize(), md5_file(), and others. Even functions that "just checked if a file existed" would trigger deserialization.
The exploit pattern:
- Attacker uploads a file that's actually a PHAR archive (often disguised with a
.jpgor.pdfextension) - Application later calls a filesystem function on the uploaded file, with attacker-controlled path
- If the path uses
phar://scheme, PHP parses the PHAR header - PHP deserializes the metadata, triggering magic methods on attacker-chosen objects
- POP chain achieves RCE
The disguise matters: the PHAR file can pass image-content-type checks and even have valid image headers. PHP only looks at the file when accessed via phar://, where the metadata is parsed regardless of file extension.
This vector was documented in detail by Sam Thomas at Black Hat USA 2018, demonstrating exploits against several major PHP applications. The research kicked off a wave of CVEs in WordPress plugins, Joomla, TYPO3, Drupal, and others — none of which had ever called unserialize() on user data, but all of which called filesystem functions with paths derived from user input.
PHP 8.0 changed the behavior: PHAR metadata is no longer deserialized when accessed through most filesystem functions. The mitigation is partial, however: Phar::getMetadata() still deserializes (as it must, for legitimate PHAR users), and legacy code that explicitly handles PHARs is still exposed. The complete mitigation for applications that don't use PHARs is to disable the phar:// stream wrapper entirely:
// In php.ini or application bootstrap
stream_wrapper_unregister('phar');// In php.ini or application bootstrap
stream_wrapper_unregister('phar');This is generally safe because most applications don't use PHARs. Frameworks like Laravel and Symfony don't load PHAR archives at runtime. Unregistering the wrapper eliminates the entire attack surface.
Mitigations and Their Limits
The PHP-recommended mitigation for unserialize() on untrusted data is the second argument added in PHP 7.0:
// PHP 7+: prevent any object instantiation
$data = unserialize($input, ['allowed_classes' => false]);// PHP 7+: prevent any object instantiation
$data = unserialize($input, ['allowed_classes' => false]);Verified working: an attacker payload that would have created a FileLogger instance instead creates a __PHP_Incomplete_Class placeholder. The placeholder has no methods, so __wakeup, __destruct, and other magic methods cannot fire. The dangerous code path is closed.
// VERIFIED: with allowed_classes => false
$payload = 'O:10:"FileLogger":2:{s:4:"path";...}';
$result = unserialize($payload, ['allowed_classes' => false]);
// /tmp/exploited.txt is NOT created
// $result is __PHP_Incomplete_Class - no methods, no magic// VERIFIED: with allowed_classes => false
$payload = 'O:10:"FileLogger":2:{s:4:"path";...}';
$result = unserialize($payload, ['allowed_classes' => false]);
// /tmp/exploited.txt is NOT created
// $result is __PHP_Incomplete_Class - no methods, no magicThe trap with allowed_classes: passing a class list (['allowed_classes' => ['SomeClass']]) only restricts which classes get instantiated. If any class in the allowed list has dangerous magic methods or contributes to a known POP chain, the mitigation doesn't help. Allowed-list allowed_classes should be treated as carefully as unserialize() itself — every class in the list is a potential gadget.
The HMAC-signed payload pattern, for cases where serialized data must round-trip through a client:
function safeUnserialize(string $signed, string $secret): mixed {
$parts = explode('.', $signed, 2);
if (count($parts) !== 2) throw new RuntimeException("Invalid format");
[$encoded, $signature] = $parts;
$data = base64_decode($encoded, true);
if ($data === false) throw new RuntimeException("Invalid base64");
$expected = hash_hmac('sha256', $data, $secret);
if (!hash_equals($expected, $signature)) {
throw new RuntimeException("Signature mismatch - tampered payload");
}
// Defense in depth - still use allowed_classes
return unserialize($data, ['allowed_classes' => false]);
}function safeUnserialize(string $signed, string $secret): mixed {
$parts = explode('.', $signed, 2);
if (count($parts) !== 2) throw new RuntimeException("Invalid format");
[$encoded, $signature] = $parts;
$data = base64_decode($encoded, true);
if ($data === false) throw new RuntimeException("Invalid base64");
$expected = hash_hmac('sha256', $data, $secret);
if (!hash_equals($expected, $signature)) {
throw new RuntimeException("Signature mismatch - tampered payload");
}
// Defense in depth - still use allowed_classes
return unserialize($data, ['allowed_classes' => false]);
}Verified working across three test scenarios:
Test 1: Legitimate signed payload → ✓ Decoded successfully
Test 2: Attacker swaps malicious payload, keeps original signature → ✓ Rejected
Test 3: Attacker provides random signature → ✓ RejectedTest 1: Legitimate signed payload → ✓ Decoded successfully
Test 2: Attacker swaps malicious payload, keeps original signature → ✓ Rejected
Test 3: Attacker provides random signature → ✓ RejectedThe HMAC mitigation has one critical assumption: the secret stays secret. This is harder than it sounds. The Laravel APP_KEY exposure pattern (documented as CVE-2018–15133 and similar) is a documented case where applications signed serialized cookies with APP_KEY, and when that key was accidentally committed to a public repository or exposed through a .env file accessible via the web, attackers could forge valid signatures and reach unserialize() on attacker-controlled data through the framework's own deserialization path.
The lesson: HMAC signing protects against modification of payloads in transit, but it doesn't protect against signing-key compromise. Defense in depth (HMAC + allowed_classes) means that even a compromised signing key doesn't directly lead to RCE — the attacker still needs another mechanism to bypass allowed_classes => false.
The Safe Alternative: JSON
For most use cases, JSON is a complete replacement for serialize/unserialize with no security trade-off:
// Serialization
$json = json_encode($data, JSON_THROW_ON_ERROR);
// Deserialization
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);// Serialization
$json = json_encode($data, JSON_THROW_ON_ERROR);
// Deserialization
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);The security property: JSON has no representation for PHP objects with methods. An attacker can inject any JSON they want; the result of json_decode() is always plain values (arrays, strings, numbers, booleans, null). No magic methods can fire because the values don't have magic methods.
Verified: an attacker payload of {"__class": "FileLogger", "path": "/tmp/test.txt", "content": "OWNED"} decodes to an associative array ['__class' => 'FileLogger', 'path' => '/tmp/test.txt', 'content' => 'OWNED']. The array has no methods. No file is created. The attacker's "class hint" is just a string key in an array.
The trade-off is real: JSON can't represent some PHP-native types (resources, closures, internal objects). For most application data — user records, configuration, cache entries, queue jobs — these limitations don't matter. The data is already JSON-shaped (associative arrays, strings, numbers); using JSON for transport is a no-op refactor with security benefit.
The case for serialize over JSON is narrower than common practice suggests: PHP-specific types that JSON can't represent, performance-sensitive scenarios with measured benefit from binary formats, and legacy code where refactoring isn't feasible. Each of these has the security cost; only the last is a forced choice.
Worth noting explicitly: igbinary is not a security alternative to serialize. igbinary is a binary serialization format that's smaller and faster than PHP's native serialize, but igbinary_unserialize() has identical security properties — magic methods fire, POP chains work, the same attack surface exists. Choosing igbinary for performance is reasonable; choosing it for security is a mistake.
Why This Bug Class Keeps Coming Back
If the mitigations are known and the alternatives are available, why does this bug class keep producing new CVEs? Several reasons compound:
The API is too convenient. unserialize() accepts any string and returns the original object graph. No setup, no schema, no decoding logic. This is exactly the convenience an attacker exploits: input becomes object becomes side effect, all in one line.
Sessions historically used native serialize. PHP's default session handler serializes the $_SESSION superglobal using a custom format that's structurally identical to serialize(). If session data crosses a trust boundary (stored in a cookie, shared between services, accessible to other users), the same deserialization risks apply. PHP 7+ added session.serialize_handler = php_serialize and php_binary options but most apps inherited the default.
Caching libraries use serialize. Memcached, Redis, file-based caches, APC — many cache backends store serialized PHP objects for convenience. If the cache becomes accessible to attackers (shared Redis, exposed Memcached, predictable cache keys), the deserialization risk extends to whatever the cache stores.
Queue payloads are sometimes serialized. Laravel's queue system serializes jobs before storing them. Other queue libraries do the same. If queue storage can be written by attackers (compromised Redis, SQL injection into a database-backed queue), the deserialization happens later when a worker processes the job — separated in time and space from the initial compromise.
Framework cookies sometimes use serialize. Older versions of various frameworks stored session data in encrypted-and-serialized cookies. Cookie tampering became a deserialization vector when encryption keys were leaked or weak. The Laravel APP_KEY pattern was the most-publicized example of this category.
PHAR's phar:// wrapper is enabled by default. Until applications explicitly unregister it, every filesystem function is a potential deserialization vector for uploaded files. Many applications never realize this; their security review focuses on explicit unserialize() calls and misses the implicit ones.
The allowed_classes parameter is opt-in. Code that's "just deserialize this" still works without allowed_classes. Developers writing new code with PHP's unserialize() rarely add allowed_classes because the function works without it. Static analysis tools have started flagging this, but adoption is incomplete.
POP chains are highly composable. An application that's "safe" because its own classes have no dangerous magic methods becomes unsafe when a new dependency is added with such methods. Adding a logging library, a debugging tool, a third-party package — any of these can introduce a new gadget. Security audits that examined the application at one point in time become stale.
Detection is hard. A successful POP chain leaves few traces. The attacker's request looks normal (a cookie, a query parameter). The vulnerable function returned without error. The side effect (file written, command executed) is logged somewhere unrelated to the trigger. Connecting "request X arrived at 03:42" to "file Y appeared at 03:42" requires careful logging and active correlation — not something most production environments have configured.
Pitfalls to Avoid
Treating allowed_classes with a list as fully safe. unserialize($data, ['allowed_classes' => ['Foo', 'Bar']]) only blocks classes outside the list. If Foo or Bar has dangerous magic methods or contributes to a POP chain (perhaps in combination with other allowed classes), the mitigation doesn't help. Every class in the list is a potential gadget — treat the list as part of the security surface.
Assuming framework "secure cookies" are safe by default. Encrypted-and-signed cookies are safer than plaintext cookies, but they're not safe if the signing key leaks. Treat the signing key with the same secrecy as a password — never commit it, never log it, rotate it if exposure is suspected, and combine cookie signing with allowed_classes defensively.
Thinking PHAR is only an issue for Phar::getMetadata(). Before PHP 8, dozens of filesystem functions deserialized PHAR metadata. After PHP 8, the explicit Phar::getMetadata() call still does. Legacy code, mixed PHP versions, and any code that explicitly handles PHARs remains exposed. If the application doesn't use PHARs, unregister the phar:// wrapper.
Believing "we sanitize input" prevents deserialization attacks. Input sanitization works for fields that are interpreted as text (HTML escaping, SQL escaping). Serialized data isn't text — it's a structured representation that's parsed by unserialize(). There's no equivalent of HTML-escape for serialize. Filtering for "looks like serialize" is incomplete (the syntax is flexible) and counterproductive (it suggests the design is salvageable when the right answer is to not pass user input to unserialize() at all).
Skipping security review on caching layer changes. A team moves caching from APC to Redis without re-examining what gets stored. The new Redis instance is shared across services, accessible from multiple network segments. Now cache pollution attacks are possible — an attacker who can write to Redis can store malicious serialized payloads that the application later deserializes. The vulnerability didn't exist in APC (process-local) but does in Redis (network-accessible).
Relying on PHP version upgrade as full mitigation. PHP 8 changed PHAR metadata handling, which closed a major implicit attack surface. It did not close explicit unserialize() on attacker input. Applications that "use the latest PHP" are still vulnerable if their code has the pattern. Mitigation requires changing the code, not just updating PHP.
Adding magic methods without considering deserialization. Every class with __wakeup, __destruct, or __toString is a potential gadget. Adding these methods to existing classes without re-reviewing where the application deserializes data can introduce new attack chains. The principle: classes used in serializable data should be reviewed for their behavior during deserialization, not just during normal operation.
Mini Q&A
Is unserialize() always dangerous, or only with user input?
It's always potentially dangerous, but the risk depends on where the input comes from. unserialize() on data that never crosses a trust boundary (purely internal, never persisted in user-controllable storage, never derived from user input) is safe. The risk arises when input comes from anywhere the user can influence — cookies, form fields, query parameters, uploaded files, cache backends shared with other services, database fields that user input has been written to. The rule of thumb: assume unserialize() on any non-constant input is a vulnerability until proven otherwise.
Does PHP 8.x make this less dangerous?
PHP 8.0 changed PHAR metadata handling for many filesystem functions, which closed a large implicit attack surface. PHP 8.x did not change the behavior of explicit unserialize() on attacker input — that's still RCE-equivalent. The mitigation strategy hasn't changed: use allowed_classes => false, prefer JSON, sign data that must round-trip.
What about serialize()/encryption combinations?
Encrypting serialized data before transmission and decrypting before deserialization prevents tampering during transit. It does NOT protect against the original data being malicious (if the attacker controlled what got serialized in the first place). Encryption is also defeated if the encryption key leaks — see the Laravel APP_KEY pattern. The defense-in-depth approach: encrypt, sign, AND use allowed_classes => false.
Is __PHP_Incomplete_Class safe?
Yes, in the sense that magic methods can't fire on it (no methods exist on the placeholder). Code that tries to use the placeholder as if it were the original class will fail with errors. Don't rely on the placeholder for application logic — if unserialize() with allowed_classes => false returns a placeholder when the application expected a real object, that's a bug in the application's design, not in the security model.
What's the difference between PHP object injection and POP chains?
PHP object injection is the broader term — any vulnerability where attacker-controlled input becomes a PHP object via deserialization. POP chains are the specific exploitation technique that combines multiple classes' magic methods to achieve a meaningful effect. Object injection is the vulnerability class; POP chains are the exploitation technique that makes it weaponizable.
Should I scan my codebase for unserialize() calls?
Yes, as a starting point. Every unserialize() call is worth reviewing: where does its input come from, has the trust boundary been crossed, is allowed_classes set, is there an HMAC check before the call. Static analysis tools (PHPStan with appropriate rules, Psalm, dedicated security scanners like RIPS) can find these calls automatically. The harder cases are implicit deserialization (PHAR vector, framework cookie handling, queue payloads) which require understanding the application's data flow rather than searching for a specific function name.
Can WAF rules block this?
Partially. WAFs can block payloads that match common serialize patterns (O:N:"ClassName"), but the syntax is flexible enough to evade signature-based detection. WAFs also can't see the implicit deserialization that happens inside the application (PHAR access, framework cookie handling). WAF rules are a defense-in-depth layer, not a substitute for fixing the underlying code.
Wrap-Up
PHP deserialization vulnerabilities have been documented for over fifteen years. The mitigations have existed since PHP 7.0 (2015). The safe alternative (JSON) has been part of PHP since 5.2 (2006). And yet new CVEs in this class keep appearing — in WordPress plugins, in custom applications, in framework-adjacent code that nobody reviewed for this specific risk.
The reason is structural rather than technical. The dangerous API is convenient, the safe API requires more setup, and the bug class is hard to detect through normal testing. Applications that aren't actively reviewed for deserialization risk tend to have it; applications that are reviewed have it less, but adding new dependencies or making cache layer changes can reintroduce it. The vulnerability surface is dynamic — what was safe yesterday becomes unsafe with the next library update.
The practical posture for a PHP application in 2026: avoid unserialize() on any non-constant input, prefer JSON for data transport, disable phar:// if PHARs aren't used, audit the dependency graph for classes with dangerous magic methods, and treat serialize/unserialize with the same caution as eval(). None of these are heavy-lift changes; together they close the practical attack surface for the bug class.
The deeper takeaway: this is a category of vulnerability where "we'll fix it when we see it" doesn't work. By the time it's visible (someone exploits it in production), data has already left. The vulnerability has to be designed out — at architecture review, at dependency selection, at code review — not patched in when noticed. Frameworks that treat deserialization defensively (Laravel's encrypted-cookie pattern, Symfony's signed-payload patterns) provide some of this protection by default; applications that bypass these patterns for "convenience" pay the cost when the underlying assumptions break.
Closing Loop
A security researcher publishes a write-up of a CVE in a popular PHP-based content management system. The bug: a session cookie was passed through unserialize() without allowed_classes. The exploitation: a POP chain through three classes in a logging library that the CMS depends on. The impact: remote code execution on any installation of the CMS, given a known cookie format and the ability to make HTTP requests.
The CMS team patches the bug within 24 hours by adding allowed_classes => false to the deserialization call. They publish a security advisory. They emphasize that users should update immediately. The patch propagates through the ecosystem; package managers update; most installations are protected within a few days.
Six months later, the same CMS has another CVE. Different code path, same root cause. A different cookie, a different unserialize() call, missed in the previous audit. The team patches it. The cycle continues.
This is the pattern of the deserialization bug class. Not because the developers are careless — they're not. Not because the mitigations don't work — they do. The pattern persists because the dangerous API is convenient, the mitigations are opt-in, and human review can't reliably find every instance. Each new feature, each new dependency, each refactor is an opportunity for the bug class to slip back in.
The solution at the codebase level is process, not vigilance. Pre-commit hooks that flag unserialize() without allowed_classes. Static analysis rules in CI that block the pattern. Documentation that explains why JSON is the default and serialize is the exception. Dependency review that examines new packages for dangerous magic methods. These reduce the per-instance probability of the bug to near zero, where vigilance alone keeps it at "we'll find it eventually."
For developers who don't work on framework code: the practical actions are limited but high-leverage. Don't call unserialize() on input that crossed a trust boundary. If a library calls it for you, check whether it uses allowed_classes. Prefer JSON for all new data transport. Sign data that must round-trip with HMAC, and combine signing with allowed_classes. Disable PHAR if you don't use it. These steps, applied consistently, close the bug class for an application's own code.
The bug class will keep coming back at the ecosystem level. The goal isn't to eliminate it everywhere; it's to keep it out of the specific code the team controls. That's achievable. The rest is what dependency audits and security updates are for.
"People Also Ask"
- Why is
unserialize()dangerous in PHP?unserialize()reconstructs PHP objects from a string representation. During reconstruction, magic methods (__wakeup,__destruct,__toString) fire automatically on the new objects, with attacker-controlled property values. If any class accessible to the application has dangerous behavior in these methods, attacker input becomes attacker code execution. The vulnerability is universal across PHP applications that pass non-constant input tounserialize().
2. What's a POP chain in PHP deserialization? POP (Property-Oriented Programming) chain is a sequence of class instantiations that, when deserialized together, produce a useful effect for the attacker. Each class in the chain provides a small step — __wakeup casts a value to string, __toString resolves a template, __destruct writes a file. The combination achieves something the application never intended. Tools like PHPGGC maintain databases of known POP chains for popular PHP libraries.
- Does
allowed_classes => falsefully fix the vulnerability? For explicitunserialize()calls, yes — verified that no object instantiation occurs, no magic methods fire, no POP chain can execute. The mitigation is straightforward and effective. The catch: it must be applied at everyunserialize()call (developers often forget), and it doesn't protect against implicit deserialization (PHAR metadata processing, framework cookie handling). Defense in depth requires both explicit mitigation and addressing implicit attack surfaces.
4. What is the PHAR deserialization vulnerability? PHAR archives store metadata as serialized PHP values. Historically, dozens of PHP filesystem functions (file_exists, file_get_contents, fopen, etc.) would deserialize PHAR metadata automatically when called with paths using the phar:// scheme. Attackers could upload disguised PHAR files (often with image extensions) and trigger deserialization through innocent-looking application code. PHP 8.0 closed many of these implicit deserialization paths, but Phar::getMetadata() and some legacy paths still expose the attack surface. Disabling the phar:// wrapper is the complete mitigation for apps that don't use PHARs.
5. Is JSON safe from deserialization attacks? Yes. JSON has no representation for PHP objects with methods. json_decode() always returns plain values (arrays, strings, numbers, booleans, null). Even attacker-injected __class or __type fields decode as string keys in arrays, not as class instantiations. No magic methods can fire. This makes JSON the preferred format for data transport when the alternative is PHP's native serialize.
6. What is the Laravel APP_KEY exposure pattern? Laravel historically used encrypted-and-signed cookies for session storage, with APP_KEY as both the encryption and signing secret. If APP_KEY was committed to a public repository or exposed through a misconfigured web server (.env accessible via web), an attacker who obtained the key could forge valid encrypted cookies. The framework's signature verification would pass; the cookie would be decrypted and unserialize()d; a POP chain through Laravel's dependencies could achieve RCE. The pattern is documented as CVE-2018-15133 and similar issues. The lesson: signing keys must remain secret; signature checking is not a complete defense.
7. Does PHP 8 make deserialization safe? PHP 8 changed PHAR metadata handling, closing a significant implicit attack surface. It did NOT change the behavior of explicit unserialize() on attacker-controlled input — that's still RCE-equivalent. Applications upgraded to PHP 8 still need the same mitigations: allowed_classes => false, preferring JSON, HMAC-signing payloads that round-trip through clients. The PHP version is one piece of the defense; the application code is the larger piece.
8. How can I detect deserialization vulnerabilities in my code? Static analysis tools (PHPStan with security rules, Psalm, dedicated security scanners) can find explicit unserialize() calls and flag those without allowed_classes. For implicit deserialization (PHAR vector, framework cookie handling), data flow analysis is required — tracing where user input might reach filesystem functions or cookie deserialization. The hardest cases require understanding the dependency graph: new libraries with magic methods can introduce new POP chains without changing your own code. Periodic security review of dependencies, combined with locking down direct unserialize() usage, gives the most coverage.
Note: All PHP code examples and exploit demonstrations in this article were verified on PHP 8.3.6. The POP chain demonstration created an actual file on disk through unserialize() with no explicit file_put_contents() call in the application code. The mitigation strategies (allowed_classes => false, HMAC signing) were tested against the working exploit and verified to block it. CVE references reflect publicly-documented vulnerabilities; specific framework version status should be verified against current advisories. The PHAR deserialization research was originally published by Sam Thomas at Black Hat USA 2018 — interested readers should consult the original research for technical depth beyond this article's overview. Defensive recommendations reflect current PHP best practices as of late 2026 and should be combined with ongoing security review and dependency auditing.