Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix ReDoS in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

A validation regex with a nested quantifier backtracks catastrophically on the default engine: a 30-character string pins the request thread. Compile it with RegexOptions.NonBacktracking, whose automaton matches in linear time for any pattern; cap input length; or, where NonBacktracking will not do, pass a match timeout so it throws RegexMatchTimeoutException instead of hanging.

On this page

Steps

  1. Find every Regex applied to request data (email, URL and slug validators). Flag nested quantifiers ((X+)+, (X*)*), overlapping alternations ((a|aa)+) and any such group followed by a pattern that can fail - that is where catastrophic backtracking lives.
  2. Compile the expression with RegexOptions.NonBacktracking. .NET's non-backtracking engine matches with an automaton, so run time is linear in the input for every pattern; the expression that is catastrophic under the default engine is safe.
  3. Cap the input length before matching (254 for an email) as cheap defence in depth.
  4. Where a pattern cannot use NonBacktracking (it forbids backreferences, lookaround and RightToLeft), pass a match timeout instead - new Regex(pattern, options, TimeSpan.FromMilliseconds(100)) - so a pathological input throws RegexMatchTimeoutException rather than hanging. Prefer NonBacktracking, which removes the blow-up rather than capping it.
  5. Set a global default with AppContext.SetData("REGEX_DEFAULT_MATCH_TIMEOUT_IN_MILLISECONDS", ...) as a backstop, and prefer a parser or a plain string check over a hand-rolled regex where you can.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/Validator/impl/VulnerableEmailValidator.csC#
public sealed class VulnerableEmailValidator : IEmailValidator
{
    private static readonly Regex Email =
        new(@"^([a-zA-Z0-9._%+-]+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$");

    public bool IsValid(string input) => Email.IsMatch(input);
}

With IMPL=vulnerable, 2 of 9 tests fail (2 exploit tests).

The fix: NonBacktracking engine

Fixedsrc/Validator/impl/FixedEmailValidator.csC#
public sealed class FixedEmailValidator : IEmailValidator
{
    private const int MaxLength = 254; // RFC 5321 caps an email address at 254 characters

    private static readonly Regex Email = new(
        @"^([a-zA-Z0-9._%+-]+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
        RegexOptions.NonBacktracking);

    public bool IsValid(string input)
    {
        if (input.Length > MaxLength)
        {
            return false;
        }

        return Email.IsMatch(input);
    }
}

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Validator/impl/WrongEmailValidator.csC#
public sealed class WrongEmailValidator : IEmailValidator
{
    private const int MaxLength = 100_000; // "nobody sends a 100 KB email" - but ~30 chars is plenty

    private static readonly Regex Email =
        new(@"^([a-zA-Z0-9._%+-]+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$");

    public bool IsValid(string input)
    {
        if (input.Length > MaxLength)
        {
            return false;
        }

        return Email.IsMatch(input);
    }
}

With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Validator.Tests/ExploitTests.csC#
// Exploit: each payload is a short, benign string - letters and one terminator, with no
// "@" - that a linear validator rejects in microseconds but a backtracking one grinds on
// for seconds to minutes. The validator runs on a background thread with a deadline; the
// attack is defeated only if it returns a verdict within the budget. A hang means the
// deadline fired, which is the denial of service.
public sealed class ExploitTests
{
    private const int BudgetMs = 1000; // well below the 60s run limit, far above a linear match

    [Theory]
    [MemberData(nameof(Support.Evil), MemberType = typeof(Support))]
    public void ExploitStaysWithinTimeBudget(string payload)
    {
        var (completed, result) = Deadline.ValidateWithin(Support.Validator(), payload, BudgetMs);
        Assert.True(completed, $"validation did not finish within {BudgetMs}ms - catastrophic backtracking (ReDoS)");
        Assert.False(result, "the crafted input is not a valid email");
    }
}

