Engineering · Sep 17, 2026 · 7 min read

ReDoS: when a regex becomes a denial-of-service weapon

A regex with catastrophic backtracking turns user input validation into a CPU drain - the confirming probe is a graduated timing oracle, not a large payload.

A regular expression with catastrophic backtracking turns user input validation into a CPU drain that can be sustained with trivial effort. The mechanism is deterministic: given a pattern that contains overlapping or nested quantifiers and a crafted string that forces the engine to explore every possible grouping before reporting no match, evaluation time grows exponentially with input length. The confirming probe is safe - a graduated timing oracle across inputs of 16, 32 and 64 characters exposes the growth curve without sending anything close to a damaging payload to a production endpoint.

01What the scanner sees on an affected endpoint

The scan itself is lightweight. Three POST requests to the registration endpoint, each substituting a crafted email value of growing length. The baseline - a legitimate-looking address - returns in under 10 ms. The first crafted probe returns in over a second. The second exceeds 10 seconds. The third never returns at all within the timeout window. That curve is the artifact, not any error message or exception trace.

scan-output.txttext
POST /api/auth/register  email=user@example.com
200 OK  8 ms  # baseline, legitimate input

POST /api/auth/register  email=aaaaaaaaaaaaaaab  (16 a + b)
200 OK  1 840 ms  # ~230x slower than baseline

POST /api/auth/register  email=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab  (32 a + b)
200 OK  14 200 ms  # ratio vs 16-char probe: 7.7x

POST /api/auth/register  email=aaaa...b  (64 a + b)
Timeout after 20 s  (server CPU pegged)

growth ratio 64-char / 16-char input: >1 000x  # exponential confirmed
Proven - HighReDoS - email validation regex - /api/auth/register
POST /api/auth/register email=aaaaaaaaaaaaaaab (16 a + b) ──► 200 OK 1 840 ms (baseline: 8 ms, ratio 230x) email=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab (32 a + b) ──► 200 OK 14 200 ms (ratio 32x/16x: 7.7x) email=aaaa...b (64 a + b) ──► timeout 20 s exceeded Growth ratio 64x/16x input: >1 000x - exponential backtracking confirmed.
Oracle: timing differential, 5 samples per length, max input 64 chars. No records written. Pattern identified in SAST as ^([\w.+-]+@[\w-]+\.[\w.]+)+$ - nested quantifiers on overlapping character classes. Severity high: unauthenticated endpoint; single request blocks event loop for the duration.

02Why sending a large payload is the wrong probe

A naive probe that sends a 10 000-character string either hits the endpoint's request-size limit - returning 413 before any regex fires - or saturates the process so completely that the probe itself cannot distinguish a ReDoS from an overloaded server or a network timeout. Neither result is evidence. The graduated approach keeps every input below 100 characters, measures the ratio across the sequence, and uses that ratio as the confirming artifact rather than the raw timing.

Naive probe: 10 000-char string ──► 413 Request Entity Too Large (size limit; regex never ran) OR ──► timeout (ambiguous: ReDoS or overload?) Graduated probe: 16 / 32 / 64 chars 16 chars ──► 1.8 s ratio vs baseline: 230x 32 chars ──► 14.2 s ratio 32/16: 7.7x 64 chars ──► timeout ratio 64/16: >1 000x Curve shape: super-linear ──► exponential backtracking confirmed Max probe size: 64 chars ──► safe, no size-limit interference
Large payloads produce ambiguous results. The ratio across a short geometric sequence is the signal.

03Common vulnerable patterns and the probe algorithm

All catastrophically backtracking patterns share one structural property: the same input character can satisfy more than one branch or repetition of the group, so the engine explores every valid assignment before declaring failure. The canonical cases below are the ones most commonly found in validation code.

Vulnerable patternWhy it backtracksSafe rewrite
^(a+)+$Outer and inner + can both consume the same characters in multiple combinations^a+$ (remove redundant outer quantifier)
^(a|aa)+$Single and double 'a' overlap; engine tries both paths at every position^a+$ or ^(a{1,2})+$ with no overlap
([\w.]+@)+[\w.]+\w and . each match the same character class; nested quantifier on bothUse a dedicated email validation library, not a regex
(.*a){N} for large N.* can match zero to any chars before 'a'; exponential at each step([^a]*a){N} (negated class removes backtrack surface)
^(\s*,\s*)+$Leading and trailing \s* each can match the separator gap^\s*(,\s*)+$ (anchor optional space outside quantifier)
redos-probe.pypython
import requests, time, statistics

# Evil input: N 'a' chars followed by 'b'. Never matches a well-formed email.
# Forces full backtrack on any pattern that treats 'a' as a valid email char.
def timed_probe(url, param, n, reps=5):
    payload = "a" * n + "b"
    times = []
    for _ in range(reps):
        t0 = time.perf_counter()
        try:
            requests.post(url, data={param: payload}, timeout=20)
        except requests.exceptions.Timeout:
            times.append(20.0)  # count as 20 s
            continue
        times.append(time.perf_counter() - t0)
    return statistics.median(times)

t_base = timed_probe(TARGET, "email", 0)  # empty + 'b' = baseline
t16    = timed_probe(TARGET, "email", 16)
t32    = timed_probe(TARGET, "email", 32)
t64    = timed_probe(TARGET, "email", 64)

ratio = t64 / t16 if t16 > 0.005 else 0
print(f"base={t_base:.3f}s  t16={t16:.3f}s  t32={t32:.3f}s  t64={t64:.3f}s")
print(f"ratio 64/16: {ratio:.1f}x")
if ratio > 64: print("exponential confirmed")
elif ratio > 8:  print("super-linear confirmed - at minimum quadratic")

04Severity calibration and remediation

Severity follows how much of the event loop or thread pool a single request blocks and how accessible the endpoint is. An unauthenticated registration or search field where one 64-character request saturates a Node.js process for tens of seconds is high: the attacker needs no account, no token, and the cost is a single HTTP request. An authenticated, rate-limited path where each probe costs a session token is medium: real, but expensive to sustain. A regex that only runs in a background batch job behind a queue is low. Rating every instance critical because 'denial of service' reads as severe is the kind of over-claiming that makes the rest of the report less credible.

64
max chars in confirming probe
5
timing samples per input length
8x
minimum growth ratio to flag

The remediation lives in the pattern, not the runtime. Removing the nested or overlapping quantifier eliminates the backtracking surface - a timeout shim only caps the damage. Where the runtime supports them, atomic groups ((?>...)) or possessive quantifiers (a++) commit the engine to the first match without backtracking. Python's re module does not support atomic groups; the regex package does. PCRE2, Rust's regex crate and RE2 use linear-time evaluation algorithms and reject catastrophically backtracking patterns at compile time - migrating to one of these eliminates the class at the engine level. SAST identifies the vulnerable pattern before any probe fires; DAST confirms the endpoint actually wires user input into it at real HTTP latency. A finding present in SAST but whose timing oracle never fires is rated reachable, not confirmed - the input may be sanitized upstream before it reaches the regex.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →