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 Deserialization

<?php
// VULNERABLE - Phar files trigger unserialization
$filename = $_GET['file'];

// These functions can trigger phar:// deserialization:
file_get_contents('phar://' . $filename);
file_exists('phar://' . $filename);
is_dir('phar://' . $filename);
// Many more file functions vulnerable!

Why this is vulnerable:

  • phar:// auto-deserializes metadata on access.
  • Triggers magic methods without explicit unserialize().
  • Many file APIs can invoke it implicitly.
  • 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

  • Triggers deserialization when accessing phar files
  • Many file functions vulnerable: file_exists(), file_get_contents(), is_dir(), etc.
  • Block phar:// protocol or validate paths
<?php
file_exists('phar://' . $_GET['file']);  // Triggers unserialize - UNSAFE!
stream_wrapper_unregister('phar');  // Disable phar:// protocol

Migration Recommendations

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 → Disable protocol with stream_wrapper_unregister('phar')
  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);
        $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);

        // Verify signature
        $expectedSignature = hash_hmac('sha256', $json, $this->secretKey);

        if (!hash_equals($expectedSignature, $signature)) {
            throw new SecurityException('Invalid signature');
        }

        // Only deserialize if signature valid
        return json_decode($json, true);
    }
}

// Usage:
$serializer = new SignedSerializer('your-secret-key-here');

$data = ['user' => 'john', 'role' => 'admin'];
$signed = $serializer->serialize($data);

// Later...
try {
    $restored = $serializer->deserialize($signed);
} catch (SecurityException $e) {
    die('Tampered data detected');
}

Preventing Phar Deserialization

<?php
// Validate file operations to prevent phar:// attacks

function safe_file_operation(string $filename): string {
    // Block phar:// wrapper
    if (strpos($filename, 'phar://') !== false) {
        throw new InvalidArgumentException('Phar protocol not allowed');
    }

    // Allowlist allowed paths
    $allowedDir = '/var/www/uploads/';
    $realPath = realpath($filename);

    if ($realPath === false || strpos($realPath, $allowedDir) !== 0) {
        throw new InvalidArgumentException('Invalid file path');
    }

    return file_get_contents($realPath);
}

// Disable phar at runtime (bootstrap):
stream_wrapper_unregister('phar');

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use safe deserialization APIs and reject unsafe formats
  • Static analysis: Use security scanners to verify no unsafe deserialization patterns remain
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

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');

Additional Resources