Deserialisierungs-Schwachstellen in PHP vermeiden
AI generated
<?php
8.4
PHP · Security · Object Injection · Gadget Chains
Deserialisierungs-Schwachstellen in PHP vermeiden
PHP Object Injection verstehen und unserialize() richtig absichern

unserialize() auf nicht vertrauenswürdige Daten anzuwenden, gehört zu den gefährlichsten Mustern in PHP-Anwendungen. Gadget Chains aus harmlos wirkenden Klassen ermöglichen Remote Code Execution allein durch das Wiederherstellen eines Objekts. JSON, allowed_classes und signierte Payloads schließen diese Lücke zuverlässig.

19 Min. Lesezeit unserialize · allowed_classes · Gadget Chain · HMAC PHP 7.0+

1. Was PHP Object Injection ist und warum es gefährlich ist

Deserialisierungs-Schwachstellen in PHP entstehen, wenn eine Anwendung unserialize() auf Daten anwendet, die ein Angreifer ganz oder teilweise kontrollieren kann. PHP-Objekte können beim Deserialisieren sogenannte Magic Methods auslösen, insbesondere __wakeup() und __destruct(), die automatisch aufgerufen werden, sobald das Objekt erzeugt beziehungsweise wieder aufgeräumt wird. Wenn eine im Projekt vorhandene Klasse in einer dieser Methoden gefährlichen Code ausführt, etwa eine Datei löscht, eine Datenbankabfrage baut oder externen Input in eval() einspeist, kann ein Angreifer diesen Code allein durch das Erzeugen eines passend präparierten serialisierten Strings auslösen, ganz ohne eigenen Programmcode einzuschleusen.

Diese Klasse von Deserialisierungs-Schwachstellen wird oft als PHP Object Injection bezeichnet und ist deutlich subtiler als klassische Injection-Angriffe wie SQL-Injection, weil der eigentliche Schadcode bereits im legitimen Projekt- oder Bibliothekscode existiert und nur in einer ungewöhnlichen Reihenfolge aufgerufen wird. Genau das macht diese Schwachstellen so gefährlich: Ein Entwickler kann eine Klasse völlig korrekt und harmlos für ihren eigentlichen Zweck schreiben, und trotzdem wird sie über eine sogenannte Gadget Chain zum Baustein für Remote Code Execution. Dieser Artikel zeigt, wie man Deserialisierungs-Schwachstellen systematisch erkennt und verhindert.

2. Wie ein Deserialisierungs-Angriff technisch abläuft

Ein typischer Ablauf für Deserialisierungs-Schwachstellen beginnt damit, dass eine Anwendung einen serialisierten String aus einer nicht vertrauenswürdigen Quelle akzeptiert, etwa aus einem Cookie, einem Formularfeld oder einem Cache-Eintrag, der von außen beeinflussbar ist. Der Angreifer analysiert zunächst den verfügbaren Code, meist über öffentlich zugängliche Bibliotheken im vendor-Verzeichnis, und sucht nach Klassen mit interessanten Magic Methods. Findet er eine Kette von Klassen, deren __wakeup(), __destruct() oder __toString()-Methoden sich gegenseitig aufrufen und am Ende eine gefährliche Operation ausführen, konstruiert er ein Objektgraph, der genau diese Kette auslöst.

Der eigentliche Angriff besteht dann darin, diesen konstruierten Objektgraphen als serialisierten String über den anfälligen Endpunkt an die Anwendung zu senden. Ruft die Anwendung unserialize() auf diesem String auf, baut PHP automatisch die komplette Objektkette wieder auf und ruft dabei die entsprechenden Magic Methods auf, ohne dass der Anwendungscode selbst jemals bösartige Eingaben validiert oder verarbeitet hätte. Genau dieser automatische, unkontrollierte Ablauf macht Deserialisierungs-Schwachstellen so gefährlich: Der Angreifer benötigt keine Codeausführungslücke im eigentlichen Sinn, sondern missbraucht bereits vorhandene, legitime Programmlogik.

3. Sichere Alternative: JSON statt serialize/unserialize