Pitfalls

  • The default System.Text.RegularExpressions engine backtracks. A nested quantifier such as ([a-z]+)+ is exponential on input that matches the inner part but fails overall, so a few dozen characters is enough.
  • A maximum length only helps when it is below the size the pattern chokes on. Backtracking blows up at tens of characters, so a generous cap (1000, 100000) does nothing; that is the 'wrong' fix here.
  • A Regex with no match timeout cannot be cancelled once running; a CancellationToken on the request does not stop it. Set a match timeout or use NonBacktracking.
  • RegexOptions.NonBacktracking cannot be combined with RightToLeft, backreferences, lookaround or atomic groups; if your pattern needs those, either rewrite it or fall back to a match timeout.
  • GeneratedRegex ([GeneratedRegex(...)]) improves startup and allocation but still uses the backtracking engine unless you also pass RegexOptions.NonBacktracking; the source generator is not itself a ReDoS fix.
  • No built-in Roslyn analyzer flags this; use CodeQL cs/redos or SonarLint S5852, which inspect the pattern.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csharp-aspnetcore does this for you).
  2. Each exploit runs a crafted ~40-character input on a background thread with a 1s deadline and asserts the validator returns within budget; the vulnerable and wrong validators blow the deadline, which is the denial of service.
  3. Confirm RegexOptions.NonBacktracking matches a 1000-character crafted input in well under a millisecond, and that the same pattern under RegexOptions.None with a 100ms match timeout throws RegexMatchTimeoutException on the crafted input.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix ReDoS in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepn/a0 findings. Semgrep CE's C# ruleset has no ReDoS-pattern rule, so it does not catch the nested quantifier. The control is the regression suite plus CodeQL cs/redos and a pattern analyser (recheck reports the same literal as exponential).Checked: Semgrep 1.180.0 (CE); semgrep scan --metrics=off --config p/security-audit csharp-aspnetcore/src (Ran 3 rules on 33 files)
CodeQLcs/redosExpected to flag vulnerable and wrong. cs/redos ("Denial of Service from comparison of user input against expensive regex") detects a regex with super-linear/exponential backtracking matched against data; the nested quantifier ([a-zA-Z0-9._%+-]+)+ is that shape. The fixed validator keeps the same pattern but passes RegexOptions.NonBacktracking; whether the query accounts for that option is version-dependent, so it may also flag the fixed pattern (a false positive) - the regression tests give the real verdict, since NonBacktracking makes the pattern linear at run time.Checked: id/name/path verified in github/codeql main via GitHub API (@id cs/redos, @name 'Denial of Service from comparison of user input against expensive regex'); query not executed
.NET code analysis (Roslyn analyzers)n/aNo built-in analyzer for ReDoSThe built-in CAxxxx quality/security rules do not cover CWE-1333; the SYSLIB regex source-generator diagnostics are about GeneratedRegex adoption, not backtracking. No built-in analyzer flags the vulnerable pattern. Enforcement is the regression suite.Checked: .NET SDK 10.0.107
SonarQube / SonarLintS2631Regular expressions should not be vulnerable to Denial of Service attacksS2631 is Sonar's C# rule for regular expressions vulnerable to denial of service, classed as a vulnerability; Sonar's C# rules have no S5852. Sonar also lists S6444, "Regular expressions should be executed with a timeout". Neither was run against this card.Checked: Rule listed in SonarSource's public SonarQube instance; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC#
Runtime.NET 10.0.12 (SDK 10.0.112)
FrameworkASP.NET Core 10 minimal API
System.Text.RegularExpressions (BCL).NET 10.0.12
xunit.v34.0.2 (Microsoft Testing Platform v2)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the regex-dos/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Validator.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Validator.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Validator.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong dotnet test --project tests/Validator.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs regex-dos/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 22 KB

Verified

Machine
macOS 27.0, Apple M5 Max
Toolchain
  • dotnetSdk 10.0.112
  • dotnetRuntime 10.0.12
  • xunitV3 4.0.2
Matrix
fixed: 9 of 9 tests pass; vulnerable: 2 of 9 tests fail (2 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
Test results
fixed: 9/9 pass; vulnerable: 2 fail (2 exploit); wrong: 2 fail (2 exploit)
Notes
The crafted inputs are benign strings of 'a' with no '@'; on the default backtracking engine the vulnerable pattern needs ~30 characters to exceed the 1s budget by orders of magnitude, while RegexOptions.NonBacktracking matches it in microseconds. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP: Regular expression Denial of Service - ReDoS
  2. CWE-1333: Inefficient Regular Expression Complexity
  3. CAPEC-492: Regular Expression Exponential Blowup
  4. .NET regular expressions: backtracking and NonBacktracking
  5. RegexOptions.NonBacktracking
  6. Regex match timeout (best practices)
  7. CodeQL: DoS from comparison of user input against expensive regex (cs/redos)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.