Deserialisierungs-Schwachstellen in PHP verstehen
OWASP
0x00
Security · PHP · Deserialisierung · OWASP
Deserialisierungs-Schwachstellen in PHP verstehen
Object Injection, Gadget-Chains und sichere Alternativen

Wer unserialize auf nicht vertrauenswürdige Eingaben wie Cookies, Sitzungsdaten oder API Payloads anwendet, öffnet die Tür für PHP Object Injection und gefährliche Gadgetketten, die über magische Methoden wie wakeup und destruct beliebigen Code ausführen können. Dieser Artikel erklärt die Mechanik, zeigt sichere Alternativen wie json_decode und beschreibt bewährte Deserialisierungsmuster für produktive PHP und Magento Anwendungen.

12 Min. Lesezeit unserialize · Object Injection · Gadget-Chains PHP 8.4 · Magento 2.4.8 · OWASP

1. Was Deserialisierung ist und warum nicht vertrauenswürdige Eingaben gefährlich sind

PHP bietet mit serialize() und unserialize() ein natives Format, um beliebige Werte, darunter Arrays, Skalare und vollständige Objekte, in einen String umzuwandeln und später wiederherzustellen. Der serialisierte String kodiert dabei nicht nur die Werte, sondern bei Objekten auch den exakten Klassennamen und die interne Property-Struktur. Genau diese Eigenschaft unterscheidet PHPs natives Serialisierungsformat fundamental von reinen Datenformaten wie JSON: Der String selbst bestimmt, welche Klasse beim Wiederherstellen instanziiert wird.

Sobald ein solcher String aus einer nicht vertrauenswürdigen Quelle stammt, etwa einem Cookie, einem Formularfeld, einer Session-ID oder einem Cache-Eintrag, der von außen beeinflussbar ist, wird unserialize() zu einer aktiven Angriffsfläche. Der Aufrufer kontrolliert dann nicht mehr nur Daten, sondern potenziell auch, welche Klassen instanziiert und welche magischen Methoden dabei automatisch ausgeführt werden. In Magento und vergleichbaren PHP-Anwendungen tauchte diese Problematik historisch bei Session-Handling, Payment-Callback-Daten und Cache-Layern auf, die serialisierte Strukturen ohne ausreichende Herkunftsprüfung verarbeitet haben.

2. Wie unserialize() bei angreiferkontrollierten Daten PHP Object Injection auslöst

PHP Object Injection (POI) entsteht, wenn ein Angreifer den Klassennamen und die Property-Werte im serialisierten String frei wählen kann. Ruft die Anwendung anschließend unserialize() auf diesen String auf, instanziiert PHP jede Klasse, die zum Zeitpunkt des Aufrufs über den Autoloader erreichbar ist, unabhängig davon, ob die Anwendung diese Klasse überhaupt für diesen Zweck vorgesehen hat. Der Konstruktor der Zielklasse wird dabei nicht aufgerufen, wohl aber bestimmte magische Methoden, sofern die Klasse sie implementiert.

Das eigentliche Risiko liegt selten in der Instanziierung selbst, sondern in dem, was danach automatisch passiert. Existiert im Projekt oder in einer eingebundenen Bibliothek eine Klasse mit einer __wakeup()- oder __destruct()-Methode, die Dateien schreibt, SQL-Queries baut oder Objekte weiterreicht, kann ein Angreifer diese Logik allein durch die Wahl des Klassennamens im Payload anstoßen, ohne dass die Anwendung eine explizite Aufrufkette dafür vorgesehen hat.

Das folgende Beispiel zeigt keinen funktionierenden Exploit, sondern illustriert nur die grundsätzliche Angriffsfläche: Sobald Rohdaten aus einem Cookie direkt in unserialize() landen, verliert die Anwendung die Kontrolle darüber, welche Klasse tatsächlich instanziiert wird.


<?php
declare(strict_types=1);

// VULNERABLE PATTERN (illustrative only, do not use):
// Untrusted request data is passed directly into unserialize().
// If the payload contains a serialized object, unserialize() will
// instantiate that class and invoke its magic methods (__wakeup,
// __destruct, __toString) automatically, before any validation runs.
final class LegacyCartImporter
{
    public function importFromCookie(string $rawCookieValue): array
    {
        // Attacker fully controls $rawCookieValue.
        // Any class reachable via autoloading can be instantiated here.
        $data = unserialize($rawCookieValue);

        if (!is_array($data)) {
            throw new \InvalidArgumentException('Invalid cart payload.');
        }

        return $data;
    }
}

