Kai Ole Hartwig
21 Min. Lesezeit
Von

Symfony CVE-2026-118515 und CVE-2026-121623: Der Serializer macht aus Request-Daten eine Server-Datei

Wer in Symfony eine Datei per #[MapRequestPayload] in ein DTO übernimmt, geht davon aus, dass nur ein echter Upload zum Dateiobjekt wird. Bis zu den Releases vom 4. November war das ein Irrtum. Hatte eine Eigenschaft den Typ UploadedFile, setzte der Serializer das Objekt aus den Feldern der Anfrage zusammen, und den Dateipfad lieferte der Client gleich mit. Ein zweiter Fehler im selben Normalizer öffnete einen Data-URI mit großgeschriebenem Schema als lokale Datei. Am Ende behandelt die Anwendung in beiden Fällen eine beliebige Datei des Servers so, als hätte der Nutzer sie hochgeladen.

TL;DR — 90 Sekunden

Wie aus einem Formularfeld eine Server-Datei wird

Der Serializer macht aus eingehenden Daten PHP-Objekte, indem er eine Kette von Normalizern befragt. Jeder meldet, ob er den gewünschten Zieltyp beherrscht, und der erste Treffer übernimmt die Arbeit. Im FrameworkBundle steht der DataUriNormalizer laut Advisory ab Werk vor dem ObjectNormalizer. Der eine ist ein Spezialist, der aus Data-URIs nach RFC 2397 Objekte vom Typ \SplFileInfo, \SplFileObject und File baut. Der andere ist der Generalist für alles Übrige. Er erzeugt nahezu jede Klasse und füllt die Argumente des Konstruktors mit den gleichnamigen Feldern der Eingabe.

Warum diese Arbeitsteilung gefährlich wird, zeigt der Konstruktor von UploadedFile. Die Klasse steht für eine Datei, die PHP bereits empfangen und in seinem temporären Verzeichnis abgelegt hat. Den Pfad nimmt sie deshalb als gegeben hin. Neben path und originalName kennt sie das Flag test, das für Tests gedacht ist. Ist es gesetzt, überspringt isValid() laut Advisory die Prüfung mit is_uploaded_file(), und move() verschiebt die Datei mit einem einfachen rename(). Das Vertrauen in den Pfad steckt also im Typ selbst und nicht in einer Prüfung, die irgendwo aufgerufen würde.

Genau hier lag der Fehler. Der DataUriNormalizer fühlte sich nur für die drei genannten Klassen zuständig, und zwar exakt in dieser Schreibweise. UploadedFile ist eine Unterklasse von File und fiel deshalb an den ObjectNormalizer durch, der das Objekt brav aus den Daten der Anfrage baute, samt Pfad und Test-Flag. Seit Symfony 8.1 führt #[MapRequestPayload] bei multipart/form-data außerdem Formularfelder und hochgeladene Dateien zusammen, bevor es deserialisiert, damit ein DTO beides aufnehmen kann. Eine echte Datei verdrängt dabei ein gleichnamiges Feld, wie der Quellcode zeigt. Schickt der Angreifer aber gar keine Datei, bleibt sein Feld stehen, und ein Formularfeld oder ein JSON-Body nimmt den Platz ein, der eigentlich einem Upload gehört. Einen echten Upload braucht es dafür nicht, wie das Advisory betont. Auch Assert\File hilft nicht, denn die Prüfung untersucht die Datei am gewählten Pfad und findet dort eine ganz gewöhnliche Datei vor.

Der zweite Fehler steckt im eigentlichen Fachgebiet des Normalizers. Er erkannte Data-URIs an einem regulären Ausdruck, dem die Groß- und Kleinschreibung des Schemas gleichgültig war. PHP dagegen aktiviert seinen Stream-Wrapper für data: nur bei kleingeschriebenem Schema und hält jede andere Schreibweise für einen relativen Dateipfad. Darin enthaltene ..-Segmente löst PHP laut Advisory sogar über Verzeichnisse hinweg auf, die gar nicht existieren. So lässt sich der Pfad bis zu jeder lesbaren Datei verlängern. In einem \SplFileObject liefert der Wert den Inhalt sofort, bei \SplFileInfo und File spätestens dann, wenn die Anwendung openFile() oder getContent() aufruft.

