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

Fix ReDoS in Java and Spring Boot

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

In short

On JDK 9+ java.util.regex memoizes to defuse many catastrophic patterns - but a backreference turns that off, so a nested quantifier backtracks exponentially again, and the engine has no timeout. Rewrite the pattern to drop the nesting and the backreference so matching is linear, cap the input length, and bound any untrusted match on an interruptible thread.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks3 cards
Download the project ZIP, 22 KB
On this page

Steps

  1. Find every Pattern applied to request data (email, URL and slug validators). Flag nested quantifiers ((X+)+), overlapping alternations ((a|aa)+), and especially any of these combined with a backreference (\1) or lookaround - the backreference re-enables exponential backtracking that JDK 9+ would otherwise defuse.
  2. Rewrite the pattern so it is regular and unambiguous: drop the outer '+' and the backreference. Here the matched-quote feature ((["']?)...\1) is what introduces the backreference; allow an optional quote at each end without tying them together, so each character is consumed once and matching is linear.
  3. Cap the input length before matching (254 for an email). java.util.regex has no match timeout, so a length bound is the simplest guarantee that any residual cost stays constant.
  4. Where a pattern must stay complex or is user-supplied, run the match on a thread you can abandon with a deadline (java.util.regex cannot be interrupted once running), or match on a linear engine such as RE2/J (com.google.re2j); note RE2/J does not support backreferences, which is a signal to remove them.
  5. Do not rely on the JDK 9+ mitigation as a safety net: it is silently lost with a backreference, and it bounds nothing on an older or different runtime.

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…/redos/impl/VulnerableEmailValidator.javaJava
private static final Pattern EMAIL =
        Pattern.compile("^([\"']?)([A-Za-z0-9._%+-]+)+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\1$");

@Override
public boolean isValid(String input) {
    return EMAIL.matcher(input).matches();
}

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

The fix: regular pattern + length cap

Fixed…/redos/impl/FixedEmailValidator.javaJava
private static final int MAX_LENGTH = 254; // RFC 5321 caps an email address at 254 characters
private static final Pattern EMAIL =
        Pattern.compile("^[\"']?[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}[\"']?$");

@Override
public boolean isValid(String input) {
    if (input.length() > MAX_LENGTH) {
        return false;
    }
    return EMAIL.matcher(input).matches();
}

With IMPL=fixed, 4 of 4 tests pass.

A fix that looks safe but is not

Wrong fix…/redos/impl/WrongEmailValidator.javaJava
private static final int MAX_LENGTH = 100_000; // "nobody sends a 100 KB email" - but ~40 chars is plenty
private static final Pattern EMAIL =
        Pattern.compile("^([\"']?)([A-Za-z0-9._%+-]+)+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\1$");

@Override
public boolean isValid(String input) {
    if (input.length() > MAX_LENGTH) {
        return false;
    }
    return EMAIL.matcher(input).matches();
}

With IMPL=wrong, 2 of 4 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.

Test…/example/redos/ExploitTest.javaJava
// Exploit: each payload is a short, benign string - letters and one terminator, with no
// "@" - that a linear validator rejects instantly but a backtracking one grinds on for
// minutes. The validator runs on a daemon 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 - java.util.regex has no timeout and cannot be interrupted, so the
// deadline is the only bound.
class ExploitTest {

    private static final long BUDGET_MS = 1000L;

    private final EmailValidator validator =
            ValidatorFactory.create(System.getenv().getOrDefault("IMPL", "fixed"));

    @Test
    void exploitFirstCraftedInputStaysWithinBudget() {
        assertExploitBounded(Inputs.ALL.evil().get(0));
    }

    @Test
    void exploitSecondCraftedInputStaysWithinBudget() {
        assertExploitBounded(Inputs.ALL.evil().get(1));
    }

    private void assertExploitBounded(String payload) {
        Deadline.Outcome outcome = Deadline.validateWithin(validator, payload, BUDGET_MS);
        assertThat(outcome.completed())
                .as("validation did not finish within %dms - catastrophic backtracking (ReDoS)", BUDGET_MS)
                .isTrue();
        assertThat(outcome.result()).as("the crafted input is not a valid email").isFalse();
    }
}

Pitfalls

  • JDK 9+ added memoization that makes many classic exponential patterns fast, so a plain ([a-z]+)+ no longer hangs on JDK 21 - which lulls reviewers. A backreference (or, on other runtimes, no mitigation at all) brings the blow-up straight back.
  • 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.
  • java.util.regex has no match timeout and Matcher cannot be interrupted by Thread.interrupt(); a per-request timeout does not stop a running match. You must run it on a separate thread you are willing to abandon, or make it linear.
  • Static ReDoS analysers (CodeQL, SonarLint) model regular expressions and cannot represent backreferences, so they may not flag a backreference-induced blow-up - the hardest case to catch is the one the tools miss.
  • RE2/J (com.google.re2j) gives a linear engine but is not a drop-in for java.util.regex: it rejects backreferences and lookaround. Treat that rejection as a prompt to rewrite, not a blocker.
  • An input-validation regex compiled once as a static final Pattern is still exploitable: the cost is per match on attacker input, not per compile.

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 java-spring does this for you).
  2. Each exploit runs a crafted ~40-character input on a daemon 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. On JDK 21, confirm the plain nested quantifier without the backreference returns in under a millisecond (the engine's mitigation), then confirm adding the \1 backreference restores the hang - that is the behaviour the fix removes. The rewritten pattern matches a 1000-character crafted input instantly.

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 Java and Spring Boot
ToolRuleWhat it found here
Semgrepn/a0 findings. No Semgrep CE rule analyses a literal java.util.regex pattern for catastrophic backtracking. The control is the regression suite plus CodeQL and a pattern analyser - with the caveat below about backreferences.Checked: Semgrep 1.180.0 (CE); semgrep scan --metrics=off --config p/security-audit java-spring/src/main/java (Ran 54 rules on 8 files)
CodeQLjava/redosjava/redos ("Inefficient regular expression") detects super-linear/exponential ambiguity in a pattern, and java/polynomial-redos needs a dataflow path from an untrusted source (@RequestParam) into the match. IMPORTANT: this card's vulnerable pattern contains a backreference (\1), which is non-regular. Automaton-based analysers like CodeQL model regular expressions via finite automata, which cannot represent a backreference, so they may skip or mis-handle this pattern and miss the blow-up - the same property that makes the backreference defeat the JDK's own mitigation. (A fuzzing-based checker is different: recheck 4.5.0 did report this exact pattern as status=vulnerable, complexity=exponential.) Because tool coverage of backreference ReDoS is uneven, the regression suite is the enforcement control here.Checked: id/name/path verified in github/codeql main via GitHub API (@id java/redos, @name 'Inefficient regular expression'); query not executed
SonarQube / SonarLintS5852Regular expressions should not cause catastrophic backtrackingS5852 is Sonar's Java rule for regular expressions that can backtrack catastrophically, classed as a vulnerability. It was not run against this card, so whether it reports this backreference pattern is not known.Checked: Rule listed in SonarSource's public SonarQube instance; not executed
JDK behaviour noten/aJDK 9+ catastrophic-backtracking mitigation (and how a backreference defeats it)On JDK 21 the plain nested-quantifier email pattern ^([A-Za-z0-9._%+-]+)+@... does NOT hang (the engine's added memoization defuses it; measured <1ms for a 40-char failing input). Adding a backreference (the matched optional quote, \1) makes the match non-regular and disables that memoization, restoring exponential backtracking: ~40 characters runs for minutes. This is why the vulnerable validator uses a realistic backreference, and why 'rewrite to a regular, backreference-free, unambiguous pattern' is the fix.Checked: Measured on OpenJDK 21.0.12.1

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
java.util.regex.Pattern (JDK)OpenJDK 21
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

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/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs regex-dos/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 22 KB

Verified

Machine
macOS 27.0, Apple M5 Max
Toolchain
  • jdk OpenJDK 21.0.12.1
  • maven 3.9.16
  • springBoot 4.1.1
  • junitJupiter 6.0.3
Matrix
fixed: 4 of 4 tests pass; vulnerable: 2 of 4 tests fail (2 exploit tests); wrong: 2 of 4 tests fail (2 exploit tests)
Test results
fixed: 4/4 pass; vulnerable: 2 fail (2 exploit); wrong: 2 fail (2 exploit)
Notes
On OpenJDK 21 the plain nested-quantifier pattern does not hang (JDK 9+ mitigation), so the vulnerable validator uses a realistic backreference (a matched optional quote around the address) which disables that mitigation and restores exponential backtracking; measured HANG>4s at 40 characters versus <1ms for the rewritten pattern. The exploit runs on a daemon thread because java.util.regex cannot be interrupted. Maven defaults to a newer JDK on this machine, hence the pinned javaHome; several agents run Maven concurrently, so .mvn/maven.config pins the file-lock sync context.

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. Java SE 21: java.util.regex.Pattern
  5. SonarQube rule java:S5852: Regular expressions should not cause catastrophic backtracking
  6. RE2/J: linear-time regular expressions for Java
  7. CodeQL: Inefficient regular expression (java/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.