How a single pattern can bring down an entire application, and how to reliably prevent it
A regular expression that matches short test inputs in milliseconds can suddenly take seconds or minutes on a only slightly longer, maliciously crafted input, because the PCRE engine tries exponentially many backtracking points. This phenomenon, known as catastrophic backtracking, is one of the most underestimated performance and security pitfalls in PHP applications that validate user input against regex patterns. This article shows how backtracking works mechanically, which pattern structures cause it to explode, and which concrete techniques systematically defuse the risk.
Table of Contents
- 1. How PCRE backtracking fundamentally works
- 2. Catastrophic backtracking: nested quantifiers as the cause
- 3. Practical example: a dangerous pattern and its fix
- 4. Configuring pcre.backtrack_limit correctly
- 5. preg_last_error and consistent error handling
- 6. Possessive quantifiers as a direct countermeasure
- 7. Atomic groups: the same idea without possessive syntax
- 8. Alternative strategies: simplify the pattern instead of patching it
- 9. Testing and monitoring: identifying ReDoS-prone patterns
- 10. Summary
- 11. FAQ
1. How PCRE backtracking fundamentally works
PHP's preg functions are built on the PCRE library, which uses a backtracking-based matching algorithm, unlike alternative approaches such as a genuine NFA simulation without backtracking points, as used for example by RE2. Backtracking specifically means: when the engine hits a quantifier such as + or * with several possible interpretations of how many characters it should consume, it first tries the greediest variant, but remembers a backtracking point it can return to if the rest of the expression later fails to match at that spot.
For the vast majority of patterns, the number of these backtracking points stays small and matching runs in linear or near-linear time relative to input length. Problems only arise once a pattern is ambiguous in the sense that the same input can be consumed through the same pattern in many different ways, because then the engine may, in the worst case, have to try each of these interpretations individually before finally determining that no match exists.
2. Catastrophic backtracking: nested quantifiers as the cause
The classic trigger for catastrophic backtracking is a quantifier nested inside another quantifier, where both levels overlap in their character set, for example the pattern (a+)+ or (a|aa)+. Given an input such as a long run of 'a' characters that deliberately does not match the pattern at the end, the engine has to try every possible way of splitting the a characters between the inner and outer repetition before finally giving up, and the number of these splits grows exponentially with input length.
The key mechanism behind this is that every additional 'a' character doubles the number of possible combinations, since it can either be consumed by the inner repetition a+ or by an additional iteration of the outer group. With twenty extra characters that already means over a million combinations, with thirty characters over a billion, growth that blows past any realistic response time and completely blocks a single PHP worker process for the duration of one request.
3. Practical example: a dangerous pattern and its fix
A realistic example from practice is a naive validation of email-like strings using a pattern such as ^([a-zA-Z0-9._-]+)+@, which is actually only meant to check the characters before the at sign, but accidentally wraps a quantifier around an expression that is already quantified itself. An input such as a very long string of valid characters with no trailing at sign pushes this pattern into the multi-second range with just a few dozen characters, even though the actual matching goal is simple.
The fix is almost always to remove the redundant outer nesting: ([a-zA-Z0-9._-]+)+ simply becomes [a-zA-Z0-9._-]+, without changing the actual desired matching result for valid inputs, since the outer repetition was functionally superfluous to begin with and only existed by accident when the pattern was assembled from several sub-expressions. This kind of redundancy arises particularly often in practice when patterns are copy-pasted from several sources or generated programmatically from building blocks.
<?php
declare(strict_types=1);
// Dangerous: nested quantifier over the same character range.
$dangerousPattern = '/^([a-zA-Z0-9._-]+)+@[a-zA-Z0-9.-]+$/';
// Fixed: the outer repetition was functionally superfluous.
$safePattern = '/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+$/';
$maliciousInput = str_repeat('a', 40) . '!';
// With $dangerousPattern, this call can block for several seconds.
$result = preg_match($safePattern, $maliciousInput);
4. Configuring pcre.backtrack_limit correctly
PHP limits the number of backtracking steps by default via the ini setting pcre.backtrack_limit, whose default value is one million. If that limit is exceeded during a matching operation, the engine aborts, preg_match returns false rather than an error, and the function preg_last_error reports the specific error code PREG_BACKTRACK_LIMIT_ERROR, which can be clearly distinguished from a regular non-match.
The value of one million is deliberately chosen high enough to avoid hindering most legitimate, even fairly complex patterns, yet low enough to abort catastrophic backtracking after at most a few hundred milliseconds rather than minutes. For applications that validate user input against externally configurable or generated patterns, it is worth deliberately setting a lower limit via ini_set right before the relevant preg call, combined with consistently checking preg_last_error instead of blindly trusting a successful return value.
<?php
declare(strict_types=1);
/**
* Validates an input against an externally configurable pattern and
* explicitly distinguishes a non-match from a backtracking abort.
*
* @param string $pattern An externally configured PCRE pattern
* @param string $subject The input to validate
* @return bool True if the pattern matched reliably
* @throws RuntimeException When the backtracking limit was exceeded
*/
function safeMatch(string $pattern, string $subject): bool
{
$previousLimit = ini_set('pcre.backtrack_limit', '200000');
try {
$result = preg_match($pattern, $subject);
if (preg_last_error() === PREG_BACKTRACK_LIMIT_ERROR) {
throw new RuntimeException('Pattern exceeded the backtracking limit.');
}
return $result === 1;
} finally {
ini_set('pcre.backtrack_limit', $previousLimit);
}
}
5. preg_last_error and consistent error handling
A common mistake in production code is interpreting the return value of preg_match only as a binary match or no match, without distinguishing between a genuine non-match and a silent abort caused by hitting a limit. Since both cases return the same value of false or 0, an application can wrongly assume an input is invalid during catastrophic backtracking, even though in reality the check never actually finished.
Besides PREG_BACKTRACK_LIMIT_ERROR, there are further specific error codes such as PREG_RECURSION_LIMIT_ERROR, controlled via pcre.recursion_limit, as well as PREG_INTERNAL_ERROR and PREG_JIT_STACKLIMIT_ERROR, each covering a different internal resource limit. For security-critical validation, such as form input from the public internet, preg_last_error should be checked consistently after every preg_match call, and on failure treated deliberately as 'validation failed' rather than as 'input invalid'.
6. Possessive quantifiers as a direct countermeasure
PCRE supports possessive quantifiers in the form ++, *+, and ?+, which differ from regular, greedy quantifiers in that they leave no backtracking points behind once they have consumed characters. A possessive quantifier does not try several splits, it consumes the maximum number of characters exactly once and, if the rest of the pattern later fails, gives up immediately and completely instead of going back to test alternative splits.
For the problematic pattern shown earlier, that means: (a+)+ becomes, in its possessive variant (a++)++, functionally a fixed, no-longer-backtrackable consumption, making catastrophic backtracking structurally impossible for this specific expression. The trade-off is that possessive quantifiers can prevent certain rare but legitimate matching cases where backtracking would actually have been necessary, which is why they should be applied deliberately rather than blanket-applied to every quantifier in a pattern.
<?php
declare(strict_types=1);
// Possessive quantifiers structurally prevent backtracking and thus
// catastrophic backtracking, at the cost of a bit of flexibility.
$possessivePattern = '/^([a-zA-Z0-9._-]++)++@[a-zA-Z0-9.-]+$/';
$result = preg_match($possessivePattern, 'user.name@example.com');
7. Atomic groups: the same idea without possessive syntax
Atomic groups, written as (?>...), conceptually achieve the same goal as possessive quantifiers, but apply the principle to an entire group instead of a single quantifier: once the engine has successfully passed through the group once, every internal backtracking point inside that group is discarded, and a later failure in the rest of the pattern can no longer jump back into the group to try an alternative internal split.
Atomic groups are the more suitable tool particularly when it is not a single quantifier but an entire sequence of several elements that needs protecting from backtracking, for example (?>[a-z]+[0-9]+) as a whole. They can also be combined with older PCRE versions that may not yet support possessive quantifiers, though current PHP versions running PCRE2 fully support both constructs, making the choice primarily a question of readability and structure for the pattern at hand.
8. Alternative strategies: simplify the pattern instead of patching it
Besides possessive quantifiers and atomic groups, it is often worth asking the more fundamental question of whether a complex, potentially ambiguous pattern is even necessary, or whether the same goal can be reached through several simple, guaranteed-linear patterns combined with regular PHP code. Email validation, for example, can often be split into a rough format pre-check with a simple pattern and a subsequent, more precise check using filter_var and FILTER_VALIDATE_EMAIL, which completely avoids hand-written, potentially dangerous backtracking constructs.
For very complex grammars, such as parsing structured formats like CSV dialects or nested bracket expressions, a dedicated, hand-written parser is often not only safer but also more maintainable than a single, highly complex regex pattern, even if the initial implementation effort seems higher. The rule of thumb is: once a pattern needs more than two or three nested quantifier levels, it is worth fundamentally rethinking the approach.
9. Testing and monitoring: identifying ReDoS-prone patterns
Static analysis tools such as the npm-package-based safe-regex checker or rxxr2 can automatically inspect patterns for structural traits of catastrophic backtracking and can be integrated into a CI pipeline to check new or changed patterns before merge. In addition, a targeted unit test per security-relevant pattern is worthwhile, one that matches a deliberately constructed, long, ultimately non-matching input against the pattern while enforcing a hard time limit via set_time_limit or a test framework's own timeout mechanism.
In production, monitoring php-fpm request duration complements these preventive measures: a sudden spike of isolated, extremely slow requests amid otherwise stable load is a strong indicator of catastrophic backtracking, triggered by a previously undetected, malicious, or simply unfortunately chosen input, and justifies a targeted review of the patterns involved, even when prior static analysis came back clean.
| Pattern trait | Risk | Mitigation | Example |
|---|---|---|---|
| Nested quantifier over the same character range | Exponential, catastrophic backtracking | Remove the redundant outer group | (a+)+ to a+ |
| Alternation with overlapping options | Exponential to polynomial | Make options disjoint or group atomically | (a|aa)+ to a+ |
| Very long input against an open pattern | High backtracking count despite a linear pattern | Set pcre.backtrack_limit deliberately low | Form fields without a length limit |
| Missing check of preg_last_error | Silent failure interpreted as non-match | Check the error code after every preg_match | PREG_BACKTRACK_LIMIT_ERROR ignored |
| Highly complex grammar in one pattern | Unmaintainable and potentially ambiguous | Use a dedicated parser instead of regex | CSV dialects, nested brackets |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
Regex Backtracking: The Essentials at a Glance
Exponential growth
Nested, overlapping quantifiers double the number of backtracking steps with every additional character.
Limit as an emergency brake
pcre.backtrack_limit stops runaway matching, but must be actively checked via preg_last_error.
Possessive quantifiers
The ++ syntax structurally prevents backtracking, making catastrophic backtracking impossible for the affected parts.
Simplicity as a principle
Beyond two or three nested levels, a dedicated parser is often worth more than another regex patch.