Kai Ole Hartwig
23 min read
By

Symfony CVE-2026-118515 and CVE-2026-121623: The Serializer Turns Request Data into a Server File

When a Symfony controller takes a file into a DTO via #[MapRequestPayload], it relies on only real uploads becoming file objects. Until the releases of November 4, that was not true. If a property was typed UploadedFile, the Serializer assembled the object from the fields of the request, and the client could supply the file path as well. A second flaw in the same normalizer opened a data URI with an uppercase scheme as a local path. In both cases the application treats an arbitrary file on the server as if the user had uploaded it.

TL;DR — 90 seconds

How a form field becomes a server file

The Serializer turns incoming data into PHP objects by asking a chain of normalizers. Each normalizer reports whether it supports a given target type, and the first match takes over. According to the advisory, FrameworkBundle registers the DataUriNormalizer ahead of the ObjectNormalizer by default. The first is a specialist that builds \SplFileInfo, \SplFileObject and File objects from data URIs as defined in RFC 2397. The second is the generalist for everything else. It creates almost any class and fills the constructor arguments with the fields of the input that share their names.

Why this division of labour becomes dangerous is shown by the constructor of UploadedFile. The class describes a file that PHP has already received and placed in its temporary directory, which is why it takes the path for granted. Besides path and originalName it knows a test flag meant for tests. When it is set, isValid() skips the is_uploaded_file() check according to the advisory, and move() falls back to a plain rename(). The trust in the path lives in the type itself and not in a check that would be called somewhere.

That is exactly where the flaw was. The DataUriNormalizer only considered itself responsible for the three classes in exactly that spelling. UploadedFile is a subclass of File and therefore fell through to the ObjectNormalizer, which dutifully built the object from the request data, including path and test flag. Since Symfony 8.1, according to the documentation, #[MapRequestPayload] merges form fields and uploaded files for multipart/form-data before deserializing, so that a DTO can hold both in one object. According to the source code, a real upload replaces a form field of the same name. If the attacker sends no file at all, the form field simply stays. A form field or a JSON body can therefore take the place where an upload should be. The advisory stresses that no real upload is needed and that Assert\File does not help, because the check examines the real file at the chosen path and finds a perfectly normal file there.

The second flaw sits in the normalizer's actual specialty. It recognised data URIs with a regular expression that accepted the scheme regardless of case. PHP, however, only activates its data: stream wrapper for a lowercase scheme and treats every other spelling as a relative file path. According to the advisory, PHP even resolves .. segments in it through directories that do not exist, so the path can be extended to any readable file. If the value ends up in an \SplFileObject, it returns the content right away, with \SplFileInfo and File at the latest when the application calls openFile() or getContent().

Both flaws share the same root, which is what I derive from the advisories. The normalizer decided, based on a string the client controls, whether it processes content from the request or opens a file on the server. The type system made no difference between “content the client sent” and “a file that already lives on the server”.

What the fix commits change

For CVE-2026-121623 one line is enough. Commit a0c5518 on the 5.4 branch sets the scheme in DataUriNormalizer::denormalize() to data: after the regular expression matches and appends the rest of the input unchanged. A code comment records that PHP opens a data URI with a differently spelled scheme as a local path. The new test checks the spellings DATA:, Data: and dAtA: for all three target types and expects the appended path to come back as content instead of being opened as a file. Uppercase schemes stay valid, as RFC 3986 intends for URI schemes, but PHP always receives the spelling its wrapper understands.

Commits 72ece5b for 5.4 and c459104 for 6.4 close CVE-2026-118515. In the 5.4 commit, supportsDenormalization() now claims every type that descends from \SplFileInfo according to is_a(), no longer just the three entries of the supported-types list. In denormalize(), two new checks come before the regular expression. If the value already is an \SplFileInfo and at the same time an instance of the target type, like the files of the request, the normalizer returns it unchanged. If the target type is a subclass that is not on the list, it throws a NotNormalizableValueException, which according to the advisory #[MapRequestPayload] turns into a response with status 422. Only then does the existing data URI path follow, which now also requires that the value is a string at all. Because the list uses the class names as keys, spellings with a leading backslash, different case or an alias also end up in the subclass branch, as the commit message notes. I did not read the forward port c459104 separately.

