unserialize() does more than restore data. With attacker-controlled input, object instantiation and magic methods can turn a data parser into a code-execution path.
This is the nineteenth article in a series on PHP and Laravel application security.
So far we have covered:
- Detecting SQL injection attempts in PHP logs
- Why URL encoding blinds most PHP security checks
- The decode bomb problem with unlimited URL decoding
- Why parameterized queries are the only real fix for SQL injection
- XSS prevention in Laravel and why
{!! !!}is the line between safe and hacked - How attackers enumerate your Laravel app before exploiting it
- File upload security — the file that isn't what it claims to be
- Path traversal in PHP — how
../escapes your application - Command injection in PHP — when
exec()becomes an attack surface - Broken access control in Laravel — why being logged in is not enough
- Secrets in Laravel — why
.envis only the beginning - Session security in PHP — what most developers get wrong
- Rate limiting in Laravel and PHP — how to stop brute force before it starts
- Security headers in PHP and Laravel — the lines that harden every response
- IDOR in PHP and Laravel — when changing one number exposes someone else's data
- Mass assignment in PHP and Laravel — when user input becomes more than it should
- Open redirect vulnerabilities in PHP and Laravel
- PHP type juggling — when 0 equals admin
Every article in this series follows the same principle: understand the attack before you try to stop it.
Insecure deserialization is the most technically complex vulnerability in this series. It is also one of the most severe. When a usable gadget chain exists, insecure deserialization can escalate from attacker-controlled data into arbitrary code execution. Other outcomes are possible too file manipulation, arbitrary method execution, SSRF, data deletion or modification, or application crashes but the code-execution scenario is why this class of bug gets so much attention.
Understanding it requires understanding how PHP serialization works from the inside.
What Serialization Actually Is
Serialization is converting a data structure — an object, an array, a complex type — into a string that can be stored or transmitted and later reconstructed.
PHP has two functions for this:
// Serialize — convert object to string
$data = serialize($object);
// Produces: O:4:"User":2:{s:4:"name";s:4:"John";s:5:"email";s:16:"john@example.com";}
// Unserialize — convert string back to object
$object = unserialize($data);
The serialized string encodes the class name, property names, property values, and the type of each value. When you call unserialize() PHP reconstructs the original object from that string including instantiating the class.
That last part instantiating the class — is where the vulnerability begins. It's worth being precise here: unserialize() does not itself execute arbitrary PHP code. The danger is that deserializing attacker-controlled objects can instantiate classes and invoke magic methods, and if suitable application "gadgets" exist, that chain of method calls can lead to code execution. PHP's own documentation explicitly warns against passing untrusted data to unserialize().
Magic Methods — The Attack Surface
PHP classes can define magic methods that execute automatically at specific points in an object's lifecycle:
class Example
{
// Called when object is created
public function __construct() { }
// Called when unserialize() restores the object (legacy)
public function __wakeup() { }
// Called when unserialize() restores the object (PHP 7.4+)
public function __unserialize(array $data) { }
// Called when object is destroyed or goes out of scope
public function __destruct() { }
// Called when object is used as a string
public function __toString() { }
// Called when accessing undefined property
public function __get($name) { }
// Called when invoking object as function
public function __invoke() { }
}
When unserialize() processes a serialized string it:
- Reads the class name from the string
- Creates an instance of that class
- Sets the properties to the values encoded in the string
- Calls
__unserialize()if the class defines it; otherwise__wakeup()if available
The critical insight: the attacker controls the serialized string. They control which class gets instantiated, and they control the property values on that object. They do not control the code inside __wakeup(), __unserialize(), or __destruct() — but if a class already contains one of these magic methods, the attacker-controlled property values can influence what that existing method does when it runs automatically.
A Simple Deserialization Attack
Imagine your application has this class somewhere in the codebase:
class FileLogger
{
public string $logFile;
public string $logMessage;
public function __destruct()
{
file_put_contents($this->logFile, $this->logMessage);
}
}
(Because the properties are typed, this example requires PHP 7.4 or later.)
This class looks harmless. It just writes a log message to a file. It might be used legitimately elsewhere in the application.
Now your application accepts a serialized cookie and unserializes it:
$data = unserialize($_COOKIE['user_data']);
An attacker crafts a serialized FileLogger object:
$malicious = new FileLogger();
$malicious->logFile = '/var/www/public/shell.php';
$malicious->logMessage = '<?php system($_GET["cmd"]); ?>';
echo serialize($malicious);
// O:10:"FileLogger":2:{s:7:"logFile";s:25:"/var/www/public/shell.php";s:10:"logMessage";s:30:"<?php system($_GET["cmd"]); ?>";}
They set this as their cookie. Your application calls unserialize(). PHP instantiates a FileLogger object with their property values. When the object is destroyed at the end of the request __destruct() runs — writing a PHP web shell to your public directory.
The attacker now visits /shell.php?cmd=whoami and has command execution on your server.
This is PHP object injection through insecure deserialization.
POP Chains — Property Oriented Programming
Real-world deserialization attacks rarely use a single class. They chain multiple classes together each one's magic method triggering the next to achieve code execution. These are called POP chains (Property Oriented Programming chains) or gadget chains.
A POP chain abuses classes that already exist in the application. The attacker supplies an object graph whose properties cause existing magic methods and other methods to call one another until a dangerous operation is reached. None of the individual classes need to look dangerous on their own.
A simplified example:
class A
{
public $b;
public function __wakeup()
{
// Calls __toString on $this->b
echo $this->b;
}
}
class B
{
public $c;
public function __toString()
{
// Calls __invoke on $this->c
return ($this->c)();
}
}
class C
{
public $command;
public function __invoke()
{
// Executes arbitrary command
return system($this->command);
}
}
An attacker serializes:
$c = new C();
$c->command = 'id';
$b = new B();
$b->c = $c;
$a = new A();
$a->b = $b;
echo serialize($a);
When the serialized string is unserialized:
-
A::__wakeup()fires — echoes$this->b - Echoing a
Bobject triggersB::__toString() -
__toString()invokes$this->cas a function - Invoking a
Cobject triggersC::__invoke() -
__invoke()callssystem($this->command) - The attacker's command executes
None of these classes individually look dangerous. The vulnerability comes from how they chain together when an attacker controls the deserialized object graph.
Where Deserialization Vulnerabilities Appear
1. Cookies:
// Dangerous
$user = unserialize($_COOKIE['user']);
// The cookie value is entirely attacker-controlled
2. GET and POST parameters:
// Dangerous
$data = unserialize($_GET['data']);
$config = unserialize($_POST['config']);
3. Database fields:
// Dangerous — if the serialized data was ever user-supplied
$preferences = unserialize($user->preferences);
4. Cache:
// Potentially dangerous — depends on what was cached and who could influence it
$cached = unserialize(Redis::get('user:' . $userId));
5. Session data:
Some PHP session configurations and frameworks use serialization to represent session data, but whether PHP's serialize() format is actually used depends on the session handler and configuration — not every PHP session file is simply the output of serialize(). If an attacker can write to or influence session storage, and that storage is deserialized in PHP's native format, they may be able to inject malicious serialized objects.
Laravel and Deserialization
Laravel's queue system serializes jobs so they can be stored by the configured queue driver and reconstructed by a worker later:
dispatch(new ProcessOrder($order));
The important distinction is that normal Laravel queue payloads are generated by the application. An ordinary user does not automatically get to replace those payloads with arbitrary serialized PHP objects.
The risk appears when an attacker can influence the queue payload or the storage behind it — for example, through compromised queue infrastructure, unauthorized Redis access, a database compromise, or application code that places attacker-controlled serialized objects into a job.
Laravel's SerializesModels trait provides special handling for Eloquent models:
class ProcessOrder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Order $order
) {}
}
Rather than serializing the complete Eloquent model into the job payload, Laravel serializes information needed to retrieve the model again when the job is processed. Loaded relationships can also affect the serialized payload, so large or unnecessary relationships should be avoided.
Never do this in a job:
// Dangerous — serializing user-supplied data into a job
class ProcessUserData implements ShouldQueue
{
public $userData;
public function __construct(string $rawUserData)
{
// If $rawUserData contains a serialized object this is dangerous
$this->userData = unserialize($rawUserData);
}
}
The important security rule: do not treat queue storage as a place where untrusted users can write arbitrary serialized objects. Protect Redis, database-backed queues, and other queue infrastructure as security-sensitive systems.
The Safe Alternatives
Use JSON instead of serialize():
// Instead of this
$data = serialize($object);
$object = unserialize($data);
// Do this
$data = json_encode($object);
$array = json_decode($data, true); // returns array, not object
json_decode() with true as the second argument returns an associative array never an object instance. JSON does not invoke PHP object magic methods during decoding, which makes it substantially safer than PHP object deserialization for data crossing trust boundaries.
When You Must Deserialize Existing PHP Data
The safest option is to avoid PHP serialization for data that crosses a trust boundary PHP's own documentation recommends a standard format such as JSON instead of unserialize() for untrusted data.
If you have a legitimate reason to deserialize externally stored PHP data, allowed_classes can reduce the object-instantiation attack surface:
// Explicitly allow only the classes you expect
$value = unserialize($data, [
'allowed_classes' => ['MyClass', 'AnotherClass']
]);
// Do not instantiate serialized classes
$value = unserialize($data, [
'allowed_classes' => false
]);
With allowed_classes => false, serialized objects are not instantiated as their original classes PHP represents them as __PHP_Incomplete_Class objects instead, with no magic methods to trigger.
However, allowed_classes should not be treated as a way to make arbitrary attacker-controlled input safe. PHP's documentation explicitly warns against passing untrusted input to unserialize() regardless of the allowed_classes setting.
If serialized data must cross a trust boundary, authenticate it with a strong HMAC and verify the signature before calling unserialize():
function serializeSigned(mixed $data, string $secretKey): string
{
$serialized = serialize($data);
$signature = hash_hmac('sha256', $serialized, $secretKey);
return $signature . ':' . base64_encode($serialized);
}
function unserializeSigned(string $signed, string $secretKey): mixed
{
$parts = explode(':', $signed, 2);
if (count($parts) !== 2) {
throw new RuntimeException('Invalid signed data.');
}
[$signature, $encoded] = $parts;
$serialized = base64_decode($encoded, true);
if ($serialized === false) {
throw new RuntimeException('Invalid encoding.');
}
$expected = hash_hmac('sha256', $serialized, $secretKey);
if (!hash_equals($expected, $signature)) {
throw new RuntimeException('Signature verification failed.');
}
return unserialize($serialized, ['allowed_classes' => false]);
}
An attacker who cannot forge the HMAC signature cannot inject malicious serialized data.
The important principle: do not deserialize attacker-controlled PHP objects simply because you restricted the class list. Prefer JSON. If PHP serialization is unavoidable, authenticate the data first and restrict the classes that can be instantiated.
The PHP Version Landscape
allowed_classes has been available since PHP 7.0, and PHP 7.4 added the modern __serialize()/__unserialize() magic methods, which take precedence over __sleep()/__wakeup() when both are defined. Later versions have improved type safety in various ways, but unserialize() itself remains dangerous with untrusted input in every PHP version.
There is no PHP version where unserialize() on untrusted user input is safe without class allowlisting or input signing.
Auditing Your Codebase
# Find all unserialize() calls
grep -rn "unserialize(" app/ bootstrap/ config/
# Find serialize() calls that might store to user-accessible locations
grep -rn "serialize(" app/ | grep -i "cookie\|session\|cache\|redis\|header"
# Find base64_decode followed by unserialize — common attack pattern
grep -rn "base64_decode" app/
# Find dangerous cookie handling
grep -rn "\$_COOKIE" app/
# Check for allowed_classes usage
grep -rn "allowed_classes" app/
For every unserialize() call ask:
- Where does this data come from?
- Could an attacker influence that data?
- Is
allowed_classesset to restrict which classes can be instantiated? - Could the data be replaced with JSON?
The Deserialization Security Checklist
For plain PHP:
- Never call
unserialize()on data from cookies, GET, POST, or any user-controlled source - Replace
serialize()/unserialize()withjson_encode()/json_decode()wherever possible - If PHP deserialization is unavoidable, use an explicit
allowed_classesallowlist where appropriate, authenticate externally stored data before deserializing it, and never treatallowed_classesas a substitute for trusting the input source - Sign serialized data with HMAC before storing in any user-accessible location
- Verify the HMAC signature before calling
unserialize()usehash_equals()for comparison - Audit every
unserialize()call in the codebase and trace where the data originates
For Laravel:
- Use
SerializesModelsin queue jobs it stores model identifiers rather than the full model, and reloads the model when the job runs - Never unserialize user-supplied data inside a queue job
- Never use
unserialize()on cached data that could have been influenced by user input - Prefer JSON for any data that crosses a trust boundary
- Keep Redis and queue database instances on private networks if an attacker can write to your queue storage they can inject malicious jobs
- Review any package that calls
unserialize()internally check what data it receives
What Is Kriosa?
Kriosa is an application-level and API security layer for PHP and Laravel applications. It sits at the application boundary and inspects incoming requests for suspicious traffic before that traffic reaches sensitive application logic.
For attacks involving serialized PHP payloads, Kriosa can provide an additional detection layer by identifying suspicious request patterns and payload structures associated with object-injection attempts.
But Kriosa is not a replacement for fixing insecure deserialization.
If your application accepts untrusted serialized data, the primary fix is still to remove that trust boundary: prefer JSON, authenticate externally stored serialized data, and avoid unserialize() on attacker-controlled input.
Think of Kriosa as defense in depth, not a substitute for secure application design.
How Kriosa Can Help Detect Suspicious Serialized Payloads
Insecure deserialization is fundamentally an application-layer vulnerability.
The dangerous condition isn't simply that a request contains O: or a base64-encoded value. Legitimate applications can send encoded or serialized data too.
The real problem is that attacker-controlled data eventually reaches a dangerous deserialization operation.
That means detection should be treated as a layer of defense, not the primary fix.
Serialized PHP data has a recognizable format. It uses type indicators such as:
O: Object
a: Array
s: String
i: Integer
b: Boolean
A request containing something like:
O:10:"FileLogger":...
or an encoded representation of a serialized object can become a useful security signal when it appears in a parameter, cookie, API request, or endpoint that normally expects a simple value.
Kriosa can help surface suspicious request patterns around these endpoints, including unusual encoded payloads and repeated probing of parameters with serialized-object structures.
The important distinction is:
Detection does not equal prevention.
Prevention Comes First
The application should still:
Avoid unserialize() for untrusted data.
Prefer JSON for data crossing trust boundaries.
Authenticate externally stored serialized data when PHP serialization is genuinely required.
Restrict classes when deserialization cannot be avoided.
Review dangerous magic methods and potential gadget chains.
Detection Adds Another Layer
Once the application has been designed securely, an additional security layer can help identify attempts to exploit weaknesses that may still exist.
An attacker probing an endpoint repeatedly with serialized-object payloads may be attempting to discover an insecure deserialization vulnerability.
That activity is valuable security telemetry.
Kriosa is designed to provide that additional visibility at the application layer helping developers detect, log, investigate, and respond to suspicious application traffic.
For example, in applications such as Prolify, data crossing trust boundaries can use JSON rather than PHP object serialization. In Laravel applications, features such as SerializesModels can reduce the amount of model state that needs to be serialized by representing models through identifiers and restoring them when the job is processed.
The goal is not to make dangerous deserialization safe.
The goal is to reduce the attack surface and add another layer of visibility when someone tries to probe it.
Why Kriosa?
Security controls can fail.
Developers can miss an unsafe unserialize() call. A dependency can introduce an unexpected gadget chain. An old endpoint can remain exposed. A configuration change can create a new trust boundary.
That's why defense in depth matters.
A practical security model can look like:
Secure code → Input validation → Kriosa detection → Logging & response
Each layer has a different job.
Your application code should prevent the vulnerability.
Your validation should reject unexpected input.
Kriosa can provide additional visibility into suspicious traffic.
Your logs and monitoring can help you investigate what happened.
Kriosa doesn't replace secure coding. It gives secure applications another layer to defend and monitor their attack surface.
Insecure Deserialization Security Checklist
Before shipping a PHP or Laravel application, ask:
- Do I use unserialize() anywhere in the application?
- Can the serialized data originate from a user or another untrusted source?
Can an attacker modify cookies, API parameters, cache entries, queue data, or other serialized values?
Can I replace PHP serialization with JSON?
If serialized data must cross a trust boundary, is it authenticated with an HMAC or another integrity mechanism?
Have I avoided treating allowed_classes as a complete security solution?
If classes must be allowed, have I explicitly restricted the permitted classes?
Have I reviewed __unserialize(), __wakeup(), and __destruct() for dangerous behavior?
Have I checked for classes that write files, execute commands, make network requests, modify application state, or perform other sensitive operations?
Have I reviewed third-party dependencies for potential gadget chains?
Have I checked Laravel jobs and other serialized application data for unexpected trust boundaries?
Are suspicious requests being logged and monitored?
Do I have an additional detection layer for malicious application traffic?
The goal isn't simply to remove one dangerous function.
The goal is to make sure untrusted data never gets the opportunity to become an executable object inside your application.
Try Kriosa
Try it free: kriosa.com
Install it:
composer require kriosa-ai/kriosa-php
Documentation: Kriosa Documentation
Built by a developer from Cameroon, for developers who want to understand their security - not just outsource it.
The Series So Far
Article 1: What your PHP logs actually look like during a SQL injection attack
Article 2: Why URL encoding can break PHP security checks
Article 3: The decode bomb problem - why unlimited URL decoding can be its own vulnerability
Article 4: Parameterized queries - the only real fix for SQL injection
Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
Article 6: How attackers enumerate your Laravel app before exploiting it
Article 7: File upload security in PHP and Laravel
Article 8: Path traversal in PHP - how ../ escapes your application
Article 9: Command injection in PHP - when exec() becomes an attack surface
Article 10: Broken access control in Laravel - why being logged in is not enough
Article 11: Secrets in Laravel - why .env is only the beginning
Article 12: Session security in PHP - what most developers get wrong
Article 13: Rate limiting in Laravel and PHP - how to stop brute force before it starts
Article 14: Security headers in PHP and Laravel - the lines that harden every response
Article 15: IDOR in PHP and Laravel - when changing one number exposes someone else's data
Article 16: This article - mass assignment in PHP and Laravel and when user input becomes more than it should
Article 17: This article - open redirect vulnerabilities in PHP and Laravel
Article 18: PHP type juggling - when 0 equals admin
Article 19: This article - insecure deserialization in PHP and when your cache becomes an attack surface
Top comments (0)