3. Magische Methoden als Gadget-Einstiegspunkte: wakeup, destruct, toString

Magische Methoden sind der Mechanismus, über den PHP Object Injection überhaupt praktisch ausnutzbar wird. __wakeup() wird von unserialize() automatisch direkt nach der Objekterzeugung aufgerufen und dient eigentlich dazu, nach der Wiederherstellung Ressourcen wie Datenbankverbindungen neu zu initialisieren. Enthält diese Methode aber Seiteneffekte wie Dateizugriffe, Aufrufe an andere Objekte oder dynamische Methodenaufrufe, wird sie zum ersten Glied einer möglichen Angriffskette, die vollständig ohne weiteres Zutun der Anwendung ausgeführt wird.

__destruct() ist besonders heimtückisch, weil sie am Ende des Skriptlaufs oder bei der Garbage Collection automatisch aufgerufen wird, oft weit entfernt von der Stelle, an der unserialize() ursprünglich aufgerufen wurde. Das erschwert die Fehlersuche erheblich, weil der eigentliche Auslöser und die schädliche Wirkung zeitlich und räumlich getrennt sind. __toString() wiederum greift, sobald ein Objekt in einem String-Kontext verwendet wird, etwa in einer Log-Ausgabe oder einer String-Konkatenation, was sie zu einem dritten, oft übersehenen Einstiegspunkt macht.

Wichtig ist: Keine dieser Methoden ist per se gefährlich. Gefährlich wird es erst, wenn ihre Implementierung Aktionen mit realen Nebenwirkungen ausführt und dabei auf Property-Werte vertraut, die theoretisch aus einem manipulierten serialisierten String stammen können.

4. Was eine Gadget-Chain ist: Konzept ohne Exploit-Code

Eine Gadget-Chain ist eine Abfolge von Methodenaufrufen über mehrere, oft völlig unabhängige Klassen hinweg, die erst in Kombination zu einer gefährlichen Aktion führt. Der Begriff stammt aus der Beobachtung, dass einzelne, für sich harmlose Codefragmente wie Bauteile zusammengesetzt werden können: Die __destruct()-Methode von Klasse A ruft eine Methode von Klasse B auf, deren Rückgabewert wiederum von Klasse C in einem gefährlichen Kontext verwendet wird, etwa in call_user_func() oder einer Dateioperation.

Sicherheitsforscher analysieren dafür systematisch die im Projekt und in Abhängigkeiten vorhandenen Klassen auf verkettbare magische Methoden, ein Vorgehen, das öffentlich bekannte Werkzeuge für gängige Frameworks weitgehend automatisieren. Entscheidend für Entwickler ist nicht, eine konkrete Kette nachzubauen, sondern zu verstehen, dass jede zusätzliche Abhängigkeit mit gefährlichen magischen Methoden die potenzielle Angriffsfläche vergrößert, selbst wenn der eigene Code fehlerfrei ist.

Genau deshalb ist die wirksamste Verteidigung nicht das Absichern einzelner Gadgets, sondern das grundsätzliche Verhindern der Objektinstanziierung bei nicht vertrauenswürdigen Eingaben. Ist keine beliebige Klasse instanziierbar, existiert auch keine Kette, die ausgelöst werden könnte.

5. Warum json_decode() sicherer ist, und wo die Grenzen liegen

json_decode() kennt kein Konzept, um beim Parsen beliebige PHP-Klassen zu instanziieren. Das JSON-Format selbst enthält keine Klasseninformationen, und PHPs Implementierung erzeugt aus einem JSON-Objekt standardmäßig ein stdClass-Objekt oder, mit dem associative-Flag, ein einfaches Array. Beide Rückgabetypen besitzen keine magischen Methoden, die automatisch ausgeführt werden könnten. Damit entfällt der komplette Angriffsvektor, der PHP Object Injection erst ermöglicht: Es gibt schlicht keine Klasse, deren __wakeup() oder __destruct() ein Angreifer wählen könnte.