Beide Fehler haben, wie ich aus den Advisories ableite, dieselbe Wurzel. Ob der Normalizer Inhalte aus der Anfrage verarbeitete oder eine Datei auf dem Server öffnete, entschied eine Zeichenkette, die der Client kontrolliert. Das Typsystem kannte keinen Unterschied zwischen „Inhalt, den der Client geschickt hat“ und „Datei, die schon auf dem Server liegt“.

Was die Fix-Commits ändern

Für CVE-2026-121623 genügt eine einzige Zeile. Commit a0c5518 auf dem Zweig 5.4 schreibt in DataUriNormalizer::denormalize() das Schema nach dem Treffer des regulären Ausdrucks fest als data: und hängt den Rest der Eingabe unverändert an. Ein Kommentar daneben hält fest, dass PHP einen Data-URI mit anders geschriebenem Schema als lokalen Pfad öffnet. Der neue Test spielt die Schreibweisen DATA:, Data: und dAtA: für alle drei Zieltypen durch und erwartet, dass der angehängte Pfad als Inhalt zurückkommt, statt als Datei geöffnet zu werden. Großgeschriebene Schemata bleiben damit gültig, wie es RFC 3986 für URI-Schemata vorsieht. PHP bekommt aber immer die Schreibweise, die sein Wrapper versteht.

CVE-2026-118515 schließen die Commits 72ece5b für 5.4 und c459104 für 6.4. Im Commit für 5.4 erklärt sich supportsDenormalization() nun für jeden Typ zuständig, der laut is_a() von \SplFileInfo abstammt, statt nur für die drei Einträge der Liste unterstützter Typen. In denormalize() kommen vor dem regulären Ausdruck zwei Prüfungen hinzu. Ist der Wert schon ein \SplFileInfo und zugleich eine Instanz des Zieltyps, wie die Dateien aus dem Request, reicht der Normalizer ihn unverändert durch. Ist der Zieltyp dagegen eine Unterklasse, die nicht in der Liste steht, wirft er eine NotNormalizableValueException, aus der #[MapRequestPayload] laut Advisory eine Antwort mit Status 422 macht.

Erst danach folgt der bisherige Weg über den Data-URI, und der verlangt jetzt zusätzlich eine Zeichenkette als Eingabe. Weil die Liste die Klassennamen als Schlüssel führt, landen auch Schreibweisen mit führendem Backslash, abweichender Groß- und Kleinschreibung oder ein Alias im Zweig für Unterklassen. So steht es auch in der Commit-Nachricht. Den Forward-Port c459104 habe ich nicht gesondert gelesen.

Am meisten über die Ursache verrät die zweite Änderung. Der erste Fix löst einen Widerspruch zwischen Prüfer und Verbraucher auf, denn regulärer Ausdruck und PHP verstanden unter einem Data-URI nicht dasselbe. Der zweite Fix fragt nicht mehr, welche Daten ankommen. Er fragt, welcher Typ aus Daten überhaupt entstehen darf. Ein Dateiobjekt stammt danach entweder aus der Upload-Verarbeitung von PHP oder aus einem Data-URI, aber nie aus einem Konstruktor, dessen Argumente der Client wählt. Damit hängt der Schutz allerdings an der Reihenfolge der Normalizer. Steht der DataUriNormalizer nicht vor dem ObjectNormalizer, bleibt ein Serializer laut Advisory auch nach dem Update angreifbar.

Voraussetzungen, Angriffsweg und realistischer Schaden

Bei beiden CVEs gilt dieselbe Voraussetzung: Ein Endpunkt deserialisiert Daten des Clients in einen Typ mit Datei-Eigenschaft. Für CVE-2026-121623 genügt eine Eigenschaft vom Typ \SplFileInfo, \SplFileObject oder File, für CVE-2026-118515 eine vom Typ UploadedFile oder einer anderen Unterklasse. Ab Symfony 6.4 zählen laut Advisory auch DTOs hinter #[MapRequestPayload] dazu, und weil das FrameworkBundle den DataUriNormalizer ab Werk registriert, ist schon die Standardkonfiguration angreifbar. Ausgerechnet das Muster mit UploadedFile im DTO zeigt die Doku seit 8.1 für Uploads. Bei einem öffentlichen Endpunkt braucht der Angreifer nicht einmal ein Konto.

Der Angriff selbst ist schnell beschrieben. An die Stelle des Upload-Felds setzt der Angreifer entweder eine Struktur mit einem Pfad auf dem Server oder einen Data-URI mit großgeschriebenem Schema und angehängtem Pfad. Die Anwendung validiert das vermeintliche Upload-Objekt und lässt es durch, weil am Pfad eine echte Datei liegt. Was dann geschieht, hängt vom Code der Anwendung ab. Speichert sie den Upload und liefert ihn später aus, etwa als Avatar oder Anhang, lädt der Angreifer den Inhalt der Server-Datei einfach herunter. Ruft sie wie in der Upload-Doku move() auf, verschiebt sie die referenzierte Datei per rename() ins Upload-Verzeichnis, und das Original ist weg. Das erklärt die hohe Bewertung für die Verfügbarkeit im CVSS-Vektor. Selbst eine Anwendung, die nichts zurückgibt, verrät laut Advisory noch, ob ein Pfad existiert.

Wie viel das wert ist, hängt davon ab, was der PHP-Prozess lesen darf. In einer typischen Symfony-Installation liegen die lohnendsten Dateien direkt im Projektverzeichnis: die .env.local, die von secrets:decrypt-to-local erzeugte .env.prod.local oder der Schlüssel config/secrets/prod/prod.decrypt.private.php, falls er als Datei auf den Server kopiert wurde. In Kubernetes kommt das Token des ServiceAccounts hinzu, das ein Pod ohne weitere Einstellung unter /var/run/secrets/kubernetes.io/serviceaccount/ eingehängt bekommt. Aus dem Lesen einer einzigen Datei wird so schnell ein Zugriff auf Datenbank, Drittdienste oder die Kubernetes-API, so weit die Rechte des ServiceAccounts eben reichen.

Dabei lohnt es sich, die wirklich Betroffenen von denen zu trennen, die es nur befürchten. Ein symfony/serializer im composer.lock macht eine Anwendung noch nicht angreifbar. Entscheidend ist, ob irgendwo Daten des Clients in einen Typ mit Datei-Eigenschaft deserialisiert werden. TYPO3 14 und Laravel 13 führen den Serializer laut Packagist nicht unter den Abhängigkeiten des Kerns, dort gelangt er nur über Extensions, Pakete oder eigenen Code ins Projekt. Wer Uploads ausschließlich aus $request->files liest, ist von CVE-2026-118515 nicht betroffen. Die eigentliche Risikogruppe bilden API-Anwendungen auf Basis des FrameworkBundles, deren DTOs Dateien entgegennehmen.

CVEBetroffenFixVoraussetzungCVSS
CVE-2026-118515 (GHSA-h38r-xrmh-cwhr)symfony/serializer vor 5.4.56, 6.0 bis vor 6.4.50, 7.0 bis vor 7.4.23, 8.0 bis vor 8.1.115.4.56 / 6.4.50 / 7.4.23 / 8.1.11Client-Daten werden in einen Typ mit UploadedFile- oder anderer \SplFileInfo-Unterklassen-Eigenschaft deserialisiert8.4 (hoch, CVSS 4.0)
CVE-2026-121623 (GHSA-pxj9-jh84-vhm5)dieselben Versionen5.4.56 / 6.4.50 / 7.4.23 / 8.1.11Client-Daten werden in \SplFileInfo, \SplFileObject oder File deserialisiert8.2 (hoch, CVSS 4.0)