Die wirksamste Verteidigung gegen Deserialisierungs-Schwachstellen ist, PHPs natives Serialisierungsformat für alle nicht vertrauenswürdigen Daten komplett zu vermeiden. json_encode() und json_decode() kennen keine Objektinstanziierung mit Magic Methods, sie erzeugen ausschließlich Skalare, Arrays und, mit dem Flag JSON_OBJECT_AS_ARRAY, assoziative Arrays. Da JSON keine Klassennamen und keine Objektstruktur im PHP-Sinn transportiert, kann ein Angreifer über JSON-Payloads prinzipiell keine Gadget Chain auslösen, selbst wenn er den Inhalt vollständig kontrolliert.

Für die meisten Anwendungsfälle, in denen Entwickler heute noch serialize()/unserialize() nutzen, etwa zum Cachen von Konfigurationsdaten oder zum Übertragen von Zustandsobjekten zwischen Requests, reicht JSON vollkommen aus. Wo tatsächlich komplexe Objektstrukturen mit Typinformationen benötigt werden, etwa bei Domain-Objekten mit Value Objects, lässt sich das Problem mit expliziten toArray()/fromArray()-Methoden lösen, die kontrolliert definieren, welche Felder in welcher Reihenfolge wiederhergestellt werden, statt die Wiederherstellung PHP intern über Reflection zu überlassen.


<?php

declare(strict_types=1);

// WRONG: unserialize() on untrusted input enables PHP Object Injection
$cartData = unserialize($_COOKIE['cart_state'] ?? '');

// RIGHT: JSON has no concept of object instantiation or magic methods
$cartData = json_decode($_COOKIE['cart_state'] ?? '{}', associative: true, flags: JSON_THROW_ON_ERROR);

/**
 * Explicit, controlled hydration instead of relying on PHP's
 * internal object reconstruction via unserialize().
 */
final class CartState
{
    public function __construct(
        public readonly array $items,
        public readonly string $currency,
    ) {
    }

    public static function fromArray(array $data): self
    {
        return new self(
            items: $data['items'] ?? [],
            currency: $data['currency'] ?? 'EUR',
        );
    }

    public function toArray(): array
    {
        return ['items' => $this->items, 'currency' => $this->currency];
    }
}

$cart = CartState::fromArray($cartData);

4. Wenn unserialize() unvermeidbar ist: allowed_classes nutzen

In gewachsenen Anwendungen lässt sich unserialize() nicht immer sofort komplett entfernen, etwa wenn Legacy-Datenformate über Jahre in einer Datenbank gespeichert wurden. PHP bietet seit Version 7 den zweiten Parameter allowed_classes, mit dem sich Deserialisierungs-Schwachstellen zumindest deutlich eindämmen lassen, ohne das Format sofort migrieren zu müssen. Übergibt man allowed_classes: false, werden alle Objekte während der Deserialisierung durch __PHP_Incomplete_Class ersetzt, ihre Magic Methods werden nicht aufgerufen. Übergibt man ein Array mit expliziten Klassennamen, dürfen nur genau diese Klassen instanziiert werden.

Wichtig ist, diese Positivliste so eng wie möglich zu halten und regelmäßig zu überprüfen, ob die zugelassenen Klassen selbst gefährliche Magic Methods besitzen. Eine Positivliste mit einer einzigen harmlosen Value-Object-Klasse ist deutlich sicherer als eine Liste mit einem kompletten Namespace, weil Letzteres wieder die Tür für Gadget Chains öffnen kann, sobald eine der zugelassenen Klassen unbemerkt eine gefährliche Methode bekommt, etwa durch ein Dependency-Update. allowed_classes ist ein Übergangsschutz, keine dauerhafte Lösung, der ideale Zielzustand bleibt die vollständige Migration auf JSON oder explizite Hydration.


<?php

declare(strict_types=1);

// WRONG: no allowed_classes parameter, any class can be instantiated
$data = unserialize($legacyPayload);

// SAFER: block all object instantiation entirely
$data = unserialize($legacyPayload, ['allowed_classes' => false]);

// SAFER (if objects are needed): explicit allowlist of known-safe classes
$data = unserialize($legacyPayload, [
    'allowed_classes' => [PriceValueObject::class, CurrencyCode::class],
]);

if ($data === false) {
    throw new RuntimeException('Deserialization failed or contained disallowed classes');
}

5. Gadget Chains: warum harmlose Klassen gefährlich werden

