Safeguard
Application Security

ReDoS in Python and FastAPI: how one regex takes down an event loop

CVE-2024-3772 let a single crafted email string trigger catastrophic backtracking in Pydantic's own validator — the exact code path every FastAPI request body runs through.

Safeguard Research Team
Research
6 min read

On April 15, 2024, CVE-2024-3772 was disclosed against Pydantic's EmailStr validator: a specially crafted email string could drive the library's internal regex into catastrophic backtracking, and Pydantic shipped a fix in 2.4.0 and backported it to 1.10.13. That matters far beyond one library, because Pydantic is the validation layer FastAPI runs on every request body — any endpoint that validated an EmailStr field before the patch could be knocked over pre-authentication, with a single HTTP request. This is the general shape of a ReDoS (Regular expression Denial of Service) bug: Python's built-in re module is a backtracking engine, not a linear-time automaton like RE2, so certain patterns — nested quantifiers like (a+)+, or alternations with overlapping branches like (a|a)* — can take runtime that grows exponentially with input length when matched against the wrong (deliberately non-matching) string. Because CPython is single-threaded per worker and async def FastAPI handlers share one event loop, one blocking regex call can stall every concurrent request on that worker, not just the attacker's own. This piece covers how the bug class works, the real CVEs that have shipped it, and the detection and rewriting techniques that catch it before production does.

What actually causes catastrophic backtracking?

Catastrophic backtracking happens when a regex has ambiguous ways to match the same substring, and the engine tries all of them before giving up. Take (a+)+b matched against "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" — there is no trailing b, so the match must fail, but the inner a+ and outer (...)+ can split a run of 40 a's into groups in exponentially many ways, and Python's backtracking NFA tries nearly all of them before reporting failure. Each additional character roughly doubles the work, so a 30-character string that resolves in microseconds becomes a 40-character string that takes seconds and a 50-character string that hangs the process. This is purely a property of the pattern's structure — nested repetition, or alternation branches that can match the same text — not of any specific library, which is why the bug class recurs across completely unrelated codebases and languages.

Has this actually hit the Python standard library, not just user code?

Yes — twice, in the same disclosure. CVE-2018-1060 and CVE-2018-1061 covered catastrophic-backtracking regexes in CPython's own poplib.apop() and difflib.IS_LINE_JUNK, tracked as bpo-32981 and patched in Python 2.7.15, 3.4.9, 3.5.6, 3.6.5, and 3.7.0. Separately, CVE-2020-8492 fixed an inefficient regex in urllib.request's AbstractBasicAuthHandler that was vulnerable to the same class of input. Neither of these was exotic third-party code — they were shipped inside the interpreter's own standard library for years before anyone noticed. That is the practical lesson for teams auditing their own Python: assuming "it's stdlib, it must be safe" is not a valid mitigation, and a regex's exposure to attacker-controlled input matters more than where the regex was written.

Why is this specifically dangerous for FastAPI?

FastAPI validates every request body, query parameter, and path parameter through Pydantic models before your handler code even runs, which means a vulnerable regex in a validator sits directly on the unauthenticated request path. CVE-2024-3772 is the clearest illustration: any FastAPI route accepting an EmailStr field ran the vulnerable pattern against attacker-supplied input as part of framework-level validation, before any authentication or business logic executed. Worse, most FastAPI handlers are declared async def and run on a single-threaded event loop shared by every in-flight request; a synchronous re.match() call blocks that loop entirely; it doesn't just slow the attacker's own request, it stalls every other concurrent request the process is holding. One crafted payload against one endpoint can produce a full outage for every user hitting that worker, which is a far cheaper attack than the volumetric flood a traditional DoS requires.

What does a real-world ReDoS outage actually look like?

The clearest public postmortem isn't Python-specific, but it shows the blast radius precisely. On July 2, 2019, Cloudflare suffered a roughly 27-minute global outage after deploying a single WAF rule whose regex was vulnerable to catastrophic backtracking; because the rule ran on Cloudflare's edge, CPU exhaustion on that one pattern degraded service worldwide, not just for one customer. Cloudflare's own postmortem attributed the root cause to using a backtracking regex engine rather than one with a guaranteed linear-time bound, and the incident is the reason the industry increasingly treats "which regex engine" as a security decision, not just a performance one. The scale is the point: a bug that looks like a narrow, single-line library defect can take down infrastructure well beyond the code that contains it, because regex evaluation sits on so many hot input paths.

How do you actually find ReDoS bugs before they ship?

Static analysis for ambiguous quantifier nesting is the first line of defense — tools like safe-regex and academic scanners such as rxxr2 walk a regex's parse tree looking for the nested-or-overlapping-repetition patterns that enable catastrophic backtracking, without needing to execute anything. Beyond static checks, two operational controls matter regardless of what the pattern looks like: cap input length before it ever reaches a regex (a 50-character bound turns an exponential blowup into a bounded delay), and use the third-party regex module's timeout parameter, or run untrusted-input matching in a separate process, so a pathological match fails fast instead of hanging the worker. For validators that sit on every request — email, URL, and similar formats — some teams migrate to Google's RE2 via the google-re2 Python binding or Rust's regex crate, both of which reject backreferences and guarantee linear-time matching by construction, trading a small amount of regex expressiveness for an unconditional runtime bound.

How Safeguard helps

A vulnerable regex is only worth flagging urgently when untrusted input can actually reach it, which is why Safeguard's SAST engine treats it as a dataflow problem rather than a lexical one. Safeguard's first-party static analysis (Python is a phase-1 language) traces a source→sink path from a request parameter or body field through to the sink that consumes it, so a regex compiled from a hardcoded pattern gets triaged differently than one whose input traces back to an unauthenticated FastAPI request body — the same reachability logic Safeguard already applies to injection and deserialization findings. Each finding carries the ordered dataflow trace and a CWE mapping, so a team can see exactly which endpoint exposes the pattern and prioritize the fix — replacing the pattern, capping input length, or adding a timeout — instead of re-deriving the exploit path by hand.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.