Die Advisories erschienen am 4. November, die CVE-Einträge zwei Tage später. Bis zum 9. November nennt keine der geprüften Quellen einen eigenständigen Exploit, Angriffe in freier Wildbahn sind nicht bekannt, und im CISA-KEV-Katalog steht keine der beiden Lücken. EPSS schätzt die Wahrscheinlichkeit einer Ausnutzung in den nächsten 30 Tagen auf etwa 0,6 Prozent. Beruhigen sollte dich diese Zahl trotzdem nicht, denn beide Advisories enthalten Beispielanfragen, und ein Nachbau kostet kaum Aufwand. Eine Unstimmigkeit bleibt: Der CVE-Eintrag zu CVE-2026-121623 spricht von einem Ausdruck, der Groß- und Kleinschreibung unterscheidet. Advisory und Fix-Commit sagen das Gegenteil, und ich halte mich an den Commit.

Fix-Versionen und Stolperfallen beim Update

Beide Lücken schließen dieselben Releases von symfony/serializer: 5.4.56, 6.4.50, 7.4.23 und 8.1.11. Wer die Komponente über das Gesamtpaket symfony/symfony bezieht, braucht dort dieselben Versionen. Für die Zweige 6.0 bis 6.3, 7.0 bis 7.3 und 8.0 nennt das Advisory keine eigene Fix-Version. Dort führt der Weg über das nächste unterstützte Release, und ein Sprung etwa von 7.2 auf 7.4 ist mehr als ein Patch.

Welche Version du einsetzt, zeigt composer show symfony/serializer. Welches Paket die Komponente ins Projekt zieht, verrät composer why symfony/serializer. Das Update selbst läuft mit composer update symfony/serializer --with-dependencies. In Flex-Projekten begrenzt extra.symfony.require in der composer.json die erlaubten Symfony-Versionen, der Wert muss also zum Ziel-Zweig passen. Am selben Tag hat Symfony mindestens zwanzig Advisories veröffentlicht, darunter mit GHSA-hqp2-7rr8-hf26 eine weitere Lücke im ObjectNormalizer. Es lohnt sich deshalb, gleich alle Symfony-Pakete zu aktualisieren statt nur den Serializer.

An zwei Stellen ändert der Fix das Verhalten. Anfragen, die ein Dateiobjekt aus Daten bauen wollen, beantwortet #[MapRequestPayload] jetzt mit 422. Das ist gewollt, kann aber Tests treffen, die Uploads über JSON simuliert haben. Außerdem entstehen aus Data-URIs nur noch dann Dateiobjekte, wenn der Zieltyp genau als ::class-Konstante angegeben ist. Ein führender Backslash, abweichende Groß- und Kleinschreibung oder ein Klassen-Alias gelten jetzt als Unterklasse. Übergibst du deserialize() den Typ als selbst zusammengesetzte Zeichenkette, solltest du das nach dem Update testen.

Die größte Stolperfalle ist ein Serializer, der nicht aus dem FrameworkBundle stammt. Wird er per new Serializer([...]) mit eigener Normalizer-Liste gebaut und steht der DataUriNormalizer dort nicht vor dem ObjectNormalizer oder fehlt ganz, schützt das Update laut Advisory nicht. Solche Konstruktionen stecken gern in Bibliotheken, Message-Handlern und CLI-Werkzeugen, also auch außerhalb des eigenen src/-Verzeichnisses.

Weil Composer die Pakete in jedes Projekt und damit in jedes Image kopiert, hilft ein Update auf dem Host nicht. Distributionspakete wie php-symfony-serializer unter Debian spielen nur eine Rolle, wenn eine Anwendung sie tatsächlich lädt. Bei der üblichen Composer-Installation aktualisierst du das Lockfile, baust das Image neu und rollst es aus.

Härtung über den Patch hinaus

Zuerst lohnt eine Einordnung, welche Maßnahmen hier überhaupt wirken können. Bei diesem Angriff läuft kein fremder Code, es gibt keinen Speicherfehler und keinen ungewöhnlichen Syscall. Die Anwendung liest oder verschiebt Dateien, auf die sie ohnehin Zugriff hat, nur eben im Auftrag des Falschen. Wirksam ist deshalb, was an der Typgrenze ansetzt, an der aus Client-Daten ein Dateiobjekt wird. Ebenso hilft alles, was den Dateizugriff des Prozesses begrenzt oder die lohnenden Ziele aus seiner Reichweite entfernt. Härtungen, die im Container Rechte oder Syscalls beschneiden, gehen dagegen ins Leere.