The second change reveals the most about the cause. The first fix resolves a contradiction between validator and consumer, because the regular expression and PHP had different ideas of what a data URI is. The second fix moves the question from “which data arrives” to “which type may be created from data at all”. A file object then comes either from PHP's upload handling or from a data URI, but never from a constructor with arguments chosen by the client. The protection depends on the order of the normalizers. According to the advisory, a serializer in which the DataUriNormalizer does not come before the ObjectNormalizer stays vulnerable even after the update.

Preconditions, attack path and realistic damage

The precondition is the same for both CVEs: an endpoint deserializes client data into a type that has a file property. For CVE-2026-121623 a property of type \SplFileInfo, \SplFileObject or File is enough, for CVE-2026-118515 one of type UploadedFile or another subclass. According to the advisory, DTOs behind #[MapRequestPayload] are affected from Symfony 6.4 on, and because FrameworkBundle registers the DataUriNormalizer by default, the standard configuration is exposed. The pattern with UploadedFile in the DTO is exactly the way the documentation shows for uploads since 8.1. If the endpoint is public, the attacker needs no account.

Conceptually the attack is quickly described. Instead of a file, the attacker sends a structure with a path on the server in place of the upload field, or a data URI with an uppercase scheme and a path appended. The application validates the supposed upload object, and validation passes because a real file sits at the path. What happens next depends on the application's code. If it stores the upload and serves it later, for example as an avatar or attachment, the attacker can download the content of the server file. If it calls move() as the upload documentation shows, it moves the referenced file into the upload directory via rename(), and the original is gone. That explains the high availability rating in the CVSS vector. Even an application that returns nothing still reveals, according to the advisory, whether a path exists.

What that is worth depends on what the PHP process may read. In a typical Symfony installation the most interesting files sit right in the project directory: .env.local, the .env.prod.local created by secrets:decrypt-to-local, or the key config/secrets/prod/prod.decrypt.private.php if it was copied to the server as a file. In Kubernetes there is also the ServiceAccount token, which a pod gets mounted under /var/run/secrets/kubernetes.io/serviceaccount/ unless configured otherwise. Reading one file thus quickly turns into access to the database, third-party services or the Kubernetes API, as far as the ServiceAccount's permissions reach.

It is important to separate those who are really affected from those who only think they are. A symfony/serializer in composer.lock does not make an application vulnerable on its own. What matters is whether client data is deserialized into a type with a file property anywhere. According to Packagist, TYPO3 14 and Laravel 13 do not list the Serializer among their core dependencies, so it only enters the project through extensions, packages or your own code. If you read uploads exclusively from $request->files, you are not affected by CVE-2026-118515. The real risk group is API applications built on FrameworkBundle whose DTOs accept files.

CVEAffectedFixPreconditionCVSS
CVE-2026-118515 (GHSA-h38r-xrmh-cwhr)symfony/serializer before 5.4.56, 6.0 to before 6.4.50, 7.0 to before 7.4.23, 8.0 to before 8.1.115.4.56 / 6.4.50 / 7.4.23 / 8.1.11Client data is deserialized into a type with an UploadedFile or other \SplFileInfo subclass property8.4 (high, CVSS 4.0)
CVE-2026-121623 (GHSA-pxj9-jh84-vhm5)same versions5.4.56 / 6.4.50 / 7.4.23 / 8.1.11Client data is deserialized into \SplFileInfo, \SplFileObject or File8.2 (high, CVSS 4.0)

The advisories were published on November 4, the CVE records on November 6. As of November 9, none of the sources checked mention a standalone exploit, no attacks in the wild are known, and neither flaw is in the CISA KEV catalog. EPSS estimates the probability of exploitation in the next 30 days at about 0.6 percent. That number should not reassure you, because both advisories show example requests and reproducing them takes little effort. In its description of CVE-2026-121623, the CVE record speaks of a case-sensitive expression. The advisory and the fix commit say the opposite, and I go with the commit.

