The incident
We had a template expression validator in our reporting microservice. Users could write expressions like {{customer.name}} - {{order.total | currency}} to define report column formats. Our validator checked that expressions were syntactically valid before saving them. The validator had been running in production for eight months without issues.
On a Tuesday afternoon, our monitoring dashboard showed one of the reporting service instances pegging a CPU core at 100% for about 90 seconds. Then it recovered. No exceptions were logged. No alerts fired (our CPU alarm was set at 95% sustained for 5 minutes — this spike resolved before triggering it). Two days later, the same pattern: one instance, 100% CPU, 90 seconds, then recovery. We assumed it was a GC pause or a pathological query from the database.
The third time it happened, we had distributed tracing running and were able to correlate the CPU spike with a specific request. The request was to our template validation endpoint. The input was a 95-character string that a user had constructed manually.
Finding the regex
We tried reproducing the issue by calling the validation endpoint with the exact string from the trace. Every single time, the endpoint took 87 seconds to respond and returned a CPU spike. With every other input we'd ever tested, the endpoint responded in under 5ms. We had found our ReDoS (Regular Expression Denial of Service) vulnerability.
The problematic regex was buried in a validation helper we'd written early in the project:
// Validator for template expressions — DANGEROUS pattern
private static readonly Regex TemplateExpressionRegex = new(
@"^(\s*\{\{(\w+\.)*\w+(\s*\|\s*\w+)*\}\}\s*)+$"
);
At first glance this looks like a reasonable pattern for validating expressions. But it contains multiple layers of nested quantifiers. The outer group (\s*...\s*)+ matches "one or more template expressions". Inside it, (\w+\.)* matches "zero or more dot-separated identifiers". And (\s*\|\s*\w+)* matches "zero or more pipe-separated filters".
Why this causes catastrophic backtracking
Catastrophic backtracking happens when the regex engine tries an exponential number of ways to match a string that ultimately doesn't match. For a string that is almost valid but has one invalid character at the end, the engine explores every possible combination of how the groups can divide the string before concluding it doesn't match.
Consider the input {{a.b.c.d.e.f.g.h.i.j.k!}} — valid template start, but the ! at the end makes it invalid. The (\w+\.)* group can match a. then b. then c. ... or a.b. then c. ... or many other combinations. The engine tries all of them before giving up. With 10 path components, there are 2^10 = 1,024 possible ways to divide the match. With 20 components, there are over 1 million. The input that triggered our incident had the equivalent of about 30 path components — the engine was exploring over a billion paths.
// Demonstration of the exponential behavior
using System.Text.RegularExpressions;
// This pattern has catastrophic backtracking potential
var dangerousPattern = @"^(\s*\{\{(\w+\.)*\w+(\s*\|\s*\w+)*\}\}\s*)+$";
// This input causes exponential backtracking — DO NOT run without a timeout
var maliciousInput = "{{a.b.c.d.e.f.g.h.i.j.k.l.m.n.o!}}";
try
{
// Without timeout — this will hang for many seconds or minutes
var badRegex = new Regex(dangerousPattern);
// badRegex.IsMatch(maliciousInput); // DO NOT call this without timeout
// With timeout — throws RegexMatchTimeoutException after 2 seconds
var safeRegex = new Regex(dangerousPattern, RegexOptions.None,
TimeSpan.FromSeconds(2));
bool result = safeRegex.IsMatch(maliciousInput);
}
catch (RegexMatchTimeoutException ex)
{
// Catches the timeout — log and return a validation error to the user
_logger.LogWarning("Regex timeout on input of length {Length}: {Ex}",
maliciousInput.Length, ex.Message);
return ValidationResult.Fail("Expression is too complex to validate.");
}
Two fixes: timeout and rewrite
We implemented both fixes. The timeout is the safety net — any future patterns that have backtracking issues are caught before they can hang indefinitely. The rewrite eliminates the backtracking for this specific pattern.
// Fix 1: Add timeout to all Regex instances in the service (immediate safety net)
// In a static constructor or as a static readonly field:
private static readonly Regex TemplateExpressionRegex = new(
@"^(\s*\{\{(\w+\.)*\w+(\s*\|\s*\w+)*\}\}\s*)+$",
RegexOptions.None,
TimeSpan.FromSeconds(2) // timeout added
);
// Fix 2: Rewrite to eliminate nested quantifiers (permanent fix)
// Split the validation: first check overall structure, then validate each expression
private static readonly Regex ExpressionBlockRegex = new(
@"\{\{[^}]+\}\}", // matches {{ ... }} without nested quantifiers
RegexOptions.None,
TimeSpan.FromSeconds(1)
);
private static readonly Regex IdentifierRegex = new(
@"^\w+(\.\w+)*(\s*\|\s*\w+)*$", // validates the content inside {{ }}
RegexOptions.None,
TimeSpan.FromSeconds(1)
);
public ValidationResult ValidateTemplate(string template)
{
// Find all expression blocks — no catastrophic backtracking possible
var blocks = ExpressionBlockRegex.Matches(template);
foreach (Match block in blocks)
{
var content = block.Value[2..^2].Trim(); // strip {{ and }}
if (!IdentifierRegex.IsMatch(content))
return ValidationResult.Fail($"Invalid expression: {block.Value}");
}
// Verify the entire string is accounted for by expression blocks and whitespace
var withoutExpressions = ExpressionBlockRegex.Replace(template, "");
if (!string.IsNullOrWhiteSpace(withoutExpressions))
return ValidationResult.Fail("Template contains text outside expression blocks.");
return ValidationResult.Ok();
}
Fix 3: RegexOptions.NonBacktracking (.NET 7+)
If you're on .NET 7 or later, RegexOptions.NonBacktracking is the most comprehensive solution. It uses a finite automaton algorithm that runs in O(n) linear time relative to the input length — catastrophic backtracking is mathematically impossible. The tradeoff is that some regex features (lookaheads, backreferences, atomic groups) are not supported.
// .NET 7+: NonBacktracking guarantees linear-time matching
// Limitation: lookaheads, backreferences, and some advanced features are unsupported
private static readonly Regex SafeRegex = new(
@"^\w+(\.\w+)*$",
RegexOptions.NonBacktracking
);
// [GeneratedRegex] + NonBacktracking: compile-time pattern, runtime safety
[GeneratedRegex(@"^\w+(\.\w+)*$", RegexOptions.NonBacktracking)]
private static partial Regex IdentifierPattern();
What we changed permanently across the codebase
After this incident, we ran a codebase-wide search for all new Regex( calls that didn't specify a matchTimeout parameter, and added a 2-second timeout to each one. We also added a Roslyn analyzer rule (using a custom DiagnosticAnalyzer) that reports a warning when a Regex is instantiated without a timeout — this prevents the problem from being reintroduced.
We also added a specific integration test for every user-facing input validation: submit a 200-character input of repeated nearly-valid characters designed to trigger backtracking (like the !-terminated template string that triggered our incident). If the test takes more than 100ms, the pattern has a backtracking problem and the test fails.
The generalizable lesson
Catastrophic backtracking is a class of vulnerability (formally called ReDoS — Regular Expression Denial of Service) that is easy to introduce accidentally and impossible to detect by code review alone. The patterns that are dangerous look completely reasonable when you write them. They only misbehave on specific, pathological inputs — inputs that may only appear in production, from real users, with real data.
Three rules that prevent this class of problem: (1) always specify a matchTimeout when constructing a Regex, (2) use RegexOptions.NonBacktracking for any user-facing validation where supported features suffice, and (3) test your regex patterns with the Regex Tester, which enforces its own 5-second timeout — any pattern that times out in the tester will also time out in production.