CVE-2026-108082: Symfony ReDoS. One request. Seconds of CPU

TL;DR
A regex in symfony/http-foundation goes into catastrophic backtracking (ReDoS) if you send
it a header built on purpose. On its own it’s worth little, but you find it reachable by
default, remotely, unauthenticated, on basically every version of Symfony from 2018 to today,
and therefore on everything built on top of it: Laravel, Drupal and friends. A single GET can
pin a core at 100% for several seconds. With a hundred-ish requests you can time out a lot of
Laravel webapps. CVE-2026-108082, CVSS 8.7.
Fixed by updating symfony/http-foundation to 6.4.48 / 7.4.21 / 8.1.9 (not the first patch
round).
The story
I wasn’t looking for this. I’d started poking at how the various proxies, web servers and
frameworks parse the Accept-* headers, just to see if anything fell out of the way they
handle them. What fell out was a different header, Cache-Control, and a function that
tokenizes all of them.
Honestly I didn’t think it deserved a post, it’s a trivial regex dos. But the interesting part for me isn’t the bug itself, it’s how reachable and how widespread it is: basically every version of Symfony and Laravel for years is affected, and out there I found a ton of Laravel webapps that time out with a handful of requests. We usually give this kind of bug little weight, but when a company finds its site or webapp offline it isn’t exactly painless.
Impact
No code here, just the size of the thing.
The flaw is inside symfony/http-foundation, which is a library, not a framework. Symfony uses
it, but so does everything on top of it: Laravel (its Request extends Symfony’s), Drupal,
PrestaShop, Shopware and a long list. It’s vulnerable from version 4.1 (2018) up to the October
2026 patches, so if you have a PHP app on this stack lying around, it’s very likely exposed.
You reach it without doing anything: any HTTP request, no login, no session, no special headers beyond the one carrying the payload. The parsing happens before routing and before authentication, so it applies to every endpoint, even one that answers 404 or 401.
The cost: a single request can burn several seconds of CPU. In the worst case I measured on a real host, one request took 12.7 seconds. At that point you don’t need traffic volume: a few in parallel are enough to saturate the backend’s cores.
Root cause, briefly
The function is HeaderUtils::split(), which splits a header into its parts. The piece of regex
that matches a “token” is [^",=]+, meaning any character except quotes, comma and equals. The
space isn’t in the list, so it’s a valid character inside a token. Right after, the group closes
with (?<!\s), a check that forces the match not to end on a space (it’s there to trim trailing
spaces).

The payload fits in twenty bytes: one letter, seventeen spaces, a comma.
a ,x
The engine wants a match that doesn’t end on a space, but it can split those seventeen spaces across the token’s repetitions in 2^17 ways, all of them to be thrown away because they end on a space. It tries them one by one before giving up. The advisory measures about 813 ms for a run like that in an 8 KB header on PHP 8.5.
What lands in it is Cache-Control, which Symfony parses by itself when it builds the request
object, with no condition, on every request. The same function also handles Accept,
Accept-Language, Cookie and Forwarded, but those only get parsed if something asks for
them explicitly, while Cache-Control doesn’t, it’s automatic.
Laravel
Here it gets worse, and not because of whoever wrote the app. Laravel, by the way it builds the
request, ends up parsing the headers three times: it creates the object, rebuilds it, then
replaces the headers. Nobody decided that, it just comes out of its Request::capture().
Result, the same twenty bytes cost three times as much as plain Symfony, which parses once (like
Drupal).
Then there are cases where you pay even more. If a route uses a FormRequest, that does two
more parses for each one that resolves. And Accept, which normally wouldn’t be touched,
becomes a second vector when it’s the framework itself reading it: expectsJson() parses it,
and the auth middleware calls it on every request it has to turn away, to decide whether to
answer with a redirect or with JSON.
In the lab
Before going out into the wild I reproduced the whole thing locally, Laravel behind nginx, both
with the default config. The request is any GET, with the payload inside Cache-Control:
GET / HTTP/1.1
Host: localhost
Cache-Control: a ,xa ,xa ,x[... repeated to 8,160 bytes]
The value is a + 17 spaces + ,x, repeated 408 times = 8,160 bytes, just under nginx’s 8 KB
per-header limit.
nginx, by default (large_client_header_buffers 4 8k), accepts up to four headers with the same
name and merges them into one before passing them to PHP, without re-checking the length of the
result. So by sending several copies of the same Cache-Control the cost adds up. The timings,
Laravel + nginx:
| Cache-Control | bytes | time |
|---|---|---|
| none | 0 | 45 ms |
| 1Ă— | 8,160 | 2.455 s |
| 2Ă— | 16,320 | 4.846 s |
| 3Ă— | 24,480 | 6.860 s |
| 4Ă— | 32,640 | 8.805 s |
Each copy adds about two seconds, linearly. Four headers in a single request, almost nine seconds of CPU, and the response is a perfectly ordinary 200.


And this is the saturation live. On the left the script (redos_cc_timing.py) sends 100
requests with four 8 KB Cache-Control headers and concurrency 8: the response times climb
from around thirty up to sixty seconds and the timeouts start. On the right the browser, sitting
on the Laravel homepage, gets a 504 on every refresh.
In the wild
I tried the thing for a few weeks on the bug bounty programs I had access to, and this is where it gets interesting, because what actually happens depends on what’s in front of the backend. A proxy, a WAF, a load balancer, any node in the middle can cut, merge or resize the headers, and that decides how much payload reaches the app. The backend’s power matters too: the same payload on a small machine costs seconds, on a big one much less.
One thing up front: these are measurements taken from outside, black-box. I know the edge,
because I was going through it, but the server behind it I sometimes deduce from the error page
or the Server header, and a load balancer or a WAF that leaves no trace I just don’t see. So
the “what was in front” column is partly deduced, not a certainty.
A few real cases, anonymized, with the numbers taken from the reports:
| payload that got through | baseline | with the payload |
|---|---|---|
| 8,160 B, one header | 0.12 s | 4.1 s |
| 8,160 B, one header | 1.05 s | 4.85 s |
| 8,160 B, one header | 0.27 s | 7.26 s |
| 9,720 B, one header (behind Cloudflare) | 0.52 s | 11.7 s |
| 24,484 B, 3 merged headers (nginx, no edge) | 0.2 s | 5.3 s |
| 30,480 B, 4 merged headers | 0.27 s | 12.7 s |
| 32,640 B, 4 merged headers | 0.27 s | 5.87 s |
| 32,640 B, 4 merged headers | 1.32 s | 10.83 s |
| 49,080 B, 3 headers of 16 KB | 0.27 s | 9.58 s |
| 80,400 B, one header | — | 12.1 s |
Two things decided how much it hurt. The first, how much payload the node in front let through:
from a single 8 KB header up to 80 KB in one header, or 30-50 KB spread across several
Cache-Control headers that nginx merged into one. The second, how big the backend was: the
same 8 KB cost 4 seconds on one target and 7 on another, and the same 32 KB ran from 5.9 to
almost 11 seconds, just for the machine behind. In every case one request, no authentication, a
normal response.
The ceiling on the payload was set by the node in front. On nginx you just repeated the same
Cache-Control and the cost added up, and that’s where the biggest numbers came from. Where
Apache was instead, you stayed at 8 KB per header, because Apache doesn’t fall for duplicates:
it merges them and then re-checks the length, and answers 400.

A personal note, while I’m at it. Of all the reports I sent across the various platforms only one was accepted, all the others closed as out-of-scope. I expected that, but I tried anyway, because the impact was high for very little effort. And in my opinion the programs get this wrong. An application DoS like this makes the backend unusable with a request that sails straight through, and it isn’t the same thing as a volumetric network DDoS, which needs resources not everyone has and against which there are already plenty of protections. Here all it takes is one well-formed GET that nobody stops, and it fries the CPU.
How to fix it
Update symfony/http-foundation. Those are the library’s version numbers, not
Symfony-the-framework’s and not Laravel’s, so on Laravel you don’t need to update everything,
just composer update symfony/http-foundation. The right version is 6.4.48, 7.4.21 or 8.1.9
depending on your branch. Don’t stop at the first patch round (5.4.54 / 6.4.46 / 7.4.19 /
8.1.7): that one fixed the ReDoS but broke another piece, and it took a later release to sort
out. On the 5.4 branch the advisory lists nothing after 5.4.54, and 5.4 is out of support
anyway.
If you can’t release right away, a WAF rule covers you in the meantime, since legitimate traffic doesn’t come anywhere close:
reject any request with a
Cache-Controlheader longer than 50-100 bytes
The real values (no-cache, max-age=0, no-store) are a few dozen bytes.
References
- GitHub Security Advisory: GHSA-r925-mgvr-h9cc
- CVE / MITRE: CVE-2026-108082
- NVD / NIST: CVE-2026-108082