In short: For PHP unserialize security, a simple rule applies: never process data coming from outside with unserialize(). Use JSON instead. Where unserialize() has to stay because of legacy code, restrict it with allowed_classes => false and verify the authenticity of the data with a signature.
What can go wrong with unserialize()
PHP can convert arrays and objects into a string with serialize() and restore them with unserialize(). This is convenient for storing data in cookies, caches or database fields. The problem: in doing so, unserialize() also creates objects of arbitrary classes that can be loaded in the application.
When creating, and at the latest when destroying such an object, PHP automatically calls certain methods, such as __wakeup(), __unserialize() or __destruct(). If classes in the project or in libraries contain such methods with file, database or system actions, an attacker can misuse them via manipulated data. Experts speak of PHP object injection. Depending on the classes present, the effect ranges from deleting individual files to executing malicious code.
Summed up for non-technical readers: a single thoughtless call in the wrong place can let an attacker deep into the application. This weakness has therefore been among the topics of the OWASP Top 10 for years.
The safe alternative: JSON instead of unserialize
For almost all cases in which data comes from outside or leaves the server, JSON is the right choice. json_decode() creates only arrays, numbers, strings and booleans, no objects of your own classes.
<?php
declare(strict_types=1);
function leseEinstellungen(string $eingabe): array
{
try {
$daten = json_decode($eingabe, true, 16, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new InvalidArgumentException('Ungültige Daten.');
}
if (!is_array($daten)) {
throw new InvalidArgumentException('Erwartet wurde ein Objekt.');
}
// Accept only known fields with expected types
return [
'sprache' => in_array($daten['sprache'] ?? '', ['de', 'en'], true) ? $daten['sprache'] : 'de',
'seiten' => max(1, min(100, (int) ($daten['seiten'] ?? 20))),
];
}
The second part is important: JSON data is input too. Accept only expected fields and check types and value ranges before you work with them.
When unserialize is unavoidable: allowed_classes and signature
In older applications, serialised data is often found in databases or caches that cannot be converted immediately. You then limit the risk in two steps. First, you use allowed_classes to prevent objects from being created. Second, you use an HMAC signature to ensure that the data really comes from your application and was not altered on the way.
<?php
declare(strict_types=1);
const SCHLUESSEL = 'aus-sicherer-konfiguration-laden'; // nie im Code-Repository
function verpacken(array $daten): string
{
$roh = serialize($daten);
return hash_hmac('sha256', $roh, SCHLUESSEL) . '.' . base64_encode($roh);
}
function auspacken(string $paket): array
{
[$signatur, $b64] = array_pad(explode('.', $paket, 2), 2, '');
$roh = base64_decode($b64, true);
if ($roh === false || !hash_equals(hash_hmac('sha256', $roh, SCHLUESSEL), $signatur)) {
throw new RuntimeException('Signatur ungültig.');
}
$daten = unserialize($roh, ['allowed_classes' => false, 'max_depth' => 16]);
if (!is_array($daten)) {
throw new RuntimeException('Unerwartetes Format.');
}
return $daten;
}
If certain classes are needed after all, name them individually in allowed_classes instead of allowing all of them. The key belongs in the server configuration or a secret store and must be renewed after an incident.
Converting existing data to JSON step by step
In grown applications, serialised values often sit in the database for years. A changeover in one step is then risky. A transition has proved its worth: the application reads both formats but writes only JSON. When an old value is read, it is unpacked once with allowed_classes => false and immediately written back as JSON. After some time most entries are converted, and a one-off script takes care of the rest.
During the transition, log how often the old format is still read, and also when signatures do not match. Such failures can point to tampering attempts and belong in monitoring. Only when no old format turns up any more do you remove the unserialize() branch from the code completely.
Hidden deserialization: sessions, caches and file paths
Not every deserialization is visible in your own code. Also check these places:
- Sessions: PHP serialises session data automatically. As long as attackers cannot write the session files, this is uncritical. Separate session paths per website are described in the guide Securing sessions in PHP.
- Session serialiser: The note on CVE-2026-115560 concerns an unusual configuration in which the
serialize_handleris switched at runtime viaini_set()and session upload progress is active. Workaround according to php.net:session.upload_progress.enabled=0in php.ini or the FPM pool, then restart the workers. - Caches and queues: Redis, Memcached or database tables with serialised values are only as safe as the access to them. They do not belong openly on the network.
- File functions with paths from input: Never pass paths from the request to file functions. Stream wrappers such as
phar://can otherwise trigger unexpected processing. An allow-list for file names protects more reliably here than any filtering. - Dependencies: Libraries with known vulnerabilities widen an attacker's options. Keep them current, as described in the guide Checking Composer dependencies for vulnerabilities.
Finding risky places in the code
A first inventory works with a simple search. It changes nothing on the system and shows all calls outside the vendor directory:
grep -rn --include='*.php' 'unserialize(' /var/www/projekt \
| grep -v '/vendor/'
Assess each hit with three questions: where does the data come from? Can a user change it? Is allowed_classes set? Hits with input from cookies, $_GET, $_POST, $_REQUEST or external interfaces take priority. Replace them with JSON and test the changeover before rolling it out.
Another pointer lies in the browser. Serialised PHP data has a typical structure with type markers and length indications. If you find such values in cookies, hidden form fields or parameters of your own website, the application has given serialised data to the visitor and presumably expects it back. These are exactly the places to check.
For management, a simple question to developers or the agency is enough: “Does our application process serialised data coming from outside anywhere, and if so, how is it protected?” A clear answer with locations is a good sign. An evasive answer is a reason to have the application checked.
How does PHP SOS help?
- We search your code for deserialization with foreign data and assess each finding by reachability and impact.
- We propose concrete conversions to JSON or hardened calls and support your developers with the implementation.
- We check dependencies for known vulnerabilities and document the state.
- On request, we have the application independently checked as part of a security audit and penetration test.
Frequently asked questions
Is unserialize() in PHP unsafe in principle?
Not for data that only your own application creates and that nobody can change. It becomes dangerous as soon as users or third parties can influence the input, for example via cookies, form fields or external interfaces.
Is allowed_classes => false enough as protection?
It prevents arbitrary objects from being created and thus closes the most important attack route. For input from outside, JSON is still the better choice, because the parser is simpler and does not reconstruct objects.
Does updating libraries help against deserialization attacks?
Partly. Attacks often use classes from dependencies that perform actions when an object is destroyed or woken up. Current packages close known chains, but the actual cause is the unserialize call with foreign data.
Does PHP2Shell have anything to do with deserialization?
The two main vulnerabilities lie in PHP's request processing. The note on CVE-2026-115560, by contrast, concerns an unusual configuration of session serialisation. The workaround there is session.upload_progress.enabled=0.