Eine Gadget Chain ist eine Abfolge von Methodenaufrufen über mehrere Klassen hinweg, die einzeln betrachtet völlig harmlos wirken, in Kombination aber eine gefährliche Operation erzeugen. Bekannt wurden solche Ketten unter anderem in populären PHP-Frameworks und -Bibliotheken, wo eine Logging-Klasse beim Zerstören eines Objekts (__destruct()) eine Datei schreibt, deren Pfad und Inhalt teilweise vom deserialisierten Objekt stammen. Kombiniert man diese Klasse mit einer anderen, die eine Methode als String aufruft, entsteht eine Kette, die am Ende zu Remote Code Execution führt, obwohl keine einzelne Klasse für sich genommen verdächtig aussieht.

Der entscheidende Punkt bei Deserialisierungs-Schwachstellen durch Gadget Chains: Es reicht, dass irgendeine per Composer eingebundene Bibliothek im gesamten Projekt eine ausnutzbare Klasse enthält, selbst wenn diese Klasse im eigenen Anwendungscode nie direkt genutzt wird. Werkzeuge wie PHPGGC (PHP Generic Gadget Chains) sammeln bekannte Gadget Chains für gängige Frameworks und Bibliotheken und automatisieren das Erstellen von Angriffs-Payloads, was zeigt, wie systematisch diese Angriffsklasse mittlerweile ausgenutzt wird, sobald irgendwo im Projekt ein unserialize() auf nicht vertrauenswürdige Daten existiert.

6. Deserialisierung in Sessions und Caches absichern

PHP-Sessions verwenden standardmäßig ein eigenes, an serialize() angelehntes Format zur Speicherung der Session-Variablen, das über die session.serialize_handler-Direktive konfiguriert wird. Solange Session-Daten ausschließlich serverseitig geschrieben und gelesen werden, entstehen daraus in der Regel keine Deserialisierungs-Schwachstellen, weil der Angreifer den Rohinhalt der Session-Datei nicht direkt beeinflussen kann. Kritisch wird es, sobald eine Anwendung Objekte aus der Session unkontrolliert an Bibliotheksfunktionen weiterreicht, die intern erneut deserialisieren, oder wenn Session-Daten in einem gemeinsam genutzten Cache-System wie Redis oder Memcached landen, auf das mehrere Dienste mit unterschiedlichem Vertrauensniveau zugreifen.

Bei Cache-Systemen wie Redis, die häufig zur Zwischenspeicherung von PHP-Objekten via serialize() genutzt werden, sollte man besonders sorgfältig prüfen, wer in den Cache schreiben darf. Ein Angreifer, der auch nur einen einzigen Cache-Key beeinflussen kann, etwa über eine schwach validierte HTTP-Header-basierte Cache-Variation, kann darüber unter Umständen eine komplette Deserialisierungs-Schwachstelle auslösen, sobald die Anwendung den Cache-Inhalt später wieder mit unserialize() liest. Auch hier gilt: JSON für Cache-Inhalte, die potenziell von außen beeinflussbar sein könnten, minimiert dieses Risiko strukturell.


<?php

declare(strict_types=1);

/**
 * Cache wrapper that stores data as JSON instead of PHP's native
 * serialize() format, eliminating object injection risk entirely.
 */
final class SafeJsonCache
{
    public function __construct(private readonly \Redis $redis)
    {
    }

    public function set(string $key, array $value, int $ttlSeconds): void
    {
        $this->redis->setex($key, $ttlSeconds, json_encode($value, JSON_THROW_ON_ERROR));
    }

    public function get(string $key): ?array
    {
        $raw = $this->redis->get($key);

        if ($raw === false) {
            return null;
        }

        return json_decode($raw, associative: true, flags: JSON_THROW_ON_ERROR);
    }
}

7. Third-Party-Bibliotheken als Gadget-Quellen

Ein oft übersehener Aspekt bei Deserialisierungs-Schwachstellen ist, dass die gefährliche Klasse nicht im eigenen Anwendungscode liegen muss. Composer-Abhängigkeiten bringen häufig Dutzende Transitive-Dependencies mit, von denen viele nie direkt aufgerufen werden, aber dennoch im Autoloader registriert sind. Sobald eine dieser Klassen eine ausnutzbare Magic Method besitzt, kann ein Angreifer sie als Baustein einer Gadget Chain verwenden, auch wenn der Anwendungsentwickler die Klasse nie bewusst importiert hat.

