Unsafe deserialization

Object injection through untrusted PHP unserialize input

Description

PHP's unserialize() reconstructs scalar values, arrays, and objects from the serialize() format. Restoring an object can trigger autoloading and automatically invoke its __unserialize() or __wakeup() method. If an attacker controls serialized bytes through a request, cookie, header, or other external input, gadget chains in available classes may enable state changes, file operations, or code execution.

The PHP manual warns against passing untrusted user input to unserialize(), regardless of allowed_classes. allowed_classes => false or a narrow class allow-list can limit object restoration as defense in depth, but does not make hostile serialized data a safe interchange format.

Why it is dangerous

  • PHP serialization represents object classes and property state as well as ordinary data.
  • Autoloading and magic methods may run during restoration, and available library classes can become attack gadgets.
  • Base64 decoding, format checks, and validation after deserialization do not eliminate the risk before object creation.
  • max_depth limits resource exhaustion from nesting; it does not prevent object injection.
  • HMAC can detect changes by parties without the key, but cannot make a malicious or compromised authorized producer's payload safe.

Potential impact

  • Arbitrary code or command execution with the application's privileges.
  • File creation, deletion, or modification and access to internal services.
  • Manipulation of authentication, authorization, or other application state.
  • Denial of service through exceptions, deep structures, or oversized payloads.

Remediation

  1. Remove unserialize() from paths reachable by untrusted input.
  2. Decode external data using a data-only format such as JSON. Enforce an endpoint-specific byte limit before decoding, then check the exact top-level type, required and extra fields, lengths, ranges, and domain constraints.
  3. Copy only validated fields into a new array or value object. Do not pass the decoded or validated result back into unserialize().
  4. Migrate legacy PHP serialized records to a data-only format in a controlled offline process, separate from network requests. If legacy records must be read temporarily, authorize their producer and verify an HMAC over the exact stored bytes with a server-held key before decoding.
  5. Treat allowed_classes, max_depth, least privilege, and isolation as defense in depth for already trusted legacy data, not a permanent fix for external input.

Examples

Before

php
<?php
$payload = $_POST['payload'] ?? '';
$profile = unserialize($payload);

If payload contains a serialized object, available classes and magic methods can participate in its restoration.

After

php
<?php
function parseProfile(string $body): array
{
    if (strlen($body) > 65_536) {
        throw new InvalidArgumentException('profile payload is too large');
    }

    $value = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    $allowedKeys = ['displayName' => true, 'age' => true];

    if (
        !is_array($value) ||
        array_is_list($value) ||
        count($value) !== 2 ||
        array_diff_key($value, $allowedKeys) !== [] ||
        !array_key_exists('displayName', $value) ||
        !array_key_exists('age', $value) ||
        !is_string($value['displayName']) ||
        strlen($value['displayName']) < 1 ||
        strlen($value['displayName']) > 80 ||
        !is_int($value['age']) ||
        $value['age'] < 0 ||
        $value['age'] > 130
    ) {
        throw new InvalidArgumentException('invalid profile');
    }

    return [
        'displayName' => $value['displayName'],
        'age' => $value['age'],
    ];
}

$body = file_get_contents('php://input', false, null, 0, 65_537);
if ($body === false) {
    throw new RuntimeException('request body read failed');
}

try {
    $profile = parseProfile($body);
    http_response_code(201);
} catch (JsonException | InvalidArgumentException $error) {
    http_response_code(400);
    $profile = ['error' => 'invalid profile'];
}

header('Content-Type: application/json');
echo json_encode($profile, JSON_THROW_ON_ERROR);

The example reads one byte beyond the permitted maximum to detect oversized input. It checks the JSON object's exact fields and value ranges, then creates a new array. The 65_536-byte limit and field constraints are illustrative; adapt them to the endpoint's contract.

Usage considerations

Use data-only formats for external input regardless of how it is received or which calling syntax is used. Keep yaml.decode_php=0 when processing untrusted YAML so PHP objects cannot be restored. The example's array_is_list requires PHP 8.1 or later.

References