Skip to content

CWE-502: Insecure Deserialization - 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:10:"/etc/passwd";}
// This would write to /etc/passwd!

Why this is vulnerable:

  • Magic methods run automatically during lifecycle events.
  • Attacker controls object properties in serialized data.
  • Enables file writes, queries, or command execution.
  • No explicit invocation is required.

Session Deserialization

<?php
// VULNERABLE - Session handler mismatch
ini_set('session.serialize_handler', 'php_serialize');
session_start();

// If attacker controls session data format, can inject objects

Why this is vulnerable:

  • Handler mismatches allow object injection into sessions.
  • Session load triggers deserialization automatically.
  • Magic methods can execute on load or destroy.
  • Attackers can persist payloads across requests.

Secure Patterns

Use JSON Instead of Serialize

<?php
// SECURE - JSON cannot execute code or instantiate classes
class User {
    public $name;
    public $email;
    public $age;
}

// 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.
  • Treats input as data-only primitives and objects.
  • 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 (returns array/stdClass only):
$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().

msgpack for Binary Serialization

<?php
// SECURE - MessagePack is safe binary format
// Install: composer require msgpack/msgpack

$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: composer require msgpack/msgpack

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
// BEFORE (Unsafe)
$user = unserialize($_COOKIE['user']);

// AFTER (Safe)
$userData = json_decode($_COOKIE['user'], true);
$user = new User(
    $userData['name'],
    $userData['email'],
    $userData['age']
);

Secure alternative with allowed_classes (PHP 7.0+): If you MUST use unserialize, use allowlist:

<?php
// Only allow specific classes
$user = unserialize($data, [
    'allowed_classes' => ['User', 'Address']
]);

// Disallow all classes (returns array/stdClass only)
$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, Laravel uses encrypted cookies (secure)
// config/session.php
return [
    'driver' => 'cookie',  // Uses encryption, not serialization
    'encrypt' => true,     // Always encrypt session data
];

// 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

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
session.serialize_handler = php  ; Default, consistent
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');

Remediation Steps

  1. Locate every deserialization sink, especially unserialize(), session serializers, cache/job payloads, Phar metadata access, and third-party packages that wrap these APIs.
  2. Identify whether the data can be influenced by users, clients, queues, files, cookies, or other services across a trust boundary.
  3. Replace unserialize() for untrusted data with json_decode() into arrays or DTO-style objects, followed by explicit validation.
  4. If legacy serialized data must be read temporarily, use allowed_classes as a stopgap and plan migration away from PHP object serialization.
  5. Remove or tightly constrain file paths and stream wrappers that can reach phar://, and avoid explicit metadata access on untrusted Phar archives.
  6. Add signing only for integrity of data-only formats; do not use signatures as a reason to keep accepting arbitrary serialized objects from external sources.

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