Regelmäßige Dependency-Audits mit Tools wie composer audit helfen, bekannte CVEs in eingesetzten Paketen zu erkennen, decken aber nicht automatisch jede mögliche Gadget Chain ab, da nicht jede ausnutzbare Kombination als eigene CVE dokumentiert ist. Der zuverlässigste Schutz bleibt daher strukturell: unserialize() auf nicht vertrauenswürdige Daten so weit wie möglich vermeiden, statt sich auf eine vollständige Liste bekannter Gadget Chains zu verlassen, die naturgemäß nie vollständig sein kann. Wer dennoch unserialize() einsetzen muss, sollte allowed_classes konsequent auf die minimal nötige Klassenliste beschränken.

8. Signierte Payloads mit HMAC in der Praxis

Eine zusätzliche Verteidigungsschicht gegen Deserialisierungs-Schwachstellen, besonders bei Daten, die über Cookies oder URL-Parameter transportiert werden, ist die kryptographische Signierung des Payloads. Statt reinen serialisierten oder JSON-kodierten Daten wird ein HMAC (Hash-based Message Authentication Code) über den Inhalt berechnet und mitgesendet. Beim Empfang prüft die Anwendung zuerst die Signatur mit hash_equals(), bevor überhaupt eine Deserialisierung stattfindet. Stimmt die Signatur nicht, wird der Payload komplett verworfen, ohne ihn jemals zu dekodieren.

Diese Technik verhindert nicht direkt Deserialisierungs-Schwachstellen in einem theoretischen Sinn, weil ein Angreifer mit Kenntnis des geheimen Schlüssels weiterhin einen bösartigen Payload signieren könnte. In der Praxis ist sie aber äußerst wirksam, weil ein externer Angreifer, der den Server-seitigen Schlüssel nicht kennt, gar keine gültig signierte Payload erzeugen kann und somit nie in die Situation kommt, dass die Anwendung seinen manipulierten Objektgraphen deserialisiert. Kombiniert mit JSON statt serialize() als zugrunde liegendem Format entsteht so eine doppelte Absicherung: Selbst falls die Signaturprüfung fehlerhaft implementiert wäre, bliebe das Format selbst frei von Objektinjektionsrisiken.


<?php

declare(strict_types=1);

/**
 * Encodes and verifies a JSON payload with an HMAC signature,
 * so tampered or forged data is rejected before decoding.
 */
final class SignedPayload
{
    public function __construct(private readonly string $secretKey)
    {
    }

    public function encode(array $data): string
    {
        $json = json_encode($data, JSON_THROW_ON_ERROR);
        $signature = hash_hmac('sha256', $json, $this->secretKey);

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

    public function decode(string $payload): ?array
    {
        [$encodedJson, $signature] = array_pad(explode('.', $payload, 2), 2, '');
        $json = base64_decode($encodedJson);

        $expectedSignature = hash_hmac('sha256', (string) $json, $this->secretKey);

        if (!hash_equals($expectedSignature, $signature)) {
            return null; // reject before ever decoding untrusted structure
        }

        return json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR);
    }
}

9. Serialisierungsformate im Risikovergleich

Die folgende Tabelle vergleicht die gängigen Formate für die Serialisierung nicht vertrauenswürdiger Daten hinsichtlich ihres Risikos für Deserialisierungs-Schwachstellen.

Format Objektinstanziierung Risiko Empfehlung
serialize() ohne allowed_classes Ja, beliebige Klassen Sehr hoch Nie auf nicht vertrauenswürdige Daten anwenden
serialize() mit allowed_classes: [] Nein, nur Skalare/Arrays Gering Übergangslösung für Legacy-Formate
JSON (json_decode) Nein Praktisch keins Standardwahl für neue Anwendungen
Signiertes JSON (HMAC) Nein Praktisch keins Bevorzugt bei Cookies und URL-Parametern

Die Tabelle zeigt eine klare Hierarchie: Nur serialize() ohne jede Einschränkung stellt ein akutes Risiko dar, alle anderen Optionen reduzieren die Angriffsfläche für Deserialisierungs-Schwachstellen erheblich. In neuen Projekten sollte unserialize() auf externen Input generell vermieden werden, unabhängig davon, wie vertrauenswürdig die Quelle aktuell erscheint.

