These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP Securityunserialize() Object Injection

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:

  1. The class to instantiate (any class loaded in the autoloader at the time)
  2. The values of its properties
  3. 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:

MethodFires when
__wakeupImmediately during unserialize
__unserializePHP 7.4+, replaces __wakeup if defined
__destructWhen the object is garbage-collected
__toStringWhen the object is cast to string
__call / __callStaticOn undefined method call
__get / __setOn 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=php

Also 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 phpinfo

Frameworks/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():

  1. Source — attacker-controlled? Cookie, query, body, header, cached blob in Redis/Memcached/disk that an attacker can write to indirectly?
  2. Allowed classes — is the second argument used? unserialize($s, ["allowed_classes" => false]) neutralises gadgets by returning __PHP_Incomplete_Class for any object. Available since PHP 7.0.
  3. Class surface — what does the autoloader load at this call site? composer.json dependencies are the gadget surface. The more frameworks you include, the more chains exist.
  4. Magic methods — does any class in scope define __wakeup, __destruct, __toString, __call, __get? Even “harmless” logging/cleanup methods become RCE primitives when chained.
  5. 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 unserialize