CVE-2026-103261

mediumpriority · track · 21EPSS 0.005 · 40 %ileUS
grimoire priorityP3 · Track
Exploitable?
—
no known exploitation
Likely / severe?
0.47% (40th) EPSS
CVSS 5.3 · medium
Is it mine?
1 products
Check your exposure
Who says so?
1 sources
osv

EPSS · scored 2026-10-28

Tornado before 6.5.9 fails to limit the number of query string fields in HTTPServerRequest.__init__, allowing remote attackers to cause event-loop stalling by sending requests with thousands of query parameters. Attackers can send unauthenticated GET requests with unbounded query-string field counts to degrade response times for all clients sharing the same IOLoop.

01

cvss

Technical severity, 0–10. One row per scoring source.

6.9
010

base score 0–10 · higher = more severe · hover a vector to decode⚠ sources disagree · low–medium · 2.7–6.9

v3.15.3mediumCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:Lprimary · disclosure@vulncheck.comexploitability 3.9 · impact 1.4
v3.15.3mediumCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:Lsecondary · OSV
v4.06.9mediumCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:Xsecondary · disclosure@vulncheck.com
v4.02.7lowCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:U/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:Xsecondary · CVSS-BT (t0sche)
02

ssvc

A stakeholder decision — act, attend, or track — from exploitation and impact.

Exploitation: none · Automatable: yes · Technical Impact: partialcoordinatorcisa_ssvcUS
03

weaknesses

The CWE weakness classes this flaw belongs to.

04

affected products (1)

Products and version ranges known to be affected.

05

advisories (1)

Vendor and ecosystem advisories describing this.

  • GHSA-3hv7-mjh2-fv65Tornado: Unbounded query-string argument count allows event-loop-stalling DoS2026-09-30ghsa · osvUS

    ## Summary `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 ```python # 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: ```python # 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 1. Attacker sends a `GET` request whose query string is packed with thousands of short fields (e.g. `k0=1&k1=1&...&k7799=1`, ~61KB), fitting comfortably under `max_header_size`. No authentication, cookies, or prior state required. 2. 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). 3. 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 existing `max_num_fields` body-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 ```python # 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.

06

sources

Which sources assert this record, and when.

published 2026-10-01modified 2026-10-01
07

references (3) nvd

External citations — patches, advisories, analysis.

scry

Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.

Free scan