unserialize() Object Injection
unserialize() rebuilds objects from a serialised string. The instantiated
objects fire magic methods at predictable points — during
deserialisation and shortly after — without any explicit call from your
code. An attacker who controls the serialised blob picks:
- The class to instantiate (any class loaded in the autoloader at the time)
- The values of its properties
- The downstream magic-method execution path
Stitching those into a gadget chain that ends at system() / eval() /
arbitrary file write is the goal.
Why
PHP fires these without you asking:
| Method | Fires when |
|---|---|
__wakeup | Immediately during unserialize |
__unserialize | PHP 7.4+, replaces __wakeup if defined |
__destruct | When the object is garbage-collected |
__toString | When the object is cast to string |
__call / __callStatic | On undefined method call |
__get / __set | On undefined property access |
Any of these in any class the autoloader can reach is a candidate gadget.
Real chains stitch 4–8 of them across packages — phpggc ships pre-built
chains for the common frameworks and libraries.
Search patterns
rg -n 'unserialize\(' --type=php
# Highest-risk shapes
rg -n 'unserialize\(\s*\$_(GET|POST|REQUEST|COOKIE|SESSION)' --type=php
rg -n 'unserialize\(\s*base64_decode\(' --type=php
rg -n 'unserialize\(\s*\$_(GET|POST)\[' --type=php
# Indirect — input is decoded first
rg -n 'unserialize\(.*decrypt|unserialize\(.*decode' --type=phpAlso flag any framework cache / session driver that uses PHP serialisation on values an attacker could poison (Memcached, Redis, file-based cache).
Test inputs
Use PHPGGC (PHP Generic Gadget Chains) to generate payloads:
phpggc -l # list available chains
phpggc Laravel/RCE9 system id # build a Laravel RCE chain
phpggc -b -u Laravel/RCE9 system id # URL-encoded, base64'd
phpggc Symfony/RCE4 system id
phpggc Monolog/RCE6 system id
phpggc Doctrine/FW1 phpinfoFrameworks/libraries with public chains: Laravel, Symfony, Drupal, WordPress, Doctrine, Guzzle, Monolog, SwiftMailer, ZendFramework, CakePHP, Slim, Yii, ThinkPHP.
For a starting probe, the cheapest test is whether a recognisable error
appears when you send O:8:"stdClass":0:{} — if the application
deserialises it without complaint, the call exists.
Audit focus
For every unserialize():
- Source — attacker-controlled? Cookie, query, body, header, cached blob in Redis/Memcached/disk that an attacker can write to indirectly?
- Allowed classes — is the second argument used?
unserialize($s, ["allowed_classes" => false])neutralises gadgets by returning__PHP_Incomplete_Classfor any object. Available since PHP 7.0. - Class surface — what does the autoloader load at this call site?
composer.jsondependencies are the gadget surface. The more frameworks you include, the more chains exist. - Magic methods — does any class in scope define
__wakeup,__destruct,__toString,__call,__get? Even “harmless” logging/cleanup methods become RCE primitives when chained. - Second-order — does the application deserialise data it previously serialised but didn’t sign? Without an HMAC, the attacker can flip values in stored blobs.
__wakeup and __destruct fire on every instantiated object in the
chain — including objects nested inside arrays or other objects. The
gadget surface is the transitive closure of class definitions, not just
the top-level class.
Fix
Stop using unserialize on untrusted input. Switch to JSON:
$data = json_decode($input, true);If you absolutely must keep PHP serialisation:
$obj = unserialize($input, ["allowed_classes" => false]);Or ["allowed_classes" => [MySafeDTO::class]] for a small, audited set.
For serialised blobs you produce yourself and later read back, sign them:
$signed = base64_encode($payload) . '.' . hash_hmac('sha256', $payload, $key);
// verify HMAC before unserializeRelated
- PHAR Deserialization — same family,
no
unserializecall needed - Common Web Payloads