Cover

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 source of HeaderUtils::split(): the token class includes the space and the group closes with (?<!\s)

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.

1Ă— Cache-Control, 2.455 s

4Ă— Cache-Control, 8.805 s

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.

Four real targets in Burp: the payload in Cache-Control and the response time, with the hostnames blacked out

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-Control header longer than 50-100 bytes

The real values (no-cache, max-age=0, no-store) are a few dozen bytes.

References