skip to content

How can capturing groups and backreferences contribute to catastrophic backtracking (ReDoS), and how do you design Java regexes to avoid it?

level: principalimportance: should knowfreq 34%

answer

  1. backtracking NFA → ambiguous quantifiers explode
  2. (a+)+ on 'aaaa...!' = catastrophic / ReDoS
  3. fixes: possessive a++, atomic (?>...), non-capturing, anchors
  4. ops: cap length, time-box, or use RE2J for untrusted input

basics

~20 s

Java's regex engine backtracks. Ambiguous nested quantifiers and backreferences can make it try exponentially many combinations on certain inputs, freezing a thread (a denial of service). Avoid it with unambiguous patterns, atomic groups, possessive quantifiers, and input limits.

solid answer

~40 s

Java's java.util.regex is a backtracking NFA engine, so on a partial match it retries alternative paths. When a pattern has ambiguous overlapping quantifiers — classically (a+)+ or (a|a)* — or combines quantifiers with backreferences, the number of paths can grow exponentially with input length; a crafted non-matching string then hangs the matching thread, a ReDoS denial of service. Capturing groups make this worse because each adds backtrack state, and backreferences force the engine to revisit captured text. Mitigations: write unambiguous patterns (avoid nested/overlapping quantifiers), prefer possessive quantifiers (a++, a*+) and atomic groups (?>...) which commit and never give back, replace needless capturing groups with non-capturing (?:...), anchor patterns, and cap input length or run untrusted matching with a timeout/separate thread. Never run an untested complex regex against untrusted input.

code

java · 9 lines
java
// DANGEROUS: overlapping quantifiers -> catastrophic backtracking
Pattern bad = Pattern.compile("^(a+)+$");
// bad.matcher("aaaaaaaaaaaaaaaaaaaaaaaaaaaaa!").matches(); // hangs

// SAFE: possessive quantifier commits, no backtracking
Pattern good = Pattern.compile("^a++$");
// SAFE: atomic group also commits
Pattern atomic = Pattern.compile("^(?>a+)+$");
// And drop needless capture: use (?:...) when not read back.

go deeper

for a junior

Aware that some regexes can be slow and that user input should be validated, even if not the mechanics.

for a middle

Can identify nested quantifiers like (a+)+ as risky and knows anchoring and simpler patterns help.

for a senior

Explains backtracking, applies possessive quantifiers, atomic groups, and non-capturing groups, and validates input length.

for a principal

Owns the policy: backtracking-engine trade-offs, RE2J for untrusted patterns, time-boxing/watchdog strategy, static analysis in CI, and threat-modeling regex as an attack surface.

## How Java matches: backtracking NFA Java's `java.util.regex` is a **backtracking** engine (a nondeterministic finite automaton simulated with backtracking). When it hits a point where a quantifier *could* have consumed a different number of characters, and the rest of the pattern fails, it **backtracks** — rewinds and tries another split. For unambiguous patterns this is cheap. For ambiguous ones, the number of ways to split the input can grow **exponentially**. ## The classic explosion Consider `^(a+)+$` matched against a long string of `a`s followed by one `!` (so it can never fully match): `aaaa...a!`. The inner `a+` and the outer `+` overlap — the engine can partition the run of `a`s in exponentially many ways, and it must try *all* of them before concluding failure. Matching `aaaaaaaaaaaaaaaaaaaaaaaa!` can take seconds; a few more characters, minutes. This is **catastrophic backtracking**, and exploited deliberately it is a **ReDoS** (Regular-expression Denial of Service): a tiny input pins a CPU core and starves the application. ## Where groups and backreferences fit - **Capturing groups** add per-attempt bookkeeping (saving/restoring start/end on each backtrack), increasing both constant cost and the state the engine juggles. Groups you never read should be non-capturing `(?:...)`. - **Backreferences** (`\1`, `\k<name>`) make the language non-regular and force the engine to compare captured text on each path, amplifying backtracking and making blow-ups easier to trigger. - **Overlapping quantifiers** — `(x+)+`, `(x*)*`, `(a|ab)+`, alternations whose branches can match the same text — are the root cause; groups just package them. ## Designing patterns that can't explode 1. **Remove ambiguity.** Ensure each input character can be consumed by exactly one part of the pattern. Rewrite `(a+)+` as `a+`; rewrite `(a|ab)*` to a non-overlapping form. 2. **Possessive quantifiers** — `a++`, `a*+`, `a?+`, `a{2,}+`. These match greedily and **never give back** characters on backtrack. `^a++!$` cannot explode because once `a++` consumes the run, it won't reconsider. 3. **Atomic groups** — `(?>...)`. Once the group matches, its internal backtrack points are discarded — the engine commits. `(?>a+)+` is safe where `(a+)+` is not. 4. **Non-capturing groups** `(?:...)` for pure structure — less state, clearer intent. 5. **Anchoring** — `^...$` or `\b` reduces the positions the engine retries from. 6. **Avoid backreferences** unless you truly need text-equality; they cannot be made into a DFA. ## Operational defenses (defense in depth) - **Bound input length** before matching untrusted data. - **Time-box matching**: run on a worker thread you can interrupt, or use a `CharSequence` wrapper that throws after N steps. (Java's engine isn't interruptible mid-match by `Thread.interrupt()`, so a watchdog that abandons the thread or a step counter is often needed.) - **Static analysis / linters** flag known-dangerous shapes; review regexes touching untrusted input. - **Prefer a non-backtracking engine** (e.g. RE2/RE2J) for untrusted patterns — it guarantees linear time but drops backreferences. ## Mental model Think of the engine as a maze-walker that, on a dead end, walks back and tries every untried fork. Ambiguous quantifiers create exponentially many forks; possessive/atomic constructs brick up the forks behind it so it can never re-explore them. The architect's job is to ensure the maze has no exponential fork structure, and to put a guard (timeout/length cap) on the door for untrusted visitors.

  • What is the difference between a possessive quantifier and an atomic group as ReDoS mitigations?
    A possessive quantifier (a++) makes a single quantifier match greedily and never give back characters. An atomic group (?>...) commits an entire sub-pattern: once it matches, all internal backtrack points are discarded. Both prevent the engine from re-exploring those choices, but the atomic group scopes the commitment over arbitrary sub-patterns, not just one token.
  • Why doesn't switching to non-capturing groups by itself fix catastrophic backtracking?
    Non-capturing groups remove capture bookkeeping but do not change the fundamental ambiguity of overlapping quantifiers. (x+)+ explodes whether the group captures or not; you must remove the ambiguity (possessive/atomic/rewrite), not just the capture.

The engine is a maze-walker that retries every fork on a dead end. Ambiguous quantifiers add exponentially many forks; possessive quantifiers and atomic groups brick the forks shut so it can never wander back into them.

saying these in an interview costs you the question

  • Assuming Java regex runs in linear time like a DFA engine
  • Thinking non-capturing groups alone stop ReDoS
  • Running complex untested regexes on untrusted input without limits
  • Believing Thread.interrupt() reliably aborts an in-progress match
  • Confusing greedy vs possessive quantifiers (greedy still backtracks)

context