CWE-502: Deserialization of Untrusted Data - PHP
Overview
PHP's unserialize() function can instantiate arbitrary classes and invoke magic methods (__wakeup(), __destruct(), __toString()) during deserialization, allowing attackers to achieve remote code execution through "gadget chains" or property-oriented programming (POP).
Primary Defence: Use json_decode() for untrusted data deserialization instead of unserialize(). If you must use unserialize(), use allowed_classes as a last-resort mitigation (PHP 7.0+).
Common Vulnerable Patterns
unserialize() with User Input
<?php
// VULNERABLE - Never unserialize untrusted data!
$user_data = $_COOKIE['user'];
$user = unserialize($user_data); // RCE vulnerability!
// VULNERABLE - POST data
$obj = unserialize($_POST['data']);
// VULNERABLE - Query parameter
$config = unserialize($_GET['config']);
Why this is vulnerable:
- Instantiates arbitrary classes from untrusted input.
- Triggers magic methods like
__wakeup()and__destruct(). - Enables gadget chains for file writes or RCE.
- Occurs before application-level validation.
Phar Metadata Deserialization
<?php
// VULNERABLE on PHP < 8.0 - Phar metadata can trigger unserialization
$filename = $_GET['file'];
// On older PHP versions, these functions could trigger phar metadata unserialization:
file_get_contents('phar://' . $filename);
file_exists('phar://' . $filename);
is_dir('phar://' . $filename);
// PHP 8.0+ no longer automatically unserializes phar metadata
// during stream-wrapper file operations, but explicit metadata access
// can still deserialize metadata:
$phar = new Phar($filename);
$metadata = $phar->getMetadata();
Why this is vulnerable:
- In PHP versions before 8.0,
phar://file operations could automatically deserialize phar metadata. - In PHP 8.0+, metadata is deserialized when
Phar::getMetadata()is called. - Metadata can contain attacker-controlled serialized PHP objects.
- Enables RCE via attacker-controlled archives.
Magic Methods Exploitation
<?php
// VULNERABLE - Magic methods execute during unserialize
class Logger {
private $logFile;
public function __destruct() {
// Executes when object is destroyed
file_put_contents($this->logFile, "Log entry");
}
}
// Attacker crafts:
// O:6:"Logger":1:{s:7:"logFile";s:11:"/etc/passwd";}
// This writes to /etc/passwd when the object is destroyed
Why this is vulnerable:
- Magic methods run automatically during lifecycle events, with no call from your code.
- Attacker controls object properties in serialized data.
- Enables file writes or command execution.
Two details matter if you reproduce this. The s: prefix carries a byte count
that unserialize() enforces exactly: /etc/passwd is eleven bytes, and
s:10: makes the whole call return false without ever setting the property -
which is how a live gadget gets mistaken for a safe one. And private does not
protect the property here. PHP's own serialize() emits a private property
under a null-byte-mangled name (s:15:"\0Logger\0logFile"), but unserialize()
accepts the plain name too: verified on PHP 8.5.8, the payload above populates
Logger::$logFile and __destruct() sees /etc/passwd.
Session Deserialization
<?php
// VULNERABLE - two entry points reading the same session store with
// different serialize handlers
// admin.php (or an upload endpoint, a cron script, a second vhost)
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['name'] = $_POST['display_name']; // attacker-controlled string
// index.php - same session store, PHP's default handler
session_start(); // session.serialize_handler = php
// Reading the session now unserializes whatever followed a "|" in that string
Why this is vulnerable: The weakness is the mismatch, not either handler
on its own. The php handler encodes a session as key|<serialized value> and
splits on the first | when reading. The php_serialize handler serializes
the whole $_SESSION array instead, so a | inside a value is just a
character. Write with one and read with the other and the php reader treats
the part of the attacker's value after the | as a serialized object and
unserializes it - no unserialize() call anywhere in your code. Verified on PHP
8.5.8: a display_name of
|O:4:"Evil":1:{s:1:"x";s:5:"pwned";} runs Evil::__wakeup() when the session
is read back under the php handler.
The fix is to make the setting identical everywhere the store is read and
written - every vhost, CLI script, cron job and worker - and to prefer
php_serialize, which has no delimiter to inject into. Do not conclude from
this example that php_serialize is the dangerous one; it is the handler that
does not have the parsing flaw.
Secure Patterns
Use JSON Instead of Serialize
<?php
// SECURE - JSON cannot execute code or instantiate classes. The examples
// below require this class as 'user.php'.
// The constructor is what makes the positional new User($name, $email, $age)
// in the examples below work. Without one PHP accepts those arguments,
// discards them, and leaves every property at its default - which reads as a
// working conversion and produces an empty object.
class User {
public function __construct(
public string $name = '',
public string $email = '',
public int $age = 0,
) {}
}
// Serialize to JSON
$user = new User();
$user->name = 'John';
$user->email = 'john@example.com';
$user->age = 30;
$json = json_encode($user);
// {"name":"John","email":"john@example.com","age":30}
// Deserialize from JSON
$data = json_decode($json, true); // Returns associative array
// Manually reconstruct object
$restoredUser = new User();
$restoredUser->name = $data['name'];
$restoredUser->email = $data['email'];
$restoredUser->age = $data['age'];
// Or use json_decode with stdClass:
$obj = json_decode($json); // Returns stdClass, safe
Why this works:
- Produces arrays/stdClass only, no class instantiation.
- No magic methods run during JSON parsing.
- Forces explicit reconstruction with validation.
- Eliminates PHP object injection chains.
Type Hinting with JSON
<?php
// SECURE - Type-safe deserialization
class User {
public function __construct(
public string $name,
public string $email,
public int $age
) {}
public static function fromJson(string $json): self {
$data = json_decode($json, true);
if (!is_array($data)) {
throw new InvalidArgumentException('Invalid JSON');
}
return new self(
$data['name'] ?? '',
$data['email'] ?? '',
$data['age'] ?? 0
);
}
public function toJson(): string {
return json_encode([
'name' => $this->name,
'email' => $this->email,
'age' => $this->age
]);
}
}
// Usage:
$user = new User('John', 'john@example.com', 30);
$json = $user->toJson();
$restored = User::fromJson($json);
Why this works:
json_decode()returns arrays, not objects.- Factory method validates structure before construction.
- Constructor type hints enforce runtime types.
- Prevents type juggling and confusion attacks.
- Separates parsing from trusted object creation.
Allowed Classes Allowlist (PHP 7.0+)
<?php
// MITIGATION - Allowlist allowed classes (still risky for untrusted input)
$data = $_COOKIE['user'];
// Only allow specific classes to be unserialized
$user = unserialize($data, [
'allowed_classes' => ['User', 'Address']
]);
// To disallow all classes:
$safe_data = unserialize($input, ['allowed_classes' => false]);
Why this helps:
- Allowlists specific classes during
unserialize(). - Blocks unknown classes and some gadget chains.
- Can disable class instantiation entirely.
- Still unsafe if allowed classes have dangerous magic methods.
- Use only as a stopgap while migrating away from
unserialize().
Know what a blocked object actually becomes, because it is not what most
descriptions of this option suggest. Verified on PHP 8.5.8, a disallowed class
is not skipped and does not become a stdClass: it comes back as a
__PHP_Incomplete_Class, and reading any property off one raises
The script tried to access a property on an incomplete object. stdClass
itself is blocked too, so ['allowed_classes' => false] on a payload of plain
objects yields incomplete objects rather than usable data. Arrays and scalars
pass through unchanged. If you need object-free data, json_decode() is the
call that gives it to you; allowed_classes gives you an object you cannot
read, which is safe but is not the same thing.
msgpack for Binary Serialization
<?php
// SECURE - MessagePack is safe binary format
// msgpack_pack()/msgpack_unpack() come from the PECL extension:
// pecl install msgpack
// There is no "msgpack/msgpack" Composer package. If you cannot install a
// PECL extension, use the pure-PHP composer require rybakit/msgpack instead,
// which exposes Packer/BufferUnpacker objects rather than these functions.
// The plain data class from the section above, as its own file
require_once 'user.php';
$user = [
'name' => 'John',
'email' => 'john@example.com',
'age' => 30
];
// Serialize
$packed = msgpack_pack($user);
// Deserialize (returns array, cannot instantiate classes)
$unpacked = msgpack_unpack($packed);
// Manually create object
$userObj = new User(
$unpacked['name'],
$unpacked['email'],
$unpacked['age']
);
Why this works:
- Data-only format with no PHP object metadata.
msgpack_unpack()returns arrays, not objects.- No magic methods are invoked on parse.
- Requires manual reconstruction and validation.
- Safe binary alternative to
serialize().
PHP Library Safety Matrix
When reviewing code, use this matrix to identify unsafe deserialization libraries:
Safe Alternatives
json_encode() / json_decode()
- Use instead of serialize/unserialize for all use cases
- Cannot execute code or instantiate classes
- Only creates stdClass, arrays, and basic types
msgpack (MessagePack)
- Safe binary serialization format
- Fast and compact
- Cannot instantiate classes
<?php
$packed = msgpack_pack($data); // Safe
$data = msgpack_unpack($packed); // Safe - returns array
// Install: pecl install msgpack (these functions ship with the PECL extension)
NEVER Use with Untrusted Data
unserialize()
- Allows arbitrary object instantiation
- Invokes magic methods (
__wakeup(),__destruct(),__toString()) - Replace with json_decode() for untrusted data
phar:// protocol
- PHP < 8.0: file operations on
phar://could trigger metadata deserialization - PHP 8.0+: metadata deserialization happens through explicit metadata access such as
Phar::getMetadata() - Block phar:// protocol where not needed, validate paths, and do not read metadata from untrusted archives
<?php
file_exists('phar://' . $_GET['file']); // Unsafe in PHP < 8.0 with attacker-controlled phar files
stream_wrapper_unregister('phar'); // Disable phar:// protocol
Migration Considerations
If you find these patterns in security scan results:
- unserialize($_POST['data']) → Switch to json_decode()
- unserialize($_COOKIE['session']) → Use signed/encrypted JSON
- phar:// file operations or metadata access → Disable protocol where possible and never process attacker-controlled phar metadata
- serialize() for sessions → Use JSON for session storage
Example migration:
<?php
// The plain data class from the section above, as its own file
require_once 'user.php';
// BEFORE (Unsafe)
$user = unserialize($_COOKIE['user']);
// AFTER (Safe)
$userData = json_decode($_COOKIE['user'], true);
$user = new User(
$userData['name'],
$userData['email'],
$userData['age']
);
Stopgap with allowed_classes (PHP 7.0+):
While unserialize() is still in place, restrict the classes it may instantiate:
<?php
// Only allow specific classes
$user = unserialize($data, [
'allowed_classes' => ['User', 'Address']
]);
// Disallow all classes. Objects in the payload come back as
// __PHP_Incomplete_Class, not stdClass - see the note above.
$safeData = unserialize($input, ['allowed_classes' => false]);
Framework-Specific Guidance
Laravel
<?php
// SECURE - Laravel uses JSON by default
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
class UserController extends Controller
{
public function store(Request $request)
{
// Laravel automatically deserializes JSON from request
$validated = $request->validate([
'name' => 'required|max:100',
'email' => 'required|email',
'age' => 'required|integer|min:0|max:150'
]);
// Create user from validated data
$user = User::create($validated);
return response()->json($user);
}
public function show($id)
{
$user = User::findOrFail($id);
// Laravel automatically serializes to JSON
return response()->json($user);
}
}
// For sessions, the cookie driver encrypts the payload - it does not stop serializing it
// config/session.php
return [
'driver' => 'cookie', // Payload is serialized, then encrypted
'encrypt' => true, // Encrypted AND authenticated with APP_KEY - the MAC is what rejects a tampered payload
];
// For caching, use JSON:
use Illuminate\Support\Facades\Cache;
// Store
Cache::put('user:' . $id, json_encode($userData));
// Retrieve
$json = Cache::get('user:' . $id);
$userData = json_decode($json, true);
Symfony
<?php
// SECURE - Symfony Serializer Component
namespace App\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\SerializerInterface;
class UserController extends AbstractController
{
public function create(Request $request, SerializerInterface $serializer): JsonResponse
{
$json = $request->getContent();
// Deserialize JSON to User object (safe)
$user = $serializer->deserialize(
$json,
User::class,
'json'
);
// Validate and save...
// Serialize response
$jsonResponse = $serializer->serialize($user, 'json');
return new JsonResponse($jsonResponse, 201, [], true);
}
}
Input Validation
<?php
// Validate after JSON deserialization
// The plain data class from the section above, as its own file
require_once 'user.php';
class UserValidator {
public static function validate(array $data): array {
$errors = [];
if (empty($data['name']) || !is_string($data['name'])) {
$errors[] = 'Name is required and must be a string';
} elseif (strlen($data['name']) > 100) {
$errors[] = 'Name too long';
}
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Valid email is required';
}
if (!isset($data['age']) || !is_int($data['age'])) {
$errors[] = 'Age must be an integer';
} elseif ($data['age'] < 0 || $data['age'] > 150) {
$errors[] = 'Age must be between 0 and 150';
}
return $errors;
}
}
// Usage:
$json = file_get_contents('php://input');
$data = json_decode($json, true);
$errors = UserValidator::validate($data);
if (!empty($errors)) {
http_response_code(400);
echo json_encode(['errors' => $errors]);
exit;
}
$user = new User($data['name'], $data['email'], $data['age']);
Signature Verification
<?php
// SECURE - Verify HMAC before deserializing
class SignedSerializer {
private string $secretKey;
public function __construct(string $secretKey) {
$this->secretKey = $secretKey;
}
public function serialize(array $data): string {
$json = json_encode($data, JSON_THROW_ON_ERROR);
$signature = hash_hmac('sha256', $json, $this->secretKey);
// Return signature + data
return $signature . '.' . base64_encode($json);
}
public function deserialize(string $signedData): array {
$parts = explode('.', $signedData, 2);
if (count($parts) !== 2) {
throw new InvalidArgumentException('Invalid signed data format');
}
[$signature, $encodedJson] = $parts;
$json = base64_decode($encodedJson, true);
if ($json === false) {
throw new InvalidArgumentException('Invalid encoded payload');
}
// Verify signature
$expectedSignature = hash_hmac('sha256', $json, $this->secretKey);
if (!hash_equals($expectedSignature, $signature)) {
throw new UnexpectedValueException('Invalid signature');
}
// Only deserialize if signature valid
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
}
// Usage:
$serializer = new SignedSerializer($_ENV['SIGNED_SERIALIZER_KEY']);
$data = ['user' => 'john', 'role' => 'admin'];
$signed = $serializer->serialize($data);
// Later...
try {
$restored = $serializer->deserialize($signed);
} catch (InvalidArgumentException | UnexpectedValueException | JsonException $e) {
die('Invalid or tampered payload');
}
Preventing Phar Metadata Deserialization
<?php
// Validate file operations to prevent phar:// attacks
function safe_file_operation(string $filename): string {
// Block phar:// wrapper before resolving paths
$scheme = parse_url($filename, PHP_URL_SCHEME);
if ($scheme !== null && strtolower($scheme) === 'phar') {
throw new InvalidArgumentException('Phar protocol not allowed');
}
// Allowlist allowed paths
$allowedDir = realpath('/var/www/uploads');
$realPath = realpath($filename);
if ($allowedDir === false ||
$realPath === false ||
!str_starts_with($realPath, $allowedDir . DIRECTORY_SEPARATOR)) {
throw new InvalidArgumentException('Invalid file path');
}
return file_get_contents($realPath);
}
// Disable phar at runtime if your application does not need it (bootstrap):
stream_wrapper_unregister('phar');
PHP Configuration Security
; php.ini security settings
; Disable dangerous functions
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
; Session security
; php_serialize has no "|" delimiter to inject into, unlike the `php` default.
; The value that matters is that it is the SAME everywhere the session store is
; read or written - a mismatch is the object-injection path shown above.
session.serialize_handler = php_serialize
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Strict
; Disable phar deserialization in bootstrap code:
; stream_wrapper_unregister('phar');
Testing
- Test normal JSON payloads and confirm they deserialize into expected arrays or value objects.
- Test serialized-object payloads such as
O:...strings and confirm they are rejected at the boundary. - Test tampered signed payloads, invalid base64, invalid JSON, and expired or missing signatures where applicable.
- Test
phar://paths, mixed-case schemes, symlinks, and path-prefix tricks against file operations. - Exercise stored payload paths such as sessions, cache entries, queue messages, and uploaded files, not only direct HTTP parameters.
- Re-run static analysis and dependency scans for
unserialize(),Phar::getMetadata(), and known gadget-chain libraries.
Common Pitfalls
- Treating
allowed_classesas a complete fix. It reduces class exposure but does not make object serialization safe for arbitrary input. - Signing a PHP serialized object and then accepting it from an external trust boundary indefinitely.
- Blocking direct
unserialize()calls but leaving session, cache, queue, or Phar metadata paths reachable. - Validating data after
unserialize(); magic methods may already have executed. - Allowing
phar://through generic file APIs or path normalization helpers. - Depending on
disable_functionsto fix deserialization. It may reduce payload impact but does not remove unsafe object construction.
Dependencies and Installation
json_decode()andjson_encode()are built into PHP and should be the default for data-only payloads.unserialize($data, ['allowed_classes' => ...])requires PHP 7.0+ and should be treated as a migration aid, not a primary design.- Keep frameworks, Composer packages, and autoloaded libraries current because gadget chains often depend on available classes.
- Use a secret manager or environment-backed key source for HMAC keys; do not hardcode signing keys in source.