Understanding PHP object injection and securing unserialize() correctly
Applying unserialize() to untrusted data is one of the most dangerous patterns in PHP applications. Gadget chains made of seemingly harmless classes enable remote code execution merely by reconstructing an object. JSON, allowed_classes and signed payloads reliably close this gap.
Table of Contents
- 1. What PHP object injection is and why it is dangerous
- 2. How a deserialization attack technically unfolds
- 3. Secure alternative: JSON instead of serialize/unserialize
- 4. When unserialize() is unavoidable: using allowed_classes
- 5. Gadget chains: why harmless classes become dangerous
- 6. Securing deserialization in sessions and caches
- 7. Third party libraries as gadget sources
- 8. Signed payloads with HMAC in practice
- 9. Serialization formats compared by risk
- 10. Summary
- 11. FAQ
1. What PHP object injection is and why it is dangerous
Deserialization vulnerabilities in PHP arise when an application applies unserialize() to data that an attacker can control in whole or in part. PHP objects can trigger so called magic methods during deserialization, in particular __wakeup() and __destruct(), which are called automatically as soon as the object is created or cleaned up. If a class present in the project executes dangerous code in one of these methods, for example deleting a file, building a database query or feeding external input into eval(), an attacker can trigger that code merely by crafting a suitably prepared serialized string, without injecting any of their own program code.
This class of deserialization vulnerabilities is often called PHP object injection and is considerably more subtle than classic injection attacks like SQL injection, because the actual malicious code already exists in legitimate project or library code and is only invoked in an unusual order. That is exactly what makes these vulnerabilities so dangerous: a developer can write a class perfectly correctly and harmlessly for its intended purpose, and it can still become a building block for remote code execution through a so called gadget chain. This article shows how to systematically recognize and prevent deserialization vulnerabilities.
2. How a deserialization attack technically unfolds
A typical sequence for deserialization vulnerabilities begins with an application accepting a serialized string from an untrusted source, for example a cookie, a form field or a cache entry that can be influenced from outside. The attacker first analyzes the available code, usually through publicly accessible libraries in the vendor directory, and looks for classes with interesting magic methods. If they find a chain of classes whose __wakeup(), __destruct() or __toString() methods call each other and eventually perform a dangerous operation, they construct an object graph that triggers exactly this chain.
The actual attack then consists of sending this constructed object graph as a serialized string to the vulnerable endpoint of the application. If the application calls unserialize() on this string, PHP automatically rebuilds the entire object chain and, in doing so, calls the corresponding magic methods, without the application code itself ever validating or processing malicious input. This automatic, uncontrolled process is exactly what makes deserialization vulnerabilities so dangerous: the attacker does not need a code execution flaw in the strict sense, but abuses program logic that already legitimately exists.
3. Secure alternative: JSON instead of serialize/unserialize
The most effective defense against deserialization vulnerabilities is to avoid PHP's native serialization format entirely for all untrusted data. json_encode() and json_decode() have no concept of object instantiation with magic methods, they only produce scalars, arrays and, with the JSON_OBJECT_AS_ARRAY flag, associative arrays. Since JSON transports no class names and no object structure in the PHP sense, an attacker cannot in principle trigger a gadget chain through JSON payloads, even with full control over the content.
For most use cases where developers still use serialize()/unserialize() today, for example caching configuration data or transferring state objects between requests, JSON is entirely sufficient. Where complex object structures with type information are genuinely needed, for example domain objects with value objects, the problem can be solved with explicit toArray()/fromArray() methods that define in a controlled way which fields are restored in which order, instead of leaving reconstruction to PHP's internal reflection mechanism.
<?php
declare(strict_types=1);
// WRONG: unserialize() on untrusted input enables PHP Object Injection
$cartData = unserialize($_COOKIE['cart_state'] ?? '');
// RIGHT: JSON has no concept of object instantiation or magic methods
$cartData = json_decode($_COOKIE['cart_state'] ?? '{}', associative: true, flags: JSON_THROW_ON_ERROR);
/**
* Explicit, controlled hydration instead of relying on PHP's
* internal object reconstruction via unserialize().
*/
final class CartState
{
public function __construct(
public readonly array $items,
public readonly string $currency,
) {
}
public static function fromArray(array $data): self
{
return new self(
items: $data['items'] ?? [],
currency: $data['currency'] ?? 'EUR',
);
}
public function toArray(): array
{
return ['items' => $this->items, 'currency' => $this->currency];
}
}
$cart = CartState::fromArray($cartData);
4. When unserialize() is unavoidable: using allowed_classes
In grown applications, unserialize() cannot always be immediately and completely removed, for example when legacy data formats have been stored in a database for years. Since version 7, PHP offers the second parameter allowed_classes, with which deserialization vulnerabilities can at least be significantly contained without immediately having to migrate the format. Passing allowed_classes: false replaces all objects during deserialization with __PHP_Incomplete_Class, their magic methods are not called. Passing an array with explicit class names allows only exactly those classes to be instantiated.
It is important to keep this allowlist as narrow as possible and to regularly check whether the allowed classes themselves have dangerous magic methods. An allowlist with a single harmless value object class is significantly safer than a list containing an entire namespace, because the latter can once again open the door to gadget chains as soon as one of the allowed classes unnoticeably gains a dangerous method, for example through a dependency update. allowed_classes is a transitional protection, not a permanent solution, the ideal target state remains full migration to JSON or explicit hydration.
<?php
declare(strict_types=1);
// WRONG: no allowed_classes parameter, any class can be instantiated
$data = unserialize($legacyPayload);
// SAFER: block all object instantiation entirely
$data = unserialize($legacyPayload, ['allowed_classes' => false]);
// SAFER (if objects are needed): explicit allowlist of known-safe classes
$data = unserialize($legacyPayload, [
'allowed_classes' => [PriceValueObject::class, CurrencyCode::class],
]);
if ($data === false) {
throw new RuntimeException('Deserialization failed or contained disallowed classes');
}
5. Gadget chains: why harmless classes become dangerous
A gadget chain is a sequence of method calls across several classes that each look completely harmless in isolation but, in combination, produce a dangerous operation. Such chains have become well known in popular PHP frameworks and libraries, where, for instance, a logging class writes a file when an object is destroyed (__destruct()), with the path and content partly derived from the deserialized object. Combining this class with another one that calls a method as a string creates a chain that ultimately leads to remote code execution, even though no individual class looks suspicious on its own.
The decisive point about deserialization vulnerabilities caused by gadget chains: it is enough that any library pulled in via Composer anywhere in the entire project contains an exploitable class, even if that class is never directly used in the application's own code. Tools like PHPGGC (PHP Generic Gadget Chains) collect known gadget chains for common frameworks and libraries and automate the creation of attack payloads, which shows how systematically this class of attack is exploited nowadays, as soon as an unserialize() call on untrusted data exists anywhere in the project.
6. Securing deserialization in sessions and caches
PHP sessions use, by default, their own format based on serialize() to store session variables, configured via the session.serialize_handler directive. As long as session data is exclusively written and read server side, this generally does not create deserialization vulnerabilities, because the attacker cannot directly influence the raw content of the session file. It becomes critical as soon as an application passes objects from the session uncontrolled to library functions that internally deserialize again, or when session data ends up in a shared cache system such as Redis or Memcached that multiple services with different trust levels access.
With cache systems like Redis, which are often used to cache PHP objects via serialize(), it is worth carefully checking who is allowed to write to the cache. An attacker who can influence even a single cache key, for example through weakly validated HTTP header based cache variation, may thereby trigger a complete deserialization vulnerability once the application later reads the cache content again with unserialize(). Here too, JSON for cache content that could potentially be influenced from outside structurally minimizes this risk.
<?php
declare(strict_types=1);
/**
* Cache wrapper that stores data as JSON instead of PHP's native
* serialize() format, eliminating object injection risk entirely.
*/
final class SafeJsonCache
{
public function __construct(private readonly \Redis $redis)
{
}
public function set(string $key, array $value, int $ttlSeconds): void
{
$this->redis->setex($key, $ttlSeconds, json_encode($value, JSON_THROW_ON_ERROR));
}
public function get(string $key): ?array
{
$raw = $this->redis->get($key);
if ($raw === false) {
return null;
}
return json_decode($raw, associative: true, flags: JSON_THROW_ON_ERROR);
}
}
7. Third party libraries as gadget sources
An often overlooked aspect of deserialization vulnerabilities is that the dangerous class does not have to live in the application's own code. Composer dependencies often bring along dozens of transitive dependencies, many of which are never directly invoked but are nonetheless registered in the autoloader. As soon as one of these classes has an exploitable magic method, an attacker can use it as a building block of a gadget chain, even if the application developer never consciously imported the class.
Regular dependency audits with tools like composer audit help identify known CVEs in the packages used, but do not automatically cover every possible gadget chain, since not every exploitable combination is documented as its own CVE. The most reliable protection therefore remains structural: avoid unserialize() on untrusted data as much as possible, instead of relying on a complete list of known gadget chains, which by nature can never be complete. Anyone who still has to use unserialize() should consistently restrict allowed_classes to the minimally necessary class list.
8. Signed payloads with HMAC in practice
An additional defense layer against deserialization vulnerabilities, especially for data transported via cookies or URL parameters, is cryptographic signing of the payload. Instead of plain serialized or JSON encoded data, an HMAC (Hash-based Message Authentication Code) is computed over the content and sent along with it. Upon receipt, the application first checks the signature with hash_equals(), before any deserialization takes place at all. If the signature does not match, the payload is discarded entirely, without ever being decoded.
This technique does not directly prevent deserialization vulnerabilities in a theoretical sense, because an attacker who knows the secret key could still sign a malicious payload. In practice, however, it is extremely effective, because an external attacker who does not know the server side key cannot produce a validly signed payload at all, and therefore never puts the application in a position to deserialize their manipulated object graph. Combined with JSON instead of serialize() as the underlying format, this creates a double layer of protection: even if the signature check were implemented incorrectly, the format itself would remain free of object injection risks.
<?php
declare(strict_types=1);
/**
* Encodes and verifies a JSON payload with an HMAC signature,
* so tampered or forged data is rejected before decoding.
*/
final class SignedPayload
{
public function __construct(private readonly string $secretKey)
{
}
public function encode(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
$signature = hash_hmac('sha256', $json, $this->secretKey);
return base64_encode($json) . '.' . $signature;
}
public function decode(string $payload): ?array
{
[$encodedJson, $signature] = array_pad(explode('.', $payload, 2), 2, '');
$json = base64_decode($encodedJson);
$expectedSignature = hash_hmac('sha256', (string) $json, $this->secretKey);
if (!hash_equals($expectedSignature, $signature)) {
return null; // reject before ever decoding untrusted structure
}
return json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR);
}
}
9. Serialization formats compared by risk
The following table compares common formats for serializing untrusted data with regard to their risk of deserialization vulnerabilities.
| Format | Object instantiation | Risk | Recommendation |
|---|---|---|---|
| serialize() without allowed_classes | Yes, any class | Very high | Never apply to untrusted data |
| serialize() with allowed_classes: [] | No, only scalars/arrays | Low | Transitional fix for legacy formats |
| JSON (json_decode) | No | Practically none | Default choice for new applications |
| Signed JSON (HMAC) | No | Practically none | Preferred for cookies and URL parameters |
The table shows a clear hierarchy: only unrestricted serialize() poses an acute risk, all other options significantly reduce the attack surface for deserialization vulnerabilities. In new projects, unserialize() on external input should generally be avoided, regardless of how trustworthy the source currently appears.
Mironsoft
PHP security audits and dependency reviews
unserialize() lurking somewhere in legacy code?
We find risky deserialization points in existing PHP code, check dependencies for known gadget chains and migrate to secure alternatives such as JSON and signed payloads.
Codebase scan
Targeted search for unserialize() on untrusted input
Dependency audit
Review of libraries used for known gadget chain risks
Migration
Switch to JSON, allowed_classes and HMAC-signed payloads
10. Summary
Deserialization vulnerabilities in PHP almost always arise at the same spot: unserialize() is applied to data that an attacker can influence. The most reliable defense is to structurally avoid this pattern by using JSON for data exchange with untrusted sources. Where unserialize() remains unavoidable for legacy reasons, the allowed_classes parameter limits the damage, but is only a transitional fix, not a permanent safeguard.
Gadget chains show that the real danger rarely lies in one's own code, but in combinations of classes that are harmless in isolation, often somewhere in Composer dependencies. Signed payloads with HMAC complement this defense by discarding manipulated data before deserialization even takes place. The combination of JSON as the default format, minimal allowlists where unserialize() is unavoidable, and signing for externally accessible payloads covers practically all known attack paths against deserialization vulnerabilities.
Avoiding Deserialization Vulnerabilities — The Essentials at a Glance
Basic rule
Never apply unserialize() to data from untrusted sources without allowed_classes.
Best alternative
JSON with explicit fromArray() hydration instead of PHP native serialization.
Transitional fix
allowed_classes as a narrow allowlist, regularly checked for dangerous magic methods.
Additional layer
HMAC signing for cookies and URL parameters, discarding payloads before decoding.