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.
Inhaltsverzeichnis
- 1. Was Deserialisierung ist und warum nicht vertrauenswürdige Eingaben gefährlich sind
- 2. Wie unserialize() bei angreiferkontrollierten Daten PHP Object Injection auslöst
- 3. Magische Methoden als Gadget-Einstiegspunkte: wakeup, destruct, toString
- 4. Was eine Gadget-Chain ist: Konzept ohne Exploit-Code
- 5. Warum json_decode() sicherer ist, und wo die Grenzen liegen
- 6. Sichere Deserialisierungs-Patterns in der Praxis
- 7. Ein historisches, Magento-relevantes Deserialisierungs-Beispiel
- 8. unserialize()-Nutzung im eigenen Code erkennen und auditieren
- 9. Serialisierungsansätze im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
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.