Object Injection, gadget chains, and safe alternatives
Running unserialize on untrusted input such as cookies, session data, or API payloads opens the door to PHP Object Injection and dangerous gadget chains that abuse magic methods like wakeup and destruct to execute arbitrary code. This article explains the mechanics, shows safer alternatives like json_decode, and covers proven deserialization patterns for production PHP and Magento applications.
Table of Contents
- 1. What deserialization is and why untrusted input is dangerous
- 2. How unserialize() on attacker controlled data triggers PHP Object Injection
- 3. Magic methods as gadget entry points: wakeup, destruct, toString
- 4. What a gadget chain is: concept without exploit code
- 5. Why json_decode() is safer, and where its limits lie
- 6. Safe deserialization patterns in practice
- 7. A historical, Magento relevant deserialization example
- 8. Detecting and auditing unserialize() usage in your own code
- 9. Serialization approaches compared side by side
- 10. Summary
- 11. FAQ
1. What deserialization is and why untrusted input is dangerous
PHP provides a native format via serialize() and unserialize() to turn arbitrary values, including arrays, scalars, and full objects, into a string and later restore them. The serialized string encodes not only the values themselves but, for objects, also the exact class name and the internal property structure. This is precisely what fundamentally separates PHP's native serialization format from plain data formats like JSON: the string itself determines which class gets instantiated on restoration.
As soon as such a string originates from an untrusted source, for example a cookie, a form field, a session identifier, or a cache entry that can be influenced externally, unserialize() becomes an active attack surface. The caller no longer only controls data, but potentially also which classes get instantiated and which magic methods get executed automatically along the way. In Magento and comparable PHP applications, this problem has historically shown up in session handling, payment callback data, and cache layers that processed serialized structures without sufficient origin checks.
2. How unserialize() on attacker controlled data triggers PHP Object Injection
PHP Object Injection (POI) occurs when an attacker can freely choose the class name and property values inside the serialized string. If the application then calls unserialize() on that string, PHP instantiates any class reachable through the autoloader at that point in time, regardless of whether the application ever intended that class to be used this way. The target class's constructor is not called, but certain magic methods are, provided the class implements them.
The real risk rarely lies in the instantiation itself, but in what happens automatically afterward. If the project or an included library contains a class with a __wakeup() or __destruct() method that writes files, builds SQL queries, or passes objects along, an attacker can trigger that logic purely by choosing the class name inside the payload, without the application ever intending such a call chain.
The following example is not a working exploit. It only illustrates the underlying attack surface: as soon as raw data from a cookie flows directly into unserialize(), the application loses control over which class actually gets instantiated.
<?php
declare(strict_types=1);
// VULNERABLE PATTERN (illustrative only, do not use):
// Untrusted request data is passed directly into unserialize().
// If the payload contains a serialized object, unserialize() will
// instantiate that class and invoke its magic methods (__wakeup,
// __destruct, __toString) automatically, before any validation runs.
final class LegacyCartImporter
{
public function importFromCookie(string $rawCookieValue): array
{
// Attacker fully controls $rawCookieValue.
// Any class reachable via autoloading can be instantiated here.
$data = unserialize($rawCookieValue);
if (!is_array($data)) {
throw new \InvalidArgumentException('Invalid cart payload.');
}
return $data;
}
}
3. Magic methods as gadget entry points: wakeup, destruct, toString
Magic methods are the mechanism that makes PHP Object Injection practically exploitable in the first place. __wakeup() is called automatically by unserialize() right after object creation, and its intended purpose is to reinitialize resources such as database connections after restoration. But if this method contains side effects such as file access, calls into other objects, or dynamic method calls, it becomes the first link in a possible attack chain that runs entirely without any further action by the application.
__destruct() is especially insidious because it is called automatically at the end of the script run or during garbage collection, often far removed from where unserialize() was originally called. That makes debugging significantly harder, since the actual trigger and the harmful effect are separated in both time and location. __toString(), in turn, kicks in as soon as an object is used in a string context, for example in a log message or a string concatenation, making it a third, often overlooked entry point.
Importantly, none of these methods are inherently dangerous. They only become dangerous once their implementation performs actions with real side effects while trusting property values that could theoretically originate from a manipulated serialized string.
4. What a gadget chain is: concept without exploit code
A gadget chain is a sequence of method calls spanning multiple, often completely unrelated classes, which only becomes dangerous in combination. The term comes from the observation that individually harmless code fragments can be assembled like building blocks: class A's __destruct() method calls a method on class B, whose return value is then used by class C in a dangerous context, for example inside call_user_func() or a file operation.
Security researchers systematically analyze the classes present in a project and its dependencies for chainable magic methods, an approach that publicly known tooling for common frameworks largely automates. What matters for developers is not reconstructing a specific chain, but understanding that every additional dependency with dangerous magic methods enlarges the potential attack surface, even if the application's own code is entirely correct.
This is exactly why the most effective defense is not hardening individual gadgets, but fundamentally preventing object instantiation from untrusted input in the first place. If no arbitrary class can be instantiated, there is no chain that could ever be triggered.
5. Why json_decode() is safer, and where its limits lie
json_decode() has no concept of instantiating arbitrary PHP classes while parsing. The JSON format itself carries no class information, and PHP's implementation turns a JSON object into a stdClass instance by default, or into a plain array when the associative flag is used. Neither return type has magic methods that could execute automatically. That removes the entire attack vector that makes PHP Object Injection possible in the first place: there simply is no class whose __wakeup() or __destruct() an attacker could pick.
That does not mean json_decode() is automatically safe. Without JSON_THROW_ON_ERROR, the function silently returns null on invalid input, which leads to unnoticed downstream errors. Without a limit on depth, heavily nested payloads can needlessly consume resources. And even a cleanly parsed array can still contain values that make no business sense or are harmful, such as negative quantities or overly long strings, if no explicit schema validation follows the parsing step.
In practice, json_decode() fully solves the structural security problem of object instantiation but does not replace validating the actual values it returns. Both steps belong together at every point that processes input from untrusted sources.
<?php
declare(strict_types=1);
// SAFE PATTERN: json_decode() never instantiates arbitrary classes.
// It only ever produces scalars, arrays, or stdClass instances,
// so there is no magic-method entry point to abuse.
final class CartPayloadDecoder
{
/**
* @throws \JsonException
* @throws \InvalidArgumentException
*/
public function decode(string $rawJson): array
{
$decoded = json_decode(
$rawJson,
associative: true,
depth: 8,
flags: JSON_THROW_ON_ERROR
);
if (!is_array($decoded)) {
throw new \InvalidArgumentException('Payload must be a JSON object or array.');
}
// Explicit allow-list validation instead of trusting the shape blindly.
$sku = $decoded['sku'] ?? null;
$qty = $decoded['qty'] ?? null;
if (!is_string($sku) || $sku === '' || !is_int($qty) || $qty < 1) {
throw new \InvalidArgumentException('Payload failed schema validation.');
}
return ['sku' => $sku, 'qty' => $qty];
}
}
6. Safe deserialization patterns in practice
Where unserialize() remains unavoidable for compatibility reasons, for example when reading legacy session data, the allowed_classes option drastically shrinks the attack surface. With allowed_classes => false, PHP decodes every serialized object to false instead of instantiating it; with an explicit class list, only the named, vetted classes are permitted. This option has existed since PHP 7.0 and should be mandatory for every remaining unserialize() call on external data.
<?php
declare(strict_types=1);
// SAFE PATTERN: restrict unserialize() to plain data, no objects at all.
// Use this when legacy code still requires the PHP serialization format
// but the input source cannot be fully trusted.
final class LegacySessionReader
{
public function read(string $serialized): array
{
$result = unserialize($serialized, ['allowed_classes' => false]);
// Any serialized object in the payload now decodes to false
// instead of being instantiated, which lets us reject it safely.
if ($result === false && $serialized !== serialize(false)) {
throw new \RuntimeException('Rejected: payload contains object data.');
}
if (!is_array($result)) {
throw new \RuntimeException('Rejected: payload is not a plain array.');
}
return $result;
}
}
A second, independent protection principle is cryptographically signing serialized data before storing it. Alongside the raw payload, an HMAC over the serialized string is generated and stored as well. Before every unserialize() call, the signature is checked with hash_equals(), so manipulated payloads are rejected before deserialization ever happens, regardless of which class is referenced. The secret key used here must never live in frontend code or anywhere reachable by the client.
<?php
declare(strict_types=1);
// SAFE PATTERN: sign serialized data before persisting it, then verify
// the signature before ever calling unserialize() on it again.
final class SignedSerializer
{
public function __construct(private readonly string $secretKey)
{
}
public function pack(array $payload): string
{
$serialized = serialize($payload);
$signature = hash_hmac('sha256', $serialized, $this->secretKey);
return $signature . '.' . base64_encode($serialized);
}
/**
* @throws \RuntimeException
*/
public function unpack(string $packed): array
{
[$signature, $encoded] = array_pad(explode('.', $packed, 2), 2, '');
$serialized = base64_decode($encoded, true) ?: '';
$expected = hash_hmac('sha256', $serialized, $this->secretKey);
if (!hash_equals($expected, $signature)) {
throw new \RuntimeException('Signature mismatch, payload rejected.');
}
$result = unserialize($serialized, ['allowed_classes' => false]);
if (!is_array($result)) {
throw new \RuntimeException('Unexpected payload shape.');
}
return $result;
}
}
The third principle is schema validation after every deserialization, regardless of format. Expected field names, types, and value ranges are checked explicitly before the data flows into business logic. Combine all three patterns, allowed_classes, signing, and schema validation, and you get a layered defense where a single overlooked case does not immediately lead to full compromise.
7. A historical, Magento relevant deserialization example
Over the years, several Magento security updates identified spots where the platform used PHP's native serialization for data whose origin was not fully trustworthy, for example certain configuration values, product options, or payment related fields populated by modules or external interfaces. When such values were later processed with unserialize() instead of a plain data format, a potential PHP Object Injection surface arose whenever an attacker could influence the stored string, whether through a vulnerable form field or an insufficiently validated API.
The lesson from these cases was less about a single line of code than a recurring pattern: serialized PHP strings were used in places originally intended for internal, trusted configuration data, but later became indirectly influenceable from outside through forms, imports, or extensions, without the processing logic being updated accordingly. The fix approach in the affected areas consistently followed the same pattern: replacing serialize()/unserialize() with json_encode()/json_decode() for any value that could potentially be influenced from outside, paired with explicit whitelist validation of the expected field structure.
For today's Magento and PHP projects, the practical takeaway remains identical: every place where serialize() or unserialize() touches data that could originate from a form, an extension, an import, or an external API deserves a targeted review, regardless of whether that spot was historically considered safe.
8. Detecting and auditing unserialize() usage in your own code
The first step of an audit is simple and effective: a complete inventory of every unserialize() call in the project and its own modules, including where the respective input data actually comes from. A simple grep across the codebase provides a quick overview here, but should be supplemented with a check of whether the allowed_classes option is already set, since a plain hit on unserialize( says nothing yet about the actual safety of that spot.
Static analysis tools like PHPStan or Psalm can be extended with specialized rule sets that flag unsafe unserialize() calls without allowed_classes as errors, making them enforceable in the CI pipeline instead of relying on manual reviews. A Rector rule set additionally helps by automatically proposing replacements for known unsafe patterns, for example switching from serialize()/unserialize() to json_encode()/json_decode() at spots without a hard requirement for objects.
A code review checklist for deserialization should cover at least these questions: Does the input come from an untrusted source? Is allowed_classes set, or would json_decode() suffice instead? Is there a signature check before deserialization? And: does any of the potentially instantiable classes implement __wakeup(), __destruct(), or __toString() with dangerous side effects?
#!/usr/bin/env bash
# Audit a PHP codebase for potentially unsafe unserialize() usage.
set -euo pipefail
echo "== Direct unserialize() calls =="
grep -rn --include="*.php" -E '\bunserialize\s*\(' app/code vendor/local 2>/dev/null || true
echo "== unserialize() calls without allowed_classes option =="
grep -rn --include="*.php" -E 'unserialize\([^,)]+\)\s*;' app/code 2>/dev/null || true
echo "== Static analysis: PHPStan security-relevant rule set =="
vendor/bin/phpstan analyse app/code --level=5 --error-format=table
echo "== Rector dry-run: flag legacy serialize()/unserialize() usage =="
vendor/bin/rector process app/code --dry-run --config=rector-security.php
9. Serialization approaches compared side by side
Choosing a serialization format for untrusted data is not a matter of style. It has a direct impact on an application's attack surface. The table below summarizes the key differences between unsafe and safe approaches.
| Area | Unsafe approach | Safe approach | Why it matters |
|---|---|---|---|
| Parsing untrusted input | unserialize() on raw POST/cookie data |
json_decode() with type and schema checks |
No class instantiation possible |
| Session/cache storage | serialize() without further protection |
Signed payloads or plain JSON | Prevents unnoticed tampering |
| Class control in unserialize() | unserialize($data) without options |
unserialize($data, ['allowed_classes' => false]) |
Objects are never instantiated |
| Integrity checking | No signature check before deserialization | Verify an HMAC signature with hash_equals() |
Manipulated payloads get rejected |
| Magic methods | wakeup/destruct with side effects in reachable classes | Classes without dangerous magic methods, clear allow list | No automatic gadget entry point |
| Code quality/audit | No monitoring of unserialize() calls | Static analysis and mandatory review for every call | New vulnerabilities get caught early |
In practice, most cases are easy to classify: as soon as data crosses a trust boundary, for example coming from a cookie, a third party API, or a form field, json_decode() with explicit validation belongs at that spot. unserialize() stays acceptable only for internal, fully controlled data flows, and even there allowed_classes is recommended as additional protection.
Mironsoft
PHP and Magento security audits, code reviews, and secure architecture
Ready to find deserialization risks in your code?
We analyze your PHP and Magento codebase for unsafe unserialize() calls, risky magic methods, and missing validation, and deliver concrete, immediately actionable fixes instead of generic recommendations.
Security audit
Static analysis and manual review for deserialization and injection risks
Code hardening
Migrating unserialize() to json_decode(), retrofitting schema validation
CI integration
Anchoring PHPStan rules and automated audits directly in the pipeline
10. Summary
Deserialization vulnerabilities in PHP arise whenever unserialize() touches data whose origin is not fully controlled. The serialized string does not just determine values, but for objects also which class gets instantiated, and magic methods like __wakeup(), __destruct(), and __toString() execute automatically along the way. If reachable classes contain dangerous side effects inside these methods, a potential gadget chain emerges, entirely without the application itself containing a faulty call.
json_decode() eliminates this structural risk completely, because JSON carries no class information. Where PHP's native serialization is still needed, allowed_classes, HMAC signing, and consistent schema validation reduce the attack surface to a manageable level. Regular audits using grep, static analysis, and a clear review checklist ensure that new unsafe spots do not appear unnoticed.
Deserialization Vulnerabilities in PHP, the Essentials at a Glance
Object instantiation
The serialized string determines which class unserialize() instantiates, a key difference from plain data formats.
Magic methods
__wakeup(), __destruct(), and __toString() run automatically and are the typical gadget entry points.
json_decode as an alternative
Only ever produces arrays or stdClass instances without magic methods, structurally eliminating the object instantiation risk.
Layered defense
allowed_classes, HMAC signing, and schema validation together provide resilient protection for any remaining unserialize() calls.