Das bedeutet aber nicht, dass json_decode() automatisch sicher ist. Ohne JSON_THROW_ON_ERROR gibt die Funktion bei ungültigem Input still null zurück, was zu unbemerkten Folgefehlern führt. Fehlt eine Begrenzung der depth, können stark verschachtelte Payloads unnötig Ressourcen binden. Und selbst ein sauber geparstes Array kann fachlich unsinnige oder schädliche Werte enthalten, etwa negative Mengenangaben oder überlange Strings, wenn keine explizite Schema-Validierung nach dem Parsen erfolgt.

Die Praxis zeigt: json_decode() löst das strukturelle Sicherheitsproblem der Objektinstanziierung vollständig, ersetzt aber nicht die fachliche Validierung der enthaltenen Werte. Beide Schritte gehören zusammen in jede Stelle, die Eingaben aus nicht vertrauenswürdigen Quellen verarbeitet.


<?php
declare(strict_types=1);

// SAFE PATTERN: json_decode() never instantiates arbitrary classes.
// It only ever produces scalars, arrays, or stdClass instances,
// so there is no magic-method entry point to abuse.
final class CartPayloadDecoder
{
    /**
     * @throws \JsonException
     * @throws \InvalidArgumentException
     */
    public function decode(string $rawJson): array
    {
        $decoded = json_decode(
            $rawJson,
            associative: true,
            depth: 8,
            flags: JSON_THROW_ON_ERROR
        );

        if (!is_array($decoded)) {
            throw new \InvalidArgumentException('Payload must be a JSON object or array.');
        }

        // Explicit allow-list validation instead of trusting the shape blindly.
        $sku = $decoded['sku'] ?? null;
        $qty = $decoded['qty'] ?? null;

        if (!is_string($sku) || $sku === '' || !is_int($qty) || $qty < 1) {
            throw new \InvalidArgumentException('Payload failed schema validation.');
        }

        return ['sku' => $sku, 'qty' => $qty];
    }
}

6. Sichere Deserialisierungs-Patterns in der Praxis

Wo unserialize() aus Kompatibilitätsgründen unverzichtbar bleibt, etwa beim Lesen von Legacy-Session-Daten, reduziert die Option allowed_classes die Angriffsfläche drastisch. Mit allowed_classes => false dekodiert PHP jedes serialisierte Objekt zu false, statt es zu instanziieren; mit einer expliziten Klassenliste werden ausschließlich die genannten, geprüften Klassen zugelassen. Diese Option existiert seit PHP 7.0 und sollte bei jedem verbleibenden unserialize()-Aufruf auf externe Daten Pflicht sein.


<?php
declare(strict_types=1);

// SAFE PATTERN: restrict unserialize() to plain data, no objects at all.
// Use this when legacy code still requires the PHP serialization format
// but the input source cannot be fully trusted.
final class LegacySessionReader
{
    public function read(string $serialized): array
    {
        $result = unserialize($serialized, ['allowed_classes' => false]);

        // Any serialized object in the payload now decodes to false
        // instead of being instantiated, which lets us reject it safely.
        if ($result === false && $serialized !== serialize(false)) {
            throw new \RuntimeException('Rejected: payload contains object data.');
        }

        if (!is_array($result)) {
            throw new \RuntimeException('Rejected: payload is not a plain array.');
        }

        return $result;
    }
}

Ein zweites, unabhängiges Schutzprinzip ist die kryptografische Signierung serialisierter Daten vor dem Speichern. Statt der reinen Payload wird zusätzlich ein HMAC über den serialisierten String gebildet und mitgespeichert. Vor jedem unserialize()-Aufruf wird die Signatur mit hash_equals() geprüft, sodass manipulierte Payloads schon vor der Deserialisierung verworfen werden, unabhängig davon, welche Klasse referenziert wird. Der geheime Schlüssel darf dabei niemals im Frontend oder in Client-erreichbarem Code liegen.


<?php
declare(strict_types=1);

// SAFE PATTERN: sign serialized data before persisting it, then verify
// the signature before ever calling unserialize() on it again.
final class SignedSerializer
{
    public function __construct(private readonly string $secretKey)
    {
    }

