BREW-SNAKEVIZ-GHSA-3HV7-… (GHSA-3HV7-MJH2-FV65)
Vulnerability from osv_homebrew – Published: 2026-10-01 11:45 – Updated: 2026-10-01 11:45 – Source websiteSummary
HTTPServerRequest.__init__ in tornado/httputil.py parses the URL query string via
parse_qs_bytes() with no field-count limit — while the sibling POST-body parsing path
(parse_body_arguments) received a max_num_fields=1000 cap added earlier in this exact
same release (v6.5.8, commit 8d6363ed), explicitly to bound parsing cost for the identical
underlying primitive. This leaves the query-string path with the resource-exhaustion exposure
the body-path fix was meant to close.
File: tornado/httputil.py, line 553 (HTTPServerRequest.__init__)
Root Cause
# tornado/httputil.py:553 (before fix)
self.arguments = parse_qs_bytes(self.query, keep_blank_values=True)
Compare with the POST-body path fixed one commit earlier in the same release:
# tornado/httputil.py:1038-1041
uri_arguments = parse_qs_bytes(
body,
keep_blank_values=True,
max_num_fields=config.urlencoded.max_arguments, # default 1000
)
Both call sites funnel through the same tornado.escape.parse_qs_bytes (a thin wrapper over
urllib.parse.parse_qs), which is exactly why max_num_fields was added to
urllib.parse.parse_qsl upstream — to let frameworks bound field count. The fix was applied
only to the body path; the query-string path was missed.
The request line + headers together are capped at max_header_size (default 65536 bytes), so
this is not literally unbounded, but a single ~64KB request line can carry thousands of short
key=value pairs — far beyond the 1000-field limit the maintainer judged appropriate for the
structurally identical body case.
Attack Scenario
- Attacker sends a
GETrequest whose query string is packed with thousands of short fields (e.g.k0=1&k1=1&...&k7799=1, ~61KB), fitting comfortably undermax_header_size. No authentication, cookies, or prior state required. - Tornado accepts and parses this with no field-count cap, unlike the equivalent POST-body request (which is correctly rejected with 400 once >1000 fields are present).
- Parsing thousands of fields is CPU work performed synchronously inside Tornado's
single-threaded
IOLoop. Several such requests in flight concurrently stall the event loop, delaying processing of all other connections on that loop — not just the attacker's own request.
Verification (dynamic, local reproduction against v6.5.8)
Ran the unmodified v6.5.8 source directly (no external dependencies needed) with a minimal
tornado.web.Application on 127.0.0.1:8888.
- Identical 7800-field/~61KB payload sent as GET query string →
200 OK; sent as POST body (application/x-www-form-urlencoded) →400 Bad Request(correctly rejected by the existingmax_num_fieldsbody-path limit). This confirms the asymmetry directly. - Per-request parse cost: baseline (
/?a=1) averaged 1.86ms; the 7800-field query string averaged 25.1ms (~13x). - Event-loop-blocking amplification (raw-socket test, isolating server-side stall from client overhead): with 10 sequential baseline probe requests fired with no load, average latency was 1.47ms (max 5.9ms). With 5 concurrent 61KB/7800-field requests in flight, the same baseline probes averaged 13.0ms (max 118.1ms) — an 8.9x average slowdown for unrelated clients, produced by ~305KB of unauthenticated attacker traffic.
Impact
All Tornado servers/applications are affected — this triggers on every request with a query
string, independent of application/handler logic. An unauthenticated, unprivileged remote
attacker can measurably degrade response times for all other clients sharing the same
IOLoop, using a small amount of bandwidth and no special conditions. This is an
availability/DoS concern; no confidentiality or integrity impact.
Recommended Fix
# tornado/httputil.py — HTTPServerRequest.__init__
if uri is not None:
self.path, sep, self.query = uri.partition("?")
try:
self.arguments = parse_qs_bytes(
self.query,
keep_blank_values=True,
max_num_fields=_DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments,
)
except ValueError as e:
raise HTTPInputError("Invalid query string: %s" % e) from e
This reuses the existing ParseUrlEncodedConfig.max_arguments default (1000) via the
module's _DEFAULT_PARSE_BODY_CONFIG, matching the POST-body limit and honoring any global
override via set_parse_body_config(). The try/except is necessary because — unlike
parse_body_arguments, which already wraps its call and converts ValueError into a clean
HTTPInputError/400 — the query-string call site currently has no such handling, so without
it, a request exceeding the limit would raise an uncaught ValueError instead of a clean 400.
Verified: with the fix applied, requests with ≤1000 query-string fields are unaffected;
requests with >1000 fields are rejected with 400 Bad Request (consistent with the POST-body
behavior); Tornado's own httputil_test and web_test suites (256 tests) pass unchanged.
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "tornado",
"resource_purl": "pkg:pypi/tornado@6.5.8",
"upstream_fixed_in": "6.5.9"
},
"package": {
"ecosystem": "Homebrew",
"name": "snakeviz",
"purl": "pkg:brew/snakeviz"
},
"ranges": [
{
"events": [
{
"introduced": "2.2.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/tornado@6.5.8",
"name": "tornado",
"resource": "tornado",
"strategy": "registry",
"subject_version": "6.5.8"
}
]
},
"details": "## Summary\n\n`HTTPServerRequest.__init__` in `tornado/httputil.py` parses the URL query string via\n`parse_qs_bytes()` with no field-count limit \u2014 while the sibling POST-body parsing path\n(`parse_body_arguments`) received a `max_num_fields=1000` cap added earlier in this exact\nsame release (v6.5.8, commit `8d6363ed`), explicitly to bound parsing cost for the identical\nunderlying primitive. This leaves the query-string path with the resource-exhaustion exposure\nthe body-path fix was meant to close.\n\n**File**: `tornado/httputil.py`, line 553 (`HTTPServerRequest.__init__`)\n\n### Root Cause\n\n```python\n# tornado/httputil.py:553 (before fix)\nself.arguments = parse_qs_bytes(self.query, keep_blank_values=True)\n```\n\nCompare with the POST-body path fixed one commit earlier in the same release:\n\n```python\n# tornado/httputil.py:1038-1041\nuri_arguments = parse_qs_bytes(\n body,\n keep_blank_values=True,\n max_num_fields=config.urlencoded.max_arguments, # default 1000\n)\n```\n\nBoth call sites funnel through the same `tornado.escape.parse_qs_bytes` (a thin wrapper over\n`urllib.parse.parse_qs`), which is exactly why `max_num_fields` was added to\n`urllib.parse.parse_qsl` upstream \u2014 to let frameworks bound field count. The fix was applied\nonly to the body path; the query-string path was missed.\n\nThe request line + headers together are capped at `max_header_size` (default 65536 bytes), so\nthis is not literally unbounded, but a single ~64KB request line can carry thousands of short\n`key=value` pairs \u2014 far beyond the 1000-field limit the maintainer judged appropriate for the\nstructurally identical body case.\n\n### Attack Scenario\n\n1. Attacker sends a `GET` request whose query string is packed with thousands of short fields\n (e.g. `k0=1\u0026k1=1\u0026...\u0026k7799=1`, ~61KB), fitting comfortably under `max_header_size`. No\n authentication, cookies, or prior state required.\n2. Tornado accepts and parses this with no field-count cap, unlike the equivalent POST-body\n request (which is correctly rejected with 400 once \u003e1000 fields are present).\n3. Parsing thousands of fields is CPU work performed synchronously inside Tornado\u0027s\n single-threaded `IOLoop`. Several such requests in flight concurrently stall the event\n loop, delaying processing of *all* other connections on that loop \u2014 not just the\n attacker\u0027s own request.\n\n### Verification (dynamic, local reproduction against v6.5.8)\n\nRan the unmodified v6.5.8 source directly (no external dependencies needed) with a minimal\n`tornado.web.Application` on `127.0.0.1:8888`.\n\n- Identical 7800-field/~61KB payload sent as GET query string \u2192 `200 OK`; sent as POST body\n (`application/x-www-form-urlencoded`) \u2192 `400 Bad Request` (correctly rejected by the\n existing `max_num_fields` body-path limit). This confirms the asymmetry directly.\n- Per-request parse cost: baseline (`/?a=1`) averaged 1.86ms; the 7800-field query string\n averaged 25.1ms (~13x).\n- Event-loop-blocking amplification (raw-socket test, isolating server-side stall from\n client overhead): with 10 sequential baseline probe requests fired with no load, average\n latency was 1.47ms (max 5.9ms). With 5 concurrent 61KB/7800-field requests in flight,\n the same baseline probes averaged 13.0ms (max 118.1ms) \u2014 an **8.9x average slowdown** for\n unrelated clients, produced by ~305KB of unauthenticated attacker traffic.\n\n### Impact\n\nAll Tornado servers/applications are affected \u2014 this triggers on every request with a query\nstring, independent of application/handler logic. An unauthenticated, unprivileged remote\nattacker can measurably degrade response times for all other clients sharing the same\n`IOLoop`, using a small amount of bandwidth and no special conditions. This is an\navailability/DoS concern; no confidentiality or integrity impact.\n\n### Recommended Fix\n\n```python\n# tornado/httputil.py \u2014 HTTPServerRequest.__init__\nif uri is not None:\n self.path, sep, self.query = uri.partition(\"?\")\ntry:\n self.arguments = parse_qs_bytes(\n self.query,\n keep_blank_values=True,\n max_num_fields=_DEFAULT_PARSE_BODY_CONFIG.urlencoded.max_arguments,\n )\nexcept ValueError as e:\n raise HTTPInputError(\"Invalid query string: %s\" % e) from e\n```\n\nThis reuses the existing `ParseUrlEncodedConfig.max_arguments` default (1000) via the\nmodule\u0027s `_DEFAULT_PARSE_BODY_CONFIG`, matching the POST-body limit and honoring any global\noverride via `set_parse_body_config()`. The `try/except` is necessary because \u2014 unlike\n`parse_body_arguments`, which already wraps its call and converts `ValueError` into a clean\n`HTTPInputError`/400 \u2014 the query-string call site currently has no such handling, so without\nit, a request exceeding the limit would raise an uncaught `ValueError` instead of a clean 400.\n\nVerified: with the fix applied, requests with \u22641000 query-string fields are unaffected;\nrequests with \u003e1000 fields are rejected with `400 Bad Request` (consistent with the POST-body\nbehavior); Tornado\u0027s own `httputil_test` and `web_test` suites (256 tests) pass unchanged.",
"id": "BREW-snakeviz-GHSA-3hv7-mjh2-fv65",
"modified": "2026-10-01T11:45:24Z",
"published": "2026-10-01T11:45:24Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/security/advisories/GHSA-3hv7-mjh2-fv65"
},
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/pull/3719"
},
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/commit/03945136ea9746eccf61caf88edae39642e59c93"
},
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/commit/8a61dd6005f42733015160f3c23a2bcffe200542"
},
{
"type": "PACKAGE",
"url": "https://github.com/tornadoweb/tornado"
},
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/releases/tag/v6.5.9"
}
],
"schema_version": "1.7.3",
"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": "Tornado: Unbounded query-string argument count allows event-loop-stalling DoS",
"upstream": [
"GHSA-3hv7-mjh2-fv65"
]
}
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.