Understanding PHP Deserialization Vulnerabilities and Preventing Remote Code Execution

Understanding PHP Deserialization Vulnerabilities and Preventing Remote Code Execution
Understanding PHP Deserialization Vulnerabilities and Preventing Remote Code Execution

This guide breaks down PHP deserialization vulnerabilities, showing how they lead to RCE attacks and offering mitigation techniques to secure your PHP apps.

A single line, unserialize($_GET['data']), has handed attackers full control of more servers than most developers would like to admit. It looks harmless. It reads a string, hands you back an object, and moves on. But if that string came from someone you don’t trust, you’ve just let them decide which objects your code builds and which methods fire on the way out. That’s how a data-loading call turns into remote code execution.

This walks through why PHP deserialization goes wrong, how the jump to RCE actually happens, and the two defenses that hold up. We’ll build from the basics to the mitigations you can ship today.

Table of Contents

Introduction to PHP Deserialization

Serialization turns a PHP value or object into a flat string you can store or send over the wire. Deserialization is the reverse: it reads that string back into a live object. Useful on its own. The problem starts the moment the string comes from outside your control. Feed attacker-shaped data to unserialize() and you’re no longer just restoring data, you’re letting the input pick which classes get instantiated in your process.

How Does Deserialization Work?

In PHP you serialize with serialize() and read the string back with unserialize(). Here’s the round trip.

Example of Serialization in PHP
PHP
<?php
// Define a basic class.
class User {
    public $username;
    public $email;
    public function __construct($username, $email) {
        $this->username = $username;
        $this->email = $email;
    }
}
// Create an instance of the User class.
$user = new User('john_doe', '[email protected]');
// Serialize the object.
$serializedUser = serialize($user);
echo $serializedUser;
// Output: O:4:"User":2:{s:8:"username";s:8:"john_doe";s:5:"email";s:15:"[email protected]";}

Notice the format. It encodes the class name, the property names, and their values. That is the key detail: the string itself says which class to build. Reverse it and you get the object back.

PHP
<?php
// Deserialize the object.
$unserializedUser = unserialize($serializedUser);
echo $unserializedUser->username; // Output: john_doe

What Makes PHP Deserialization Dangerous?

Because the serialized string names the class, whoever controls the string controls what unserialize() instantiates. That’s object injection. An attacker doesn’t need to smuggle in new code; they craft a string that builds an object of a class already loaded in your application, with property values they chose. If instantiating that object has side effects, those side effects now run on their terms. Control the input to unserialize() and you’ve handed over a piece of the runtime.

Common Attack Vectors in Deserialization

Most deserialization attacks come in through one of these paths:

  • Manipulated Serialized Data: User input gets serialized, stored, then deserialized later without validation. The attacker tampers with the stored string in between.
  • Untrusted Input: The app accepts a serialized blob straight from a request, cookie, or third party and unserializes it. That’s a direct line in.
  • Object Injection: By choosing the class name in the string, the attacker gets your app to build an object it never meant to, triggering method calls during and after deserialization.

Remote Code Execution Through Deserialization

Object injection is the door. Remote code execution is the room behind it. RCE means running arbitrary commands on your server, and it’s the outcome you’re actually defending against here. It happens when the object an attacker builds reaches code that touches the filesystem, the shell, or another dangerous sink.

How Remote Code Execution Happens

PHP’s magic methods do the work. When an object is created or destroyed during deserialization, PHP may automatically call __wakeup() or __destruct(), and later __toString() or __call() depending on how the object is used. The attacker’s real job is to chain these together: pick a class whose __destruct() calls into a second object, whose method calls into a third, until the chain lands on something that executes a command. Security researchers call that a POP chain (property-oriented programming), and tools like PHPGGC keep ready-made chains for popular frameworks. So the danger isn’t only your own code; it’s any exploitable class already loaded, including your dependencies.

Example of a Basic PHP Deserialization Exploit

Here’s a stripped-down example. It’s a teaching model, not a real-world target: a genuine attack usually has to build a POP chain out of existing classes rather than find a destructor this convenient. But it shows the mechanism cleanly.

Vulnerable PHP Code
PHP
<?php
class VulnerableClass {
    public $username;
    public function __construct($username) {
        $this->username = $username;
    }
    public function __destruct() {
        echo shell_exec($this->username);
    }
}
$input = $_GET['data'];
$object = unserialize($input);

The attacker controls $_GET['data']. They send a serialized VulnerableClass with username set to a shell command. When the object is destroyed at the end of the request, __destruct() fires and passes that value straight to shell_exec(). A payload might look like this:

PHP
<?php
O:15:"VulnerableClass":1:{s:8:"username";s:12:"rm -rf /";}

On deserialization, shell_exec runs rm -rf /. Note the string length there is illustrative; the point is that the attacker, not you, decides what that command is.

How to Prevent PHP Deserialization Vulnerabilities

The fixes aren’t exotic. They come down to keeping untrusted data away from unserialize().

  • Don’t unserialize untrusted input: This is the whole ballgame. If a string came from a request, cookie, header, or any source you don’t fully control, don’t hand it to unserialize(). Full stop.
  • Use JSON instead: Swap serialize() and unserialize() for json_encode() and json_decode(). JSON only carries data, never PHP objects, so there’s no class to instantiate and no magic method to trigger.
  • Validate what you decode: Even with JSON, check the shape and types of what you got back before you trust it. Decoding safely isn’t the same as the data being correct.

Advanced Mitigation Techniques

When you genuinely can’t avoid unserialize(), PHP gives you a real lever.

1. Disable Object Deserialization

Since PHP 7.0, unserialize() takes an allowed_classes option. Set it to false and any serialized object comes back as an __PHP_Incomplete_Class instead of a live instance, so no constructors, no destructors, no magic methods run. That’s the single most effective guard when you must deserialize a value that might contain objects.

PHP
<?php
// No objects will be instantiated.
$data = unserialize( $input, [ 'allowed_classes' => false ] );
2. Implement a Whitelist of Allowed Classes

If you legitimately need specific classes back, pass an array of exactly those class names instead of false. Anything not on the list is downgraded to __PHP_Incomplete_Class, which shuts the door on attacker-chosen gadget classes. Keep the list as small as the code actually requires.

PHP
<?php
// Only User objects are allowed.
$data = unserialize( $input, [ 'allowed_classes' => [ 'User' ] ] );
3. Use Hardened Libraries

Reach for maintained serialization libraries and framework helpers that harden this path for you. Many restrict which classes can be built or refuse object deserialization outright, which saves you from hand-rolling the check on every call site.

Secure PHP Serialization Alternatives

The cleanest long-term move is to stop using PHP’s native serialization for data that crosses a trust boundary. Some options:

  • JSON: The default choice. It carries primitives, arrays, and objects-as-plain-data only, so there’s nothing for object injection to grab. Widely supported and easy to validate.
  • MessagePack: A compact binary format for primitives and arrays. Handy when payload size matters and you don’t need PHP objects.
  • Protocol Buffers: Google’s Protobuf gives you a schema-defined, language-neutral format. More setup, but strong typing and no arbitrary object instantiation.
Conclusion

Deserialization bugs are dangerous because they’re quiet: one trusting call, and an attacker gets to choose which objects your code builds. The defense is short. Keep untrusted data away from unserialize(), reach for json_decode() instead, and when you truly can’t, pass ['allowed_classes' => false] so no objects come back at all. Do that consistently and the whole class of POP-chain RCE loses its footing in your application.

Leave a Comment

Your email address will not be published. Required fields are marked *


Scroll to Top