Dateien nur aus dem Upload-Bereich des Requests

Die direkteste Maßnahme setzt dort an, wo der Fehler entsteht. Solange ein DTO hinter #[MapRequestPayload] eine Eigenschaft vom Typ UploadedFile hat, entscheidet die Normalizer-Kette, woher das Objekt stammt. Nimmst du die Datei stattdessen als eigenes Controller-Argument mit #[MapUploadedFile] entgegen, liest der RequestPayloadValueResolver sie ausschließlich aus $request->files, unter dem Argumentnamen oder dem Wert der Option name. Das zeigt der Quellcode in 7.4 und 8.1. Der Serializer ist an dieser Stelle gar nicht beteiligt, und ein Formularfeld kann sich nicht als Datei ausgeben. Zusätzlich nimmt das Attribut Constraints wie Assert\File mit mimeTypes und maxSize auf und bricht bei einem Verstoß ab, bevor der Controller läuft. Der Preis ist etwas Komfort, weil das Muster aus Symfony 8.1 mit Formularfeldern und Datei in einem DTO wegfällt.

Wer beim DTO bleiben möchte, kann im eigenen Code eine zweite Prüfung einziehen. Die PHP-Funktion is_uploaded_file() liefert laut Handbuch nur dann true, wenn die Datei per HTTP POST hochgeladen wurde. Als Zweck nennt das Handbuch ausdrücklich, ein Skript davor zu schützen, auf Dateien wie /etc/passwd angesetzt zu werden. Ruf sie mit $file->getPathname() auf, bevor du eine Datei speicherst, verschiebst oder ausgibst. Weil sie nicht am Test-Flag von UploadedFile hängt, hätte sie CVE-2026-118515 ebenso abgefangen wie das Lesen über einen Data-URI. Tests, die Uploads mit test: true simulieren, brauchen danach allerdings einen anderen Weg.

Im Worker-Modus von FrankenPHP sollte die Prüfung genauso funktionieren, was ich aus dem Quellcode ableite. FrankenPHP ruft für jede Worker-Anfrage sapi_activate() auf und importiert $_FILES neu. PHP wiederum legt die Liste der hochgeladenen Dateien in sapi_activate() frisch an und verwirft sie am Ende der Anfrage. Dokumentiert ist dieses Verhalten nicht. Bevor du dich darauf verlässt, lohnt deshalb ein kurzer Test mit einem echten Upload auf Staging.

Secrets nicht als Datei neben dem Code

Schaden entsteht dort, wo lohnende Dateien in Reichweite des Prozesses liegen, und am nächsten liegt das Projektverzeichnis. Die Symfony-Doku stellt klar, dass der Schlüssel prod.decrypt.private.php nie ins Repository gehört, und bietet statt der Datei die Umgebungsvariable SYMFONY_DECRYPTION_SECRET an. Der Befehl secrets:decrypt-to-local schreibt dagegen alle Secrets im Klartext nach .env.prod.local ins Projektverzeichnis, also genau dorthin, wo ein solcher Angriff zuerst sucht. In Kubernetes übergibst du Zugangsdaten deshalb besser als Umgebungsvariablen aus einem Secret statt als Datei im Code-Baum.

Eine Grenze dieser Maßnahme übersieht man leicht. Auch Umgebungsvariablen stehen in einer Datei, nämlich in /proc/self/environ, und die darf derselbe Prozess lesen. Wirksam wird die Maßnahme deshalb erst zusammen mit open_basedir aus dem nächsten Abschnitt, das den Zugriff auf /proc sperrt. Ähnliches gilt für das Token des ServiceAccounts, das ohne weitere Einstellung als Datei im Pod liegt. Braucht deine PHP-Anwendung die Kubernetes-API nicht, setzt du im Pod automountServiceAccountToken: false. Dann legt das Kubelet laut Doku kein Token unter /var/run/secrets/kubernetes.io/serviceaccount/token ab. Ist der Wert sowohl am ServiceAccount als auch im Pod gesetzt, gilt der des Pods.