Fixed versions and pitfalls of the update

Both flaws are closed by the same releases of symfony/serializer: 5.4.56, 6.4.50, 7.4.23 and 8.1.11. If you get the component through the full symfony/symfony package, you need the same versions there. The affected ranges in the advisory contain no fixed version of their own for the branches 6.0 to 6.3, 7.0 to 7.3 and 8.0. The way out there leads to the next supported release, and a jump from 7.2 to 7.4 is more than a patch.

composer show symfony/serializer tells you which version you run, and composer why symfony/serializer reveals which package pulls the component into the project. The update runs with composer update symfony/serializer --with-dependencies. In Flex projects, extra.symfony.require in composer.json limits the allowed Symfony versions, so the value has to match the target branch. On the same day Symfony published at least twenty advisories, among them GHSA-hqp2-7rr8-hf26, a second flaw in the ObjectNormalizer. It therefore pays to bring all Symfony packages up to date and not just the Serializer.

The fix changes behaviour in two places you should know about. Requests that try to build a file object from data are now answered with 422 by #[MapRequestPayload]. That is intended, but it can hit tests that simulated uploads via JSON. In addition, the normalizer only builds file objects from data URIs when the target type is given exactly as the ::class constant. Spellings with a leading backslash, different case or a class alias now count as subclasses. If you pass the type to deserialize() as a self-built string, test that after the update.

The biggest pitfall is a serializer you do not get through FrameworkBundle. If it is built with new Serializer([...]) and its own normalizer list, and the DataUriNormalizer does not come before the ObjectNormalizer or is missing entirely, the update does not protect you according to the advisory. Such constructions tend to live in libraries, message handlers and CLI tools, so outside your own src/ directory as well.

Because Composer copies the packages into every project and thus into every image, updating the host is not enough. Distribution packages such as php-symfony-serializer on Debian only matter if an application actually loads them. For the usual Composer installation you have to update the lockfile, rebuild the image and roll it out.

Hardening beyond the patch

First a classification of which measures can work at all. This attack runs no foreign code, involves no memory error and no unusual syscall. The application reads or moves files it is allowed to read and move, just on behalf of the wrong party. What works is therefore anything that targets the type boundary where client data becomes a file object, anything that limits the process's file access, and anything that takes the valuable targets out of its reach. Hardening that trims permissions or syscalls in the container misses the point here.

Files only from the upload part of the request

The most direct measure starts where the flaw arises. As long as a DTO behind #[MapRequestPayload] has a property of type UploadedFile, the normalizer chain decides where the object comes from. If you take the file through its own controller argument with #[MapUploadedFile] instead, the RequestPayloadValueResolver reads it exclusively from $request->files according to the source code in 7.4 and 8.1, under the argument name or the value of the name option. The Serializer plays no part at this point, and a form field cannot pose as a file. The attribute also accepts constraints such as Assert\File with mimeTypes and maxSize and aborts on a violation before the controller runs. The price is some convenience, because the Symfony 8.1 pattern with form fields and file in one DTO goes away.

If you want to keep the DTO, you can add a second check in your own code. According to the manual, PHP's is_uploaded_file() only returns true if the file was uploaded via HTTP POST, and the manual explicitly names protection against a script being pointed at files such as /etc/passwd as its purpose. Call it with $file->getPathname() before you store, move or output a file. It does not depend on the test flag of UploadedFile and would have caught CVE-2026-118515 as well as reading via a data URI. Tests that simulate uploads with test: true then need a different approach. In FrankenPHP worker mode the check should work the same way. According to its source code, FrankenPHP calls sapi_activate() for every worker request and re-imports $_FILES, and PHP resets the list of uploaded files in sapi_activate() and discards it at the end of the request. That is my derivation from the code, not documented behaviour, so a quick test with a real upload on staging is worth it before you rely on it.

No secrets as files next to the code

The damage arises where valuable files are within reach of the process, and the project directory is the most obvious place. The Symfony documentation states clearly that the key prod.decrypt.private.php never belongs in the repository and offers the environment variable SYMFONY_DECRYPTION_SECRET as an alternative to the file. The command secrets:decrypt-to-local, on the other hand, writes all secrets in plain text to .env.prod.local in the project directory, which is exactly where such an attack looks first. In Kubernetes you are therefore better off passing credentials as environment variables from a Secret rather than as a file in the code tree.

