Blog ·
CVE-2026-45756: attacker-controlled regex in Symfony JsonPath filters (ReDoS)
A JSONPath filter taken from a query string lets an attacker pick the regex a Symfony app runs, once per node in the document.
- CVE
- CVE-2026-45756
- Severity
- low
- Affected
symfony/json-path >=7.3.0 <7.4.12, >=8.0.0 <8.0.12
Give a user control over a JSONPath filter and you have given them control over the regular expression your server runs. Send $.items[?search(@, "(a+)+$")] at a document holding two thousand strings and one Symfony worker spends two and a half seconds of CPU on that single request. That is CVE-2026-45756, in symfony/json-path before 7.4.12 and 8.0.12.
Where the pattern ends up
// JsonCrawler::evaluateFunction(), before the fix
'match' => match (true) {
\is_string($value) && \is_string($argList[1] ?? null) => (bool) @preg_match(\sprintf('/^%s$/u', $this->transformJsonPathRegex($argList[1])), $value), // <- $argList[1] is the caller's regex
default => false,
},
'search' => match (true) {
\is_string($value) && \is_string($argList[1] ?? null) => (bool) @preg_match("/{$this->transformJsonPathRegex($argList[1])}/u", $value), // <- and here
default => false,
},
private function transformJsonPathRegex(string $pattern): string
{
$result = '';
$inCharClass = false;
$i = -1;
while (null !== $char = $pattern[++$i] ?? null) {
switch ($char) {
case '\\': $char .= $pattern[++$i] ?? '';
break;
case '[': $inCharClass = true;
break;
case ']': $inCharClass = false;
break;
case '.': $inCharClass || $char = '[^\r\n]';
break;
}
$result .= $char;
}
return $result;
}For readers coming from elsewhere: match (true) is a switch expression, @ in front of a call suppresses the warnings it would emit, and /u is the UTF-8 modifier on the pattern.
$argList[1] is the second argument of the JSONPath function, so it is whatever string sat between quotes in the query. transformJsonPathRegex() rewrites . to [^\r\n] outside character classes and leaves the rest alone. The pattern reaches preg_match() with its quantifiers intact, and preg_match() is a backtracking engine, so (a+)+$ against forty a characters followed by one ! is the textbook catastrophic case.
The part I got wrong
Those two lines assume that PHP sized its regex budget for a regex the developer wrote, and PHP does ship one: pcre.backtrack_limit, default 1000000, and PCRE gives up once a match exceeds it. I measured the textbook pattern on the last vulnerable release, v7.4.8, on PHP 8.5.10 with JIT on. One node, 1.3 ms, then preg_last_error_msg() reports Backtrack limit exhausted. Every other pathological pattern I tried landed between 0.7 and 2.4 ms. A million backtracks is a sane ceiling when the pattern came from your own source file, and it is why this CVE scores 2.7 and the advisory rates it low.
The multiplier is the document. A filter runs the function once per candidate node, so two thousand strings in the JSON means two thousand times 1.3 ms, and I measured 2427.9 ms for that one query on v7.4.8. The attacker needs a per-node cost and a document big enough, and in most deployments the application supplies that document from its own data.
I wrote this component. I added it as experimental in January 2025, and the first version was worse than what shipped: (bool) @preg_match(\sprintf('/^%s$/', $args[1]), $value), with no transformation at all. The docblock on transformJsonPathRegex() links RFC 9485 section 5.4, the paragraph explaining how to convert an I-Regexp for PCRE. Section 8 of that same RFC, Security Considerations, tells implementers using existing regexp libraries “to check their documentation to see if mitigations are configurable, such as limits in resource consumption”. I implemented the conversion section and skipped that one.
The tests asserted that match() and search() return the right nodes for reasonable patterns, which stayed true before the fix and after it, so nothing in the suite could have caught this.
What I changed
+ private const REGEX_BACKTRACK_LIMIT = 10000;
'match' => match (true) {
- \is_string($value) && \is_string($argList[1] ?? null) => (bool) @preg_match(\sprintf('/^%s$/u', $this->transformJsonPathRegex($argList[1])), $value),
+ \is_string($value) && \is_string($argList[1] ?? null) => $this->safeRegexMatch(\sprintf('/^%s$/u', $this->transformJsonPathRegex($argList[1])), $value),
default => false,
},
'search' => match (true) {
- \is_string($value) && \is_string($argList[1] ?? null) => (bool) @preg_match("/{$this->transformJsonPathRegex($argList[1])}/u", $value),
+ \is_string($value) && \is_string($argList[1] ?? null) => $this->safeRegexMatch("/{$this->transformJsonPathRegex($argList[1])}/u", $value),
default => false,
},
+ private function safeRegexMatch(string $pattern, string $subject): bool
+ {
+ $previousLimit = ini_set('pcre.backtrack_limit', self::REGEX_BACKTRACK_LIMIT);
+ try {
+ return @preg_match($pattern, $subject);
+ } finally {
+ if (false !== $previousLimit) {
+ ini_set('pcre.backtrack_limit', $previousLimit);
+ }
+ }
+ }The same query on the same document takes 31.7 ms on v7.4.12 instead of 2427.9, and that fixes the budget while leaving the assumption in place. The attacker still controls the pattern, nothing caps its length, and nothing checks that it belongs to the I-Regexp subset the RFC defines. A 10000 backtrack ceiling caps the damage per node and says nothing about whose regex this is. If your application hands user input to a JSONPath filter, cap the pattern length yourself before it reaches the crawler, because the component will not.
The cap does not hurt well-behaved patterns, because a linear pattern needs few backtracks and an email-shaped pattern over a 540 KB subject stays far below 10000. The advisory says the @ hides the backtrack errors, and my runs say otherwise: when a match exceeds the limit, preg_match() returns false with no warning at any error_reporting level, and the @ suppresses compilation failures on malformed patterns instead, so a capped match reads as no match, before the fix and after it.
Other runtimes, other limits
PHP having a backtracking budget at all puts it in a small group. Go’s regexp is RE2 and its package doc states the property in the header comment, “guaranteed to run in time linear in the size of the input”, so this bug class does not exist there and you pay for it in missing backreferences. .NET gives you new Regex(pattern, options, matchTimeout), opt-in, and Microsoft’s own documentation example for that constructor uses (a+)+$. Java, Python and Node ship nothing equivalent in their standard regex engines, so the same two lines in those languages have no floor at all.
Cloudflare’s outage of 2 July 2019 is this bug with no budget anywhere in the stack. A WAF rule update “contained a regular expression that backtracked enormously and exhausted CPU used for HTTP/HTTPS serving”, and CPU went to nearly 100% across their network.
Timeline
- Reported: by Himanshu Anand (
unknownhad) - Fixed: 12 May 2026, commit
1ac2d47, by me - Released: 20 May 2026, Symfony 7.4.12 and 8.0.12
- Published: 20 May 2026 on symfony.com, 28 May 2026 in the GitHub Advisory Database
- Patched versions: 7.4.12, 8.0.12
If you compile a pattern you did not write, look up your engine’s default budget and ask who sized it for whom. PHP sized mine for the developer, and the arithmetic I’d reuse is the per-node one: a millisecond per match, times however many rows the filter visits, before I call a regex cheap.
Sources
- The GitHub advisory, GHSA-8v8v-g73j-492j.
- The Symfony security post for CVE-2026-45756.
- The fix, commit 1ac2d47, and the commit that added the component, f34f4c4.
- RFC 9485, I-Regexp, sections 5.4 and 8.
- The PHP manual on pcre.backtrack_limit.
- The Go regexp package documentation.
- The .NET Regex constructor with a match timeout.
- Cloudflare’s post-mortem of the 2 July 2019 outage.