open_basedir als zusätzliches Netz

Mit open_basedir beschränkt PHP alle Dateizugriffe auf die angegebenen Verzeichnisse. Das gilt laut Handbuch für fopen() und include ebenso wie für weitere Funktionen, und Symlinks löst PHP vorher auf, sodass sie keinen Ausweg bieten. Gegen diesen Mechanismus wirkt das, weil jeder Pfad am Ende in einem gewöhnlichen Dateizugriff von PHP mündet. Gibst du nur das Projektverzeichnis, das Upload-Verzeichnis und das temporäre Verzeichnis frei, bleiben /etc, /proc und /var/run/secrets unerreichbar. Mehrere Verzeichnisse trennst du unter Linux mit einem Doppelpunkt.

Die Grenzen benennt das Handbuch selbst. Es bezeichnet open_basedir als zusätzliches Sicherheitsnetz, auf das man sich nicht verlassen kann, wenn es wirklich auf Sicherheit ankommt. Die Einstellung schaltet außerdem den Realpath-Cache ab, was große Symfony-Anwendungen messbar bremsen kann, und für Unix-Sockets gilt sie nicht. Vor allem muss das Projektverzeichnis erlaubt bleiben, sonst läuft die Anwendung nicht. Eine .env.local darin bleibt also lesbar, und genau deshalb gehören beide Maßnahmen zusammen.

Schreibgeschütztes Dateisystem gegen move()

Der zweite Teil des Schadens entsteht, wenn die Anwendung das vermeintliche Upload-Objekt verschiebt und so eine Server-Datei aus ihrem Verzeichnis entfernt. Ein Container mit readOnlyRootFilesystem: true, dessen Code fest im Image liegt, verhindert das für Code und Konfiguration, denn auf einem schreibgeschützten Dateisystem lässt sich nichts umbenennen. Schreibbar bleiben nur die Volumes für Cache und Uploads. Darin liegt zugleich die Grenze der Maßnahme, wie ich aus dem Mechanismus ableite. Das Lesen verhindert sie nicht, und Dateien in schreibbaren Volumes, etwa die Uploads anderer Nutzer, lassen sich weiterhin verschieben.

CVE-Gate mit Composer

Seit Version 2.4 bringt Composer den Befehl composer audit mit. Er prüft das Lockfile gegen bekannte Advisories und taugt in der Pipeline als Gate. Neuere Versionen gehen noch einen Schritt weiter, denn laut Composer-Doku steht block-insecure standardmäßig auf true und hindert composer update und composer require daran, Versionen mit bekannten Advisories zu installieren. Ein Gate findet allerdings nur, was bereits als Advisory veröffentlicht ist, und sagt nichts darüber, ob dein Code den betroffenen Pfad überhaupt nutzt. Die Prüfung der DTOs ersetzt es also nicht. Es sorgt nur dafür, dass ein gepatchtes Release nicht wochenlang ungenutzt liegen bleibt.

Was hier nicht hilft

seccomp mit RuntimeDefault, entzogene Capabilities, runAsNonRoot, allowPrivilegeEscalation: false und der Pod Security Standard „restricted“ bleiben sinnvoll, gegen diesen Mechanismus sind sie aber wirkungslos. Die Anwendung öffnet und liest Dateien mit genau den Syscalls und Rechten, die sie für ihre Arbeit ohnehin braucht, und die Secrets gehören demselben Nutzer, unter dem sie läuft. Auch User Namespaces und ein minimales Base-Image ändern daran nichts, denn die lohnenden Dateien gehören der Anwendung selbst.

Eine Egress-Sperre per NetworkPolicy hilft ebenfalls nicht, weil die Daten keine neue Verbindung brauchen. Sie verlassen den Server als Antwort auf die Anfrage des Angreifers oder als Download des gespeicherten Uploads. Eine WAF-Regel auf data: übersieht die Großschreibung, sobald sie Groß- und Kleinschreibung unterscheidet, und selbst ohne diese Schwäche deckt sie nur eine der beiden Lücken ab. Bleibt noch Assert\File, das laut Advisory die echte Datei am Pfad des Angreifers prüft und sie deshalb passieren lässt.