    public function pack(array $payload): string
    {
        $serialized = serialize($payload);
        $signature = hash_hmac('sha256', $serialized, $this->secretKey);

        return $signature . '.' . base64_encode($serialized);
    }

    /**
     * @throws \RuntimeException
     */
    public function unpack(string $packed): array
    {
        [$signature, $encoded] = array_pad(explode('.', $packed, 2), 2, '');
        $serialized = base64_decode($encoded, true) ?: '';
        $expected = hash_hmac('sha256', $serialized, $this->secretKey);

        if (!hash_equals($expected, $signature)) {
            throw new \RuntimeException('Signature mismatch, payload rejected.');
        }

        $result = unserialize($serialized, ['allowed_classes' => false]);
        if (!is_array($result)) {
            throw new \RuntimeException('Unexpected payload shape.');
        }

        return $result;
    }
}

Als drittes Prinzip gilt: Schema-Validierung nach jeder Deserialisierung, unabhängig vom Format. Erwartete Feldnamen, Typen und Wertebereiche werden explizit geprüft, bevor die Daten in die Fachlogik einfließen. Kombiniert man alle drei Muster, allowed_classes, Signierung und Schema-Validierung, entsteht eine mehrschichtige Verteidigung, bei der ein einzelner übersehener Fall nicht sofort zur vollständigen Kompromittierung führt.

7. Ein historisches, Magento-relevantes Deserialisierungs-Beispiel

In mehreren Sicherheitsupdates für Magento wurden über die Jahre Stellen identifiziert, an denen die Plattform PHPs native Serialisierung für Daten verwendete, deren Herkunft nicht vollständig vertrauenswürdig war, etwa bei bestimmten Konfigurationswerten, Produktoptionen oder zahlungsbezogenen Feldern, die von Modulen oder externen Schnittstellen befüllt wurden. Wurden solche Werte später mit unserialize() statt mit einem reinen Datenformat verarbeitet, entstand potenziell eine PHP-Object-Injection-Fläche, sobald ein Angreifer Einfluss auf den gespeicherten String nehmen konnte, sei es über ein verwundbares Formularfeld oder eine unzureichend geprüfte API.

Die Lektion aus diesen Fällen war weniger eine einzelne Codezeile als ein wiederkehrendes Muster: Serialisierte PHP-Strings wurden an Stellen verwendet, die ursprünglich für interne, vertrauenswürdige Konfigurationsdaten gedacht waren, später aber über Formulare, Importe oder Erweiterungen indirekt von außen beeinflussbar wurden, ohne dass die Verarbeitung entsprechend nachgezogen wurde. Der Fix-Ansatz in den betroffenen Bereichen folgte konsequent demselben Muster: Ersetzen von serialize()/unserialize() durch json_encode()/json_decode() für alle Werte, die potenziell von außen beeinflussbar sind, ergänzt um explizite Whitelist-Validierung der erwarteten Feldstruktur.

Für heutige Magento- und PHP-Projekte bleibt die praktische Konsequenz identisch: Jede Stelle, an der serialize() oder unserialize() auf Daten trifft, die aus einem Formular, einer Erweiterung, einem Import oder einer externen API stammen könnten, verdient eine gezielte Prüfung, unabhängig davon, ob die Stelle historisch als sicher galt.

8. unserialize()-Nutzung im eigenen Code erkennen und auditieren

