unserialize without a class restriction

ID

php.insecure_deserialization

Severity

low

Remediation Complexity

medium

Remediation Risk

medium

Remediation Effort

medium

Resource

Misconfiguration

Language

Php

Tags

ASVS50:v1.5.2, CWE:502, NIST.SP.800-53, OWASP:2025:A08, PCI-DSS:6.5.1

Description

A payload is read with unserialize() and nothing restricts the classes it may reconstruct. The payload then decides which class is built, and its magic methods — wakeup, destruct, __toString — run as part of building it.

Rationale

php.code_injection_deserialization reports deserialization when a data path can be followed from an external input to the call. That needs the input to be visible: an unserialize() in a codec or a helper, where the caller lives elsewhere, has no source to follow and is reported by nothing.

Its severity is low because the finding says nothing about whether an attacker-controlled payload actually reaches the call — when that can be shown, php.code_injection_deserialization reports it as critical, and this finding is dropped as a duplicate.

The one place reporting a default is right

Elsewhere in this family the rules report only an explicit unsafe choice, because the libraries fixed their defaults — SnakeYAML in 2.0, XStream in 1.4.18, PyYAML by requiring a Loader. PHP is the exception, and it was measured rather than assumed. On PHP 8.3:

Call Result

unserialize($payload)

reconstructs the class, __wakeup runs

unserialize($payload, ['allowed_classes' ⇒ false])

__PHP_Incomplete_Class, no magic method runs

unserialize($payload, ['allowed_classes' ⇒ ['C']])

reconstructs C — an explicit allowlist

PHP has never changed that default and cannot without breaking the language, so there is no version for which the premise stops holding. The finding is therefore the absence of allowed_classes, which is the opposite of how the other rules in this family work and is correct only because the default is genuinely unsafe.

Note the contrast with the sibling XXE rule for the same language: php.insecure_xml_parser_configuration reports only the explicit LIBXML_NOENT, because there the default is safe. Same language, opposite treatment, each following what the runtime actually does.

What counts as a restriction

Either form of allowed_classes. false refuses every class and yields plain data; a list refuses everything outside it. Other options — max_depth, for instance — bound nesting and do not restrict classes, so a call passing only those is still reported.

Examples

Reported:

return unserialize(base64_decode($blob));                  // reported
return unserialize($blob, ['max_depth' => 128]);           // reported: bounds depth, not classes

Not reported:

return unserialize($blob, ['allowed_classes' => false]);
return unserialize($blob, ['allowed_classes' => [Order::class, Customer::class]]);

Remediation

Where the payload only needs to carry data, refuse classes outright:

$data = unserialize($blob, ['allowed_classes' => false]);

Where objects are genuinely needed, allowlist them and treat that list as security-relevant code:

$order = unserialize($blob, ['allowed_classes' => [Order::class]]);

Better still, carry the data in a format that names no classes:

$data = json_decode($blob, true);

Limitations

The options argument is matched where allowed_classes is named at the call site. An options array built at run time, or passed in as a variable, is not resolved and the call is reported.

© 2026 Xygeni. All rights reserved