Mehr zu schreibgeschützten Containern, Secrets und Mandantentrennung findest du in der Serie TYPO3 auf Kubernetes unter IT-Grundschutz.

Erkennung und Prüfung

Ob du betroffen bist, klärst du in zwei Schritten. Der erste gilt der Version: composer show symfony/serializer nennt die installierte Fassung, und alles unter 5.4.56, 6.4.50, 7.4.23 oder 8.1.11 enthält beide Lücken. Der zweite gilt dem Code, denn erst dort entscheidet sich, ob die Lücke erreichbar ist. Such nach Eigenschaften und Konstruktor-Argumenten vom Typ UploadedFile, File, \SplFileInfo und \SplFileObject in Klassen, die hinter #[MapRequestPayload] stehen oder an deserialize() übergeben werden. Zum Schluss prüfst du jede Stelle, an der ein Serializer per new Serializer(...) von Hand entsteht, auf die Reihenfolge der Normalizer.

Schwerer zu beantworten ist die Frage, ob jemand die Lücke schon ausgenutzt hat. Indikatoren nennen die Advisories nicht, die folgenden Hinweise leite ich aus dem Mechanismus ab. Protokollierst du Request-Bodies, sind Upload-Felder verdächtig, die statt einer Datei ein Objekt mit einem Feld path enthalten. Gleiches gilt für Werte, die mit einem nicht kleingeschriebenen data: beginnen und ..-Segmente enthalten. Ohne solche Protokolle bleiben nur die Spuren im Upload-Speicher. Auffällig sind dort Uploads, deren Inhalt nach Konfiguration aussieht, etwa Zeilen mit APP_SECRET oder Schlüsselmaterial, und Dateien, die nach einem Upload an ihrem ursprünglichen Ort fehlen. Häufen sich nach dem Update 422-Antworten auf Upload-Endpunkten, versucht es offenbar jemand weiter.

Bei einem Verdacht gilt die Reihenfolge Patch, Rotation, Prüfung. Nach dem Update tauschst du alles aus, was der PHP-Prozess lesen konnte: APP_SECRET, Datenbank-Zugangsdaten, API-Keys aus den .env-Dateien und den Entschlüsselungsschlüssel der Secrets, den du laut Symfony-Doku mit secrets:generate-keys --rotate erneuerst. War das Token des ServiceAccounts eingehängt, verliert es laut Kubernetes-Doku seine Gültigkeit, sobald der Pod gelöscht wird. Ein Neustart der Pods ist deshalb der erste Schritt. Danach prüfst du im Audit-Log, ob mit diesem Token Anfragen von fremden Adressen kamen, und rollst das Image neu aus, damit verschobene Dateien wieder an ihrem Platz liegen.

Betreiberempfehlung

Akut handeln solltest du, wenn eine öffentlich erreichbare Symfony-Anwendung mit einem Serializer vor den Fix-Versionen Client-Daten in Typen mit Datei-Eigenschaften deserialisiert. Das betrifft vor allem APIs, die seit Symfony 8.1 Uploads über DTOs annehmen. Spiel das Update sofort ein und prüfe bei selbst gebauten Serializern die Reihenfolge der Normalizer. Kannst du nicht ausschließen, dass der Endpunkt schon angefragt wurde, rotierst du außerdem die Secrets aus dem Projektverzeichnis.

Beobachten genügt, wenn der Serializer zwar im Lockfile steht, aber nirgends Client-Daten in Dateiobjekte übersetzt. Das ist etwa in einer TYPO3-Installation der Fall, in der ihn nur eine Extension für interne Exporte nutzt. Ins nächste reguläre Wartungsfenster gehört das Update trotzdem, schon weil am selben Tag eine ganze Reihe weiterer Symfony-Advisories erschienen ist.