Der erste Schritt eines Audits ist simpel und effektiv: eine vollständige Bestandsaufnahme aller unserialize()-Aufrufe im Projekt und in eigenen Modulen, inklusive der Frage, woher die jeweiligen Eingabedaten stammen. Ein einfacher grep über den Codebestand liefert dafür einen schnellen Überblick, sollte aber ergänzt werden um eine Prüfung, ob bereits die allowed_classes-Option gesetzt ist, da ein reiner Treffer auf unserialize( noch nichts über die tatsächliche Sicherheit der Stelle aussagt.

Statische Analyse-Tools wie PHPStan oder Psalm lassen sich mit spezialisierten Regelsätzen erweitern, die unsichere unserialize()-Aufrufe ohne allowed_classes als Fehler markieren und damit in die CI-Pipeline integrierbar sind, statt sich auf manuelle Reviews zu verlassen. Ergänzend hilft ein Rector-Regelsatz, der bekannte unsichere Muster automatisch zum Ersetzen vorschlägt, etwa den Wechsel von serialize()/unserialize() zu json_encode()/json_decode() an Stellen ohne zwingenden Objektbedarf.

Eine Code-Review-Checkliste für Deserialisierung sollte mindestens folgende Fragen abdecken: Stammt der Input aus einer nicht vertrauenswürdigen Quelle? Ist allowed_classes gesetzt oder wäre json_decode() ausreichend? Gibt es eine Signaturprüfung vor der Deserialisierung? Und: Implementiert eine der potenziell instanziierbaren Klassen __wakeup(), __destruct() oder __toString() mit gefährlichen Seiteneffekten?


#!/usr/bin/env bash
# Audit a PHP codebase for potentially unsafe unserialize() usage.
set -euo pipefail

echo "== Direct unserialize() calls =="
grep -rn --include="*.php" -E '\bunserialize\s*\(' app/code vendor/local 2>/dev/null || true

echo "== unserialize() calls without allowed_classes option =="
grep -rn --include="*.php" -E 'unserialize\([^,)]+\)\s*;' app/code 2>/dev/null || true

echo "== Static analysis: PHPStan security-relevant rule set =="
vendor/bin/phpstan analyse app/code --level=5 --error-format=table

echo "== Rector dry-run: flag legacy serialize()/unserialize() usage =="
vendor/bin/rector process app/code --dry-run --config=rector-security.php

9. Serialisierungsansätze im direkten Vergleich

Die Wahl des Serialisierungsformats für nicht vertrauenswürdige Daten ist keine reine Stilfrage, sondern hat direkte Auswirkungen auf die Angriffsfläche einer Anwendung. Die folgende Übersicht fasst die wichtigsten Unterschiede zwischen unsicheren und sicheren Ansätzen zusammen.

Bereich Unsicherer Ansatz Sicherer Ansatz Warum das wichtig ist
Untrusted Eingaben parsen unserialize() auf rohe POST/Cookie-Daten json_decode() mit Typ- und Schema-Prüfung Keine Klasseninstanziierung möglich
Session-/Cache-Speicherung serialize() ohne weitere Absicherung Signierte Payloads oder reines JSON Verhindert unbemerkte Manipulation
Klassenkontrolle bei unserialize() unserialize($data) ohne Optionen unserialize($data, ['allowed_classes' => false]) Objekte werden nicht instanziiert
Integritätsprüfung Keine Signatur vor der Deserialisierung HMAC-Signatur mit hash_equals() prüfen Manipulierte Payloads werden verworfen
Magische Methoden wakeup/destruct mit Seiteneffekten in erreichbaren Klassen Klassen ohne gefährliche Magic Methods, klare Allow-List Kein automatischer Gadget-Einstiegspunkt
Codequalität/Audit Kein Monitoring von unserialize()-Aufrufen Statische Analyse und Pflicht-Review für jeden Aufruf Neue Schwachstellen werden früh erkannt

In der Praxis lassen sich die meisten Fälle klar zuordnen: Sobald Daten eine Vertrauensgrenze überschreiten, etwa von einem Cookie, einer Drittanbieter-API oder einem Formularfeld kommen, gehört json_decode() mit expliziter Validierung an diese Stelle. unserialize() bleibt ausschließlich für interne, vollständig kontrollierte Datenflüsse vertretbar, und selbst dort empfiehlt sich allowed_classes als zusätzliche Absicherung.

Mironsoft

PHP- und Magento-Security-Audits, Code-Reviews und sichere Architektur

Deserialisierungs-Risiken im eigenen Code aufspüren?

Wir analysieren eure PHP- und Magento-Codebasis auf unsichere unserialize()-Aufrufe, riskante magische Methoden und fehlende Validierung, und liefern konkrete, sofort umsetzbare Fixes statt allgemeiner Empfehlungen.

Security-Audit

Statische Analyse und manuelle Prüfung auf Deserialisierungs- und Injection-Risiken

Code-Härtung

Migration von unserialize() zu json_decode(), Schema-Validierung nachrüsten

CI-Integration

PHPStan-Regeln und automatisierte Audits fest in der Pipeline verankern

10. Zusammenfassung

Deserialisierungs-Schwachstellen in PHP entstehen, sobald unserialize() auf Daten trifft, deren Herkunft nicht vollständig kontrolliert ist. Der serialisierte String bestimmt dabei nicht nur Werte, sondern bei Objekten auch, welche Klasse instanziiert wird, und magische Methoden wie __wakeup(), __destruct() und __toString() werden dabei automatisch ausgeführt. Enthalten erreichbare Klassen in diesen Methoden gefährliche Seiteneffekte, entsteht eine potenzielle Gadget-Chain, ganz ohne dass die Anwendung selbst einen fehlerhaften Aufruf enthält.

json_decode() eliminiert dieses strukturelle Risiko vollständig, weil JSON keine Klasseninformationen transportiert. Wo PHPs native Serialisierung dennoch nötig bleibt, reduzieren allowed_classes, HMAC-Signierung und konsequente Schema-Validierung die Angriffsfläche auf ein beherrschbares Maß. Regelmäßige Audits mit grep, statischer Analyse und einer klaren Review-Checkliste stellen sicher, dass neue unsichere Stellen nicht unbemerkt entstehen.

Deserialisierungs-Schwachstellen in PHP, das Wichtigste auf einen Blick

Objektinstanziierung

Der serialisierte String bestimmt bei unserialize(), welche Klasse instanziiert wird, ein zentraler Unterschied zu reinen Datenformaten.

Magische Methoden

__wakeup(), __destruct() und __toString() werden automatisch ausgeführt und sind die typischen Gadget-Einstiegspunkte.

json_decode als Alternative

Erzeugt nur Arrays oder stdClass-Objekte ohne magische Methoden, eliminiert die Objektinstanziierungs-Gefahr strukturell.

Mehrschichtige Verteidigung

allowed_classes, HMAC-Signierung und Schema-Validierung zusammen ergeben belastbaren Schutz für verbleibende unserialize()-Stellen.

11. FAQ: Deserialisierungs-Schwachstellen in PHP

1Was ist PHP Object Injection (POI)?
Eine Sicherheitslücke, bei der ein Angreifer über den an unserialize() übergebenen String bestimmt, welche Klasse instanziiert wird und welche magischen Methoden automatisch ausgeführt werden.
2Warum ist unserialize() auf User-Input gefährlich?
Der serialisierte String enthält den Klassennamen selbst. Ist der String von außen beeinflussbar, verliert die Anwendung die Kontrolle darüber, welche Klasse instanziiert wird.
3Was macht __wakeup() zu einem Sicherheitsrisiko?
Wird automatisch von unserialize() aufgerufen. Enthält die Methode Seiteneffekte, kann ein Angreifer diese allein durch die Wahl der Klasse im Payload auslösen.
4Was ist eine Gadget-Chain?
Eine Kette von Methodenaufrufen über mehrere Klassen, die erst in Kombination gefährlich wird, ausgelöst über magische Methoden nach der Deserialisierung.
5Ist json_decode() automatisch sicher?
Eliminiert die Objektinstanziierung, ersetzt aber nicht die Validierung. Typprüfung und Schema-Validierung nach dem Parsen bleiben Pflicht.
6Was bewirkt allowed_classes bei unserialize()?
Mit allowed_classes => false wird jedes Objekt zu false dekodiert statt instanziiert. Mit einer Liste sind nur die genannten Klassen erlaubt.
7Wie schützt HMAC-Signierung serialisierte Daten?
Ein HMAC wird vor dem Speichern gebildet und vor jedem unserialize()-Aufruf geprüft. Manipulierte Payloads werden vorher verworfen.
8Wie wurden historische Magento-Deserialisierungs-Schwachstellen behoben?
Durch Wechsel von serialize()/unserialize() zu json_encode()/json_decode() bei extern beeinflussbaren Daten, ergänzt um Whitelist-Validierung.
9Wie finde ich unserialize()-Aufrufe im eigenen Code?
grep als erster Schritt, ergänzt um statische Analyse mit PHPStan oder Psalm und eine feste Code-Review-Checkliste.
10Sollte ich serialize()/unserialize() komplett vermeiden?
Für vertrauensgrenzenübergreifende Daten ja. Intern und vollständig kontrolliert ist es mit allowed_classes vertretbar, json_decode bleibt aber meist einfacher und sicherer.