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 |
|---|---|
|
reconstructs the class, |
|
|
|
reconstructs |
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);