Skip to content

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
<?php
$json = json_encode($data);  // Safe
$data = json_decode($json, true);  // Safe - returns array

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
<?php
unserialize($_POST['data']);  // NEVER DO THIS - RCE vulnerability!

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:

  1. unserialize($_POST['data']) → Switch to json_decode()
  2. unserialize($_COOKIE['session']) → Use signed/encrypted JSON
  3. phar:// file operations or metadata access → Disable protocol where possible and never process attacker-controlled phar metadata
  4. 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);
    }
}
# config/packages/framework.yaml
framework:
    serializer:
        enabled: true
        # Use JSON, not PHP serialize

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_classes as 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_functions to fix deserialization. It may reduce payload impact but does not remove unsafe object construction.

Dependencies and Installation

  • json_decode() and json_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.

Additional Resources