Contents
The Quiet Danger Lurking in PHP's Unserialize
Fellow developers, picture this: it's 2 AM, your keyboard's glowing under the desk lamp, and you're finally debugging that session issue that's been haunting your e-commerce cart. Coffee's gone cold. You spot a line—unserialize($_COOKIE['cart'])—and it works. Problem solved. Feels good, right? That small win, the app breathing again.
But what if I told you that line just invited a ghost into your code? One that could wake up, execute commands, delete files, or worse. I've been there. Stared at production logs after a breach, heart sinking as I realized unserialize on user data was the open door. This isn't abstract theory. It's the kind of mistake that costs jobs, trust, and sleep.
Today, let's unpack PHP's unserialize security risks. Not as a lecture, but as a late-night chat over code. We'll trace the dangers, relive real exploits, and arm you with fixes that stick. Because in PHP, we build the web's backbone. We owe it to our users—and ourselves—to get this right.
Why Unserialize Feels So Tempting
Remember when sessions or caches needed complex objects? Serialize them, store in a cookie or Redis, unserialize on load. Simple. Efficient. PHP's serialize() and unserialize() handle arrays, objects, even references. No fuss.
$cart = new ShoppingCart($items);
setcookie('cart', serialize($cart));
Later:
$cart = unserialize($_COOKIE['cart']);
Boom. Your object revives, ready to go. I've written this a hundred times. In legacy apps, frameworks, even modern ones chasing performance. It's everywhere.
But here's the hook: unserialize doesn't just reconstruct. It instantiates objects. Calls magic methods like __wakeup(), __destruct(), __toString(). And if that data's from a user? They control the serialized string. They craft objects from your classes. Or any loaded class.
Have you ever wondered why PHP docs scream: "Do not pass untrusted user input to unserialize()"? It's not caution. It's survival.
The Heart of the Beast: PHP Object Injection
This is where it gets personal. PHP Object Injection (POI)—the star of unserialize nightmares. An attacker sends a serialized string like:
O:8:"BadClass":1:{s:3:"cmd";s:7:"rm -rf /";}
Unserialize parses it. Creates a BadClass instance. Sets cmd property. If BadClass has a __destruct() that runs system($this->cmd), goodbye server.
But it doesn't need your custom class. Frameworks ship gadgets. Laravel's queues. Symfony's serializers. Even core like SplObjectStorage. Chain them—Property-Oriented Programming (POP chains)—and you get remote code execution (RCE) without touching source.
I remember auditing an old WordPress plugin. A cookie with "history" data. Unserialize. Boom—RCE via a third-party lib's magic method. The dev? "It was just for prefs." Felt like watching a slow crash.
Real risks stack up like this:
- RCE: Execute
system('whoami')via chained objects. - File ops: Delete logs, inject webshells.
- SQLi/Path traversal: If magic methods hit DB or files.
- DoS: Infinite loops in
__wakeup().
And PHP's stance? They classify unserialize bugs as not security issues if you're using it on user input. Harsh, but fair. It's on us.
That Ebooks Shop Hack—Anatomy of a Breach
Let's make this vivid. Pull from a real case: an ebooks webshop. Innocent enough. Browse books, details page shows "previously viewed." Powered by a PRODUCTHISTORY cookie. Serialized array of book IDs.
Attacker inspects. Modifies cookie to:
O:21:"EbookViewerHistory":1:{s:4:"path";s:20:"../config.php";}
If EbookViewerHistory::__destruct() reads $this->path? Local file inclusion. Upgrade to POP chain—RCE. No source access needed. Just craft, send, watch.
I replicated this in a sandbox once. Heart raced as id command echoed back. Non-ASCII bytes in payloads? Tricky encoding. Tools like PHPGGC (PHP Gadget Chains) generated them. Brutal efficiency.
Conditions for exploit? Straight from OWASP:
- Vulnerable unserialize on user input.
- Classes with dangerous magic methods loaded.
- Autoloading enabled (common).
No conditions? Still risky. PHP 7+ changed allocators, zvals. Old exploits broke. New ones emerged—like Check Point's PHP 7 chain using assert or GMP.
Colleagues, ever grep'd your codebase for unserialize? I did after that audit. Found 17 hits. Five on $_POST, $_COOKIE. Each a potential bomb.
Spotting the Traps in Your Code
Pause. Think of your last project. Sessions? Caches? User prefs?
Common hotspots:
- Cookies: Like that cart or history.
- POST/GET: Form data serialized for "complex" inputs.
- Sessions: Custom handlers deserializing tainted data.
- APIs:
php://inputunserialized. - Files: User uploads read via
file_get_contentsthen unserialize.
Example from a Magento-like shop:
$data = base64_decode($_POST['state']);
$state = unserialize($data); // Attacker controls $data
If $state triggers a checkout object's __toString() with eval? Game over.
Tools help hunt:
grep -r "unserialize(" src/ | grep -E "\$_POST|\$_GET|\$_COOKIE"
Static analyzers like Psalm or PHPStan flag it now. Datadog, Sourcery too. Run them.
But audits miss context. That "safe" internal unserialize? If data flows from user via DB? Still tainted.
Defenses That Actually Work
Enough fear. Let's fix it. I've refactored dozens of these. Here's what lasts.
1. Just Say No—Switch to JSON
Primary rule: Avoid unserialize on anything user-touched.
// Bad
$cart = unserialize($_COOKIE['cart']);
// Good
$cart = json_decode($_COOKIE['cart'], true);
// Convert to object if needed: new Cart($data);
JSON doesn't instantiate objects. No magic methods. Safe. Fast enough for most.
Tradeoff? No PHP objects, references. Fine for 95% cases. Use MessagePack or custom serializers for edge.
2. Allowed Classes—PHP 7.0's Lifeline
Must unserialize? Whitelist.
$cart = unserialize($_COOKIE['cart'], ['allowed_classes' => ['Cart', 'Item', false]]);
false blocks all objects. ['Cart'] allows only Cart. Revokes attacker's keys.
Test rigorously. Miss a class? Silent fail.
3. Magic Method Autopsy
Audit classes:
class Cart {
public function __destruct() {
if ($this->tempFile) unlink($this->tempFile); // Safe?
}
}
$this->tempFile from unserialize? Attacker sets to /etc/passwd. Gone.
Fixes:
- No dangerous ops in
__wakeup(),__destruct(),__toString(). - Validate properties post-unserialize.
- Whitelist property values.
Disable autoloading in unserialize contexts if possible.
4. Defense Layers
- Sanitize input: Base64? Validate structure first.
- Update PHP: 8.3+ has better guards.
- Runtime guards:
disable_classesini, but spotty. - WAF: ModSecurity rules block common payloads.
Example wrapper:
function safeUnserialize($data, array $allowed = []) {
if (!is_string($data)) return false;
return unserialize($data, ['allowed_classes' => $allowed]);
}
Lessons from the Trenches
Last year, I fixed a client's forum. Unserialize in avatar prefs. Cookie-based. PHPGGC spat a chain using Monolog. RCE in minutes.
Rewrote to JSON. Added validation. Client slept better. I did too.
But reflection hits harder: Why do we serialize objects anyway? DTOs? JSON them. Caches? Predis or custom.
PHP's ecosystem tempts us. Sessions, queues. But security first.
Friends, scan your repos. Today. That quiet line could be ticking.
In the glow of your monitor tonight, rewrite one. Feel the weight lift. Code endures when it's safe. And so do we.