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
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
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:
- unserialize($_POST['data']) → Switch to json_decode()
- unserialize($_COOKIE['session']) → Use signed/encrypted JSON
- phar:// file operations → Disable protocol with
stream_wrapper_unregister('phar') - 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');