Alexandre Daubois Get in touch

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.

The two lines that compile a stranger’s regex

// 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.

I read the half of RFC 9485 about syntax

The assumption under those two lines: the budget PHP gives a regex was sized for a regex the developer wrote.

There is a budget. PHP ships 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 no unbounded regex, only a per-node cost and a document big enough, and the document is usually the application’s 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 not 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.

A hundred times smaller, on two call sites

+    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);
+ }
+ }
+ }

Same query, same document, on v7.4.12: 31.7 ms instead of 2427.9.

This fixes the budget and not the assumption. The pattern is still attacker-controlled, there is still no length cap, and nothing checks that it belongs to the I-Regexp subset the RFC defines. A 10000 backtrack ceiling is a cheap floor under the damage rather than a decision 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 accumulates almost no backtracks and an email-shaped pattern over a 540 KB subject stays far below 10000. The advisory’s line about the @ hiding the backtrack errors is not what happens, though. Exceeding the limit makes preg_match() return false with no warning at any error_reporting level, and the @ suppresses compilation failures on malformed patterns instead. A capped match reads as no match, before the fix and after it.

Which runtimes hand you a brake

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 it was sized for. Mine was sized for me, and a regex costing a millisecond becomes a denial of service the moment something runs it once per row.

Sources