Unabhängig vom Einzelfall gilt, was ich bei jeder Lücke sage: Patches und Updates gehören automatisiert. Schlagen Renovate oder Dependabot neue Versionen der Composer-Pakete selbstständig als Merge Request vor und testet die Pipeline das neu gebaute Image gegen Staging, wird ein Security-Release wie dieses zur Sache von Stunden statt Wochen. Eine Karenzzeit für Routine-Updates schützt dabei vor kompromittierten Paketen, Sicherheitsupdates nimmst du davon aus. Wie sehr sich das lohnt, zeigt die neue 422-Antwort: Ein Test, der Uploads über JSON simuliert hat, schlägt dann in der Pipeline fehl und nicht erst beim ersten echten Kunden. Wer dagegen von Hand aktualisiert, lässt eine Lücke mit öffentlichen Beispielanfragen in der Urlaubszeit schnell vier Wochen offen. Danach folgt ein Sammel-Update, bei dem niemand mehr weiß, welche Änderung was kaputt gemacht hat.

Häufige Fragen zu CVE-2026-118515 und CVE-2026-121623

Ich betreibe TYPO3. Bin ich betroffen?+

Der Kern von TYPO3 bringt den Symfony-Serializer nicht mit. Prüf mit composer why symfony/serializer, ob eine Extension ihn nachzieht und dabei Client-Daten in Dateiobjekte übersetzt.

Reicht das Update allein?+

Für den Serializer des FrameworkBundles ja. Baust du einen Serializer selbst, muss der DataUriNormalizer vor dem ObjectNormalizer stehen, sonst bleibt die Lücke offen.

Warum hält Assert\File den Angriff nicht auf?+

Die Prüfung untersucht die Datei am Pfad, den der Angreifer gewählt hat. Dort liegt eine echte Datei, also besteht sie. Woher die Datei kommt, prüft nur is_uploaded_file().

Was tun, wenn die Lücke schon ausgenutzt sein könnte?+

Spiel zuerst das Update ein. Danach rotierst du alles, was der PHP-Prozess lesen konnte, von APP_SECRET bis zum Entschlüsselungsschlüssel der Secrets. Ein Neustart der Pods entwertet ein eingehängtes ServiceAccount-Token.

Fazit

Schaut man nur auf den Fix, sind beide Lücken klein. Groß werden sie, wenn man fragt, was sie über das Design verraten. UploadedFile trägt ein Versprechen in sich: Diese Datei hat PHP selbst empfangen. Ein Konstruktor, der dieses Versprechen mit beliebigen Argumenten einlöst, wird gefährlich, sobald ein generischer Mechanismus wie der ObjectNormalizer ihn mit Daten von außen füttert. Der Data-URI-Fehler erzählt dieselbe Geschichte von der anderen Seite, denn Prüfer und Verbraucher verstanden unter derselben Zeichenkette etwas anderes.

Die Lehre, die ich daraus ziehe, reicht über Symfony hinaus: Ein Typ, der Vertrauen ausdrückt, darf nie aus Client-Daten entstehen. Das gilt für Dateien und Pfade ebenso wie für Objekte, die Flags für Tests, Rollen oder bereits geprüfte Zustände tragen. Es lohnt sich, die eigenen DTOs einmal gezielt darauf durchzusehen, welche Klassen der Serializer dort baut und ob eine davon mehr verspricht, als die Anfrage belegen kann.

Quellen

Ich prüfe deine Symfony-Anwendungen auf DTOs, die Client-Daten in Dateiobjekte übersetzen, und härte die Container, in denen sie laufen.

Code-Review von Upload- und Serializer-Pfaden, Secrets aus dem Projektverzeichnis in Kubernetes-Secrets, open_basedir und schreibgeschützte Container, Composer-Gates und Update-Automatisierung mit Renovate.

Plattform-Betrieb statt Beratung auf Papier: Ich prüfe, patche und härte deine Infrastruktur laufend.

Über den Autor

Foto von Kai Ole Hartwig.

Kai Ole Hartwig

Freiberuflicher DevSecOps-Berater · OnlyOle Consulting

Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.