PHP Object Injection: unserialize() and the Magic-Method Chain
Calling unserialize() on attacker-controlled input instantiates arbitrary classes and fires their magic methods automatically. Here is how a POP gadget chain rides __wakeup and __destruct to a dangerous sink, and why json_decode is the fix.
O:4:"User":1:{s:4:"name";...}On this page
PHP serialization turns an object into a string with serialize(), and unserialize() rebuilds it on the other side. The trap is that unserialize() does not just parse data — it instantiates whatever classes the string names and runs their magic methods as part of reconstruction, including classes you never intended to receive. The moment you call it on input an attacker controls, you have handed them the ability to materialise objects of your choosing inside your process, and a chain of those magic methods turns that into remote code execution.
This is PHP Object Injection, and it has been a fixture of the OWASP Top 10 for years. This post shows how an untrusted serialized string reaches a dangerous sink during unserialize(), why tampering with a single property is enough, and how a data-only format closes the hole.
How it happens
The root cause is treating a serialized string from a cookie, a hidden form field or an API body as if it were safe data. A common shape stores object state on the client and rehydrates it on the next request:
function load(): string { $blob = base64_decode($_COOKIE['state'] ?? ''); $user = unserialize($blob); return "Welcome back, " . $user->name; }
The developer assumes the cookie still holds the User the server wrote. But it is sent by the client, so an attacker can replace it with any serialized string they like — and unserialize() will instantiate whatever class that string names, firing its magic methods in the process.
You can recognise the format on sight. A serialized object is O:4:"User":1:{s:4:"name";s:5:"admin";} — O for object, the class name and length, then a count of properties and each typed key/value:
POST /api/session HTTP/1.1
Content-Type: application/x-www-form-urlencoded
state=Tzo0OiJVc2VyIjoxOntzOjQ6Im5hbWUiO3M6NToiYWRtaW4iO30=
Because every field is laid out in plain text, an attacker can flip s:5:"guest" to s:5:"admin", change a numeric role, or swap the class name entirely to one with a useful destructor.
A concrete attack
An attacker does not need a vulnerable class of yours. They reach into the classes already loaded by your application and wire their magic methods into a POP chain — property-oriented programming, where attacker-chosen property values steer one magic method into the next until the chain reaches a sink. A logger whose __destruct flushes a buffer to a path, holding a "formatter" whose __toString runs a callback, is enough:
class Logger { public $file; public $data; public function __destruct() { file_put_contents($this->file, $this->data); } } $payload = new Logger(); $payload->file = "/var/www/html/shell.php"; $payload->data = "<?php system($_GET['c']); ?>"; echo base64_encode(serialize($payload));
The attacker drops that string into the state cookie. When unserialize() rebuilds the object and the request ends, PHP calls __destruct automatically — writing a web shell into the document root, all before any of your own code inspects the result. __wakeup fires the same way during reconstruction, and __toString triggers the instant the object is concatenated into a string. The attacker has gone from "I can set a cookie" to "I run commands on your server."
The string told
unserialize()which class to build; your code never got a vote. This is not a parser bug —unserialize()is doing exactly what it was designed to do, on input it should never have been allowed to read.
It gets worse: the sink need not be an obvious unserialize() call. A phar:// archive carries a serialized metadata blob, and ordinary filesystem functions — file_exists, fopen, getimagesize — deserialize that metadata when handed a phar:// path, so an attacker who can influence a filename can trigger the same chain with no visible unserialize() anywhere.
Why it matters
Because the object is built and its magic methods run inside the application process, the impact is severe:
- Remote code execution — arbitrary commands on the server, the usual end state of a POP chain.
- Authentication bypass and privilege escalation — forging an object that represents a logged-in or admin user.
- Arbitrary file write or delete — destructors that flush to attacker-chosen paths.
- Full server compromise — RCE leads directly to data theft, persistence and pivoting.
The fix: do not unserialize untrusted data
The only fully reliable rule is to never hand attacker-controlled input to unserialize(). Prefer a data-only format — JSON parsed into an explicit shape — so rebuilding a value can never instantiate a class or fire a magic method:
function load(): string { $data = json_decode($_COOKIE['state'] ?? '{}', true); $name = is_string($data['name'] ?? null) ? $data['name'] : ''; if (!ctype_alnum($name)) { http_response_code(400); exit; } return "Welcome back, " . $name; }
json_decode produces plain strings, numbers and arrays — never an arbitrary object whose construction runs code. Where unserialize() is genuinely unavoidable, pass the allowed_classes option so the parser refuses to instantiate anything unexpected:
$value = unserialize($blob, ['allowed_classes' => false]);
['allowed_classes' => false] turns every object in the stream into a harmless __PHP_Incomplete_Class, and a tight allowlist such as ['allowed_classes' => [Money::class]] admits only the types you actually expect. Keep client-held state minimal and sign it with an HMAC so it cannot be tampered with at all, and treat every serialized string crossing a trust boundary as hostile.
How SelfSec finds it
SelfSec's crawler locates the parameters, cookies, JSON bodies and form fields that feed a deserializer, then submits known serialized markers — PHP object and array structures (O: and a: payloads), Java's serialized base64 preamble, YAML python-object tags and fastjson type directives — and watches for deserialization-specific errors such as ClassNotFoundException, InvalidClassException and autoType that reveal an unguarded sink. Matched evidence is rescored to separate a genuinely exploitable deserializer from incidental error output, so findings are confirmed rather than guessed. The entire scan runs locally on 127.0.0.1, your scan data never leaves your machine, and every finding ships with a reproduction request you can replay.
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about Insecure Deserialization
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.