This measure has a limit that is easy to overlook. Environment variables also live in a file, namely /proc/self/environ, which the same process may read. It only works in combination with the next step, which blocks access to /proc. The ServiceAccount token belongs here too. If your PHP application does not need the Kubernetes API, set automountServiceAccountToken: false in the pod, and according to the documentation the kubelet will not place a token under /var/run/secrets/kubernetes.io/serviceaccount/token. If the value is set on both the ServiceAccount and the pod, the pod's value wins.

open_basedir as an additional net

With open_basedir, PHP restricts all file access to the given directories. According to the manual this applies to fopen() and include as well as other functions, and symlinks are resolved first so they offer no way out. It works against this mechanism because every path ends in an ordinary PHP file access. If you allow only the project directory, the upload directory and the temporary directory, /etc, /proc and /var/run/secrets stay out of reach. On Linux you separate several directories with a colon.

The manual names the limits itself. It calls open_basedir an extra safety net that cannot be relied upon when security is needed. The setting also disables the realpath cache, which can cost measurable performance in large Symfony applications, and it does not apply to Unix sockets. Above all, the project directory stays allowed, because otherwise the application does not run. A .env.local in it therefore stays readable, which is why both measures belong together.

Read-only file system against move()

The second part of the damage arises when the application moves the supposed upload object and thereby removes a server file from its directory. A container with readOnlyRootFilesystem: true whose code is baked into the image prevents this for code and configuration, because nothing can be renamed on a read-only file system. Only the volumes for cache and uploads remain writable. That is also the limit of the measure, as I derive from the mechanism: it does not prevent reading, and files in writable volumes, such as other users' uploads, can still be moved.

CVE gate with Composer

Since version 2.4, Composer ships the composer audit command, which checks the lockfile against known advisories and works as a gate in the pipeline. Newer versions go further. According to the Composer documentation, block-insecure defaults to true and prevents composer update or composer require from installing versions with known advisories. A gate only finds what has already been published as an advisory, though, and says nothing about whether your code uses the affected path at all. It does not replace reviewing the DTOs, it only makes sure a patched release does not sit unused for weeks.

What does not help here

seccomp with RuntimeDefault, dropped capabilities, runAsNonRoot, allowPrivilegeEscalation: false and the Pod Security Standard “restricted” remain right, but they are ineffective against this mechanism. The application opens and reads files with exactly the syscalls and permissions it needs for its work anyway, and the secrets belong to the same user it runs as. User namespaces and a minimal base image change nothing about that, because the valuable files are the application's own.

An egress block via NetworkPolicy does not help either, because the data needs no new connection. It leaves the server as the response to the attacker's request or as a download of the stored upload. A WAF rule on data: misses the uppercase spelling if it is case-sensitive, and even without that weakness covers only one of the two flaws. And Assert\File, as the advisory stresses, checks the real file at the attacker's path and therefore lets it pass.

You can find more on read-only containers, secrets and tenant separation in the series TYPO3 on Kubernetes under IT-Grundschutz.

Detection and verification

You can find out whether you are affected in two steps. First the version: composer show symfony/serializer shows the installed release, and anything below 5.4.56, 6.4.50, 7.4.23 or 8.1.11 contains both flaws. Then the code, because only there is it decided whether the flaw is reachable. Search for properties and constructor arguments of type UploadedFile, File, \SplFileInfo and \SplFileObject in classes that sit behind #[MapRequestPayload] or are passed to deserialize(). Finally, check every place where a serializer is built by hand with new Serializer(...) for the order of its normalizers.

Harder to answer is whether someone has already exploited the flaw. The advisories list no indicators, so the following hints are what I derive from the mechanism. If you log request bodies, upload fields that contain an object with a path field instead of a file are suspicious, as are values that start with a data: that is not lowercase and contain .. segments. Without such logs, the traces remain in the upload storage itself. Stored uploads whose content looks like configuration, for example lines with APP_SECRET or key material, stand out, as do files that are missing from their original location after an upload. After the update, an accumulation of 422 responses on upload endpoints is a sign that someone keeps trying.