Mironsoft

PHP-Sicherheitsaudits und Dependency-Reviews

unserialize() irgendwo im Legacy-Code?

Wir finden riskante Deserialisierungsstellen in bestehendem PHP-Code, prüfen Abhängigkeiten auf bekannte Gadget Chains und migrieren zu sicheren Alternativen wie JSON und signierten Payloads.

Codebase-Scan

Gezielte Suche nach unserialize() auf nicht vertrauenswürdigem Input

Dependency-Audit

Prüfung eingesetzter Bibliotheken auf bekannte Gadget-Chain-Risiken

Migration

Umstellung auf JSON, allowed_classes und HMAC-signierte Payloads

10. Zusammenfassung

Deserialisierungs-Schwachstellen in PHP entstehen fast immer an derselben Stelle: unserialize() wird auf Daten angewendet, die ein Angreifer beeinflussen kann. Die zuverlässigste Verteidigung ist, dieses Muster strukturell zu vermeiden, indem JSON für den Datenaustausch mit nicht vertrauenswürdigen Quellen genutzt wird. Wo unserialize() aus Legacy-Gründen unvermeidbar bleibt, begrenzt der allowed_classes-Parameter den Schaden, ist aber nur eine Übergangslösung, keine dauerhafte Absicherung.

Gadget Chains zeigen, dass die eigentliche Gefahr selten im eigenen Code liegt, sondern in Kombinationen aus Klassen, die einzeln betrachtet harmlos sind, oft irgendwo in Composer-Abhängigkeiten. Signierte Payloads mit HMAC ergänzen diese Verteidigung, indem sie manipulierte Daten schon vor der Deserialisierung verwerfen. Die Kombination aus JSON als Standardformat, minimalen Positivlisten bei unvermeidbarem unserialize() und Signierung bei extern zugänglichen Payloads deckt praktisch alle bekannten Angriffswege gegen Deserialisierungs-Schwachstellen ab.

Deserialisierungs-Schwachstellen vermeiden — Das Wichtigste auf einen Blick

Grundregel

Niemals unserialize() auf Daten aus nicht vertrauenswürdigen Quellen ohne allowed_classes anwenden.

Beste Alternative

JSON mit expliziter fromArray()-Hydration statt PHP-nativer Serialisierung.

Übergangslösung

allowed_classes als enge Positivliste, regelmäßig auf gefährliche Magic Methods prüfen.

Zusätzliche Schicht

HMAC-Signierung für Cookies und URL-Parameter, Payload vor Dekodierung verwerfen.

11. FAQ: Deserialisierungs-Schwachstellen in PHP vermeiden

1Was ist PHP Object Injection?
Ein präparierter serialisierter String löst über unserialize() Magic Methods aus, die zu unerwünschtem Verhalten führen.
2Warum ist JSON sicherer?
JSON kennt keine Objektinstanziierung oder Magic Methods, daher kein Gadget-Chain-Risiko.
3Was macht allowed_classes?
Beschränkt oder verhindert Objektinstanziierung während unserialize(), begrenzt so den Schaden.
4Was ist eine Gadget Chain?
Eine Kette einzeln harmloser Klassen, die kombiniert eine gefährliche Operation auslöst.
5Kann eine Gadget Chain aus Fremdcode stammen?
Ja, das ist der häufigste Fall, oft aus Transitive-Dependencies über Composer.
6Sind Sessions betroffen?
In der Regel nicht direkt, kritisch bei gemeinsam genutzten Caches oder unkontrollierter Weitergabe.
7Reicht composer audit aus?
Erkennt bekannte CVEs, deckt aber nicht jede mögliche Gadget Chain ab.
8Was bringt HMAC-Signierung?
Manipulierte Payloads werden vor der Dekodierung verworfen, ohne gültige Signatur kein Angriff möglich.
9Ist allowed_classes dauerhaft ausreichend?
Nein, es ist ein Übergangsschutz. Die dauerhafte Lösung ist Migration auf JSON mit expliziter Hydration.
10Müssen auch interne Schnittstellen abgesichert werden?
Ja, bei geteilten Caches oder Queues mit unterschiedlichem Vertrauensniveau entsteht dieselbe Angriffsfläche.