If you suspect an attack, the order is patch, rotation, review. After the update, replace everything the PHP process could read: APP_SECRET, database credentials, API keys from the .env files and the secrets decryption key, which the Symfony documentation renews with secrets:generate-keys --rotate. If the ServiceAccount token was mounted, the Kubernetes documentation says it loses its validity as soon as the pod is deleted, so restarting the pods is the first step. Then check the audit log for requests with that token from foreign addresses, and roll out the image again so that moved files are back in place.

Recommendation for operators

Act now if a publicly reachable Symfony application with a Serializer older than the fixed versions deserializes client data into types with file properties. This mainly concerns APIs that have accepted uploads via DTOs since Symfony 8.1. Apply the update immediately, check self-built serializers for the order of their normalizers and rotate the secrets that were in the project directory if you cannot rule out that the endpoint has already been probed.

Monitoring is enough if the Serializer is in the lockfile but nowhere translates client data into file objects, for example in a TYPO3 installation where only one extension uses it for internal exports. The update still belongs in the next regular maintenance window, not least because a whole series of further Symfony advisories appeared on the same day.

Regardless of the individual case, what I say about every vulnerability applies: patches and updates belong automated. When Renovate or Dependabot propose new versions of the Composer packages as merge requests on their own and the pipeline tests the rebuilt image against staging, a security release like this one is a matter of hours instead of weeks. A cooldown period for routine updates protects against compromised packages, and you exempt security updates from it. The new 422 response for upload tests is a good example, because a test that simulated uploads via JSON then fails in the pipeline and not with the first real customer. If you update by hand instead, a flaw with public example requests easily stays open for four weeks during the holiday season, followed by a bulk update where nobody knows which change broke what.

Frequently asked questions about CVE-2026-118515 and CVE-2026-121623

I run TYPO3. Am I affected?+

The TYPO3 core does not ship the Symfony Serializer. Use composer why symfony/serializer to check whether an extension pulls it in and translates client data into file objects.

Is the update enough on its own?+

For the FrameworkBundle serializer, yes. If you build a serializer yourself, the DataUriNormalizer has to come before the ObjectNormalizer, otherwise the flaw stays open.

Why does Assert\File not stop the attack?+

The check examines the file at the path the attacker chose. A real file sits there, so it passes. Only is_uploaded_file() checks where the file came from.

What should I do if the flaw may already have been exploited?+

Apply the update first. Then rotate everything the PHP process could read, from APP_SECRET to the secrets decryption key. Restarting the pods invalidates a mounted ServiceAccount token.

Conclusion

Both flaws are small if you only look at the fix, and large if you ask what they reveal about the design. UploadedFile carries a promise: PHP itself received this file. A constructor that lets this promise be made with arbitrary arguments becomes dangerous as soon as a generic mechanism like the ObjectNormalizer feeds it data from outside. The data URI flaw tells the same story from the other side, because validator and consumer understood the same string differently.

The transferable lesson I draw from this: a type that expresses trust must never be created from client data. That applies to files and paths as well as to objects with flags for tests, roles or already verified states. It is worth going through your own DTOs once with this in mind, looking at which classes the Serializer builds there and whether one of them promises more than the request can prove.

Sources

I review your Symfony applications for DTOs that turn client data into file objects, and I harden the containers they run in.

Code review of upload and Serializer paths, secrets moved from the project directory into Kubernetes Secrets, open_basedir and read-only containers, Composer gates and update automation with Renovate.

Platform operations instead of consulting on paper: I review, patch and harden your infrastructure continuously.

About the author

Photo of Kai Ole Hartwig.

Kai Ole Hartwig

Freelance DevSecOps consultant · OnlyOle Consulting

Programming since 2002 – self-taught, set up my own business with KO-Web in 2012. Over 100 projects, with a focus on security, performance, automation and quality. Today freelance: DevSecOps consulting, training and software development.