{"id":"CVE-2026-103261","aliases":["AZL-105372","BELL-CVE-2026-103261","CLSA-2026-1790831290","CLSA-2026-1790836405","DEBIAN-CVE-2026-103261","EUVD-2026-90655","GHSA-2v9w-f3h6-74rj","GHSA-3hv7-mjh2-fv65","GHSA-c2m8-h5v5-343r","UBUNTU-CVE-2026-103261"],"status":"published","title":"Tornado before 6.5.9 Denial of Service via Query String","description":"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.","severity":{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N","level":"medium","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-09","score":0.00467,"percentile":0.40714,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-59","CWE-770"],"published":"2026-09-30T23:48:55Z","modified":"2026-11-06T20:00:33Z","fixes":[{"ecosystem":"PyPI","package":"tornado","fixed_in":["6.5.9"],"sources":["ghsa","osv"]},{"ecosystem":"Alpaquita:25","package":"py3-tornado","fixed_in":["6.5.10-r0"],"sources":["osv"]},{"ecosystem":"Alpaquita:stream","package":"py3-tornado","fixed_in":["6.5.10-r0"],"sources":["osv"]},{"ecosystem":"TuxCare:PyPI","package":"tornado","fixed_in":["6.1.0.post9+tuxcare","5.1.1.post5+tuxcare"],"sources":["osv"]}],"affected":[{"source":"ghsa","ecosystem":"PyPI","package":"tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"6.5.8"},{"fixed":"6.5.9"}]}]},{"source":"ghsa","ecosystem":"PyPI","package":"tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"6.5.8"},{"fixed":"6.5.9"}]}]},{"source":"osv","ecosystem":"Azure Linux:3","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"6.3.3-11"}]}]},{"source":"osv","ecosystem":"Alpaquita:25","package":"py3-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"6.5.1-r0"},{"fixed":"6.5.10-r0"}]}]},{"source":"osv","ecosystem":"Alpaquita:stream","package":"py3-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"6.3.3-r0"},{"fixed":"6.5.10-r0"}]}]},{"source":"osv","ecosystem":"TuxCare:PyPI","package":"tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"6.1"},{"fixed":"6.1.0.post9+tuxcare"}]}],"versions":["6.1","6.1.0.post1+tuxcare","6.1.0.post2+tuxcare","6.1.0.post3+tuxcare","6.1.0.post4+tuxcare","6.1.0.post5+tuxcare","6.1.0.post6+tuxcare","6.1.0.post7+tuxcare","6.1.0.post8+tuxcare"]},{"source":"osv","ecosystem":"TuxCare:PyPI","package":"tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"5.1.1"},{"fixed":"5.1.1.post5+tuxcare"}]}],"versions":["5.1.1","5.1.1.post1+tuxcare","5.1.1.post2+tuxcare","5.1.1.post3+tuxcare","5.1.1.post4+tuxcare"]},{"source":"osv","ecosystem":"Debian:12","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["6.2.0-3","6.2.0-3+deb12u1","6.2.0-3+deb12u2","6.2.0-3+deb12u3","6.2.0-3+deb12u4","6.3.2-1","6.4.0-1","6.4.0-2","6.4.1-1","6.4.1-2","6.4.1-3","6.4.2-1","6.4.2-2","6.4.2-3","6.5.2-1","6.5.2-2","6.5.2-3","6.5.4-0.1","6.5.4-1","6.5.5-1","6.5.5-2","6.5.5-3","6.5.5-4","6.5.5-5"]},{"source":"osv","ecosystem":"Debian:13","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["6.4.2-3","6.4.2-3+deb13u1","6.4.2-3+deb13u2","6.5.2-1","6.5.2-2","6.5.2-3","6.5.4-0.1","6.5.4-1","6.5.5-1","6.5.5-2","6.5.5-3","6.5.5-4","6.5.5-5"]},{"source":"osv","ecosystem":"Debian:14","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["6.4.2-3","6.5.2-1","6.5.2-2","6.5.2-3","6.5.4-0.1","6.5.4-1","6.5.5-1","6.5.5-2","6.5.5-3","6.5.5-4","6.5.5-5"]},{"source":"osv","ecosystem":"PyPI","package":"tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"6.5.9"}]}],"versions":["0.2","1.0","1.1","1.1.1","1.2","1.2.1","2.0","2.1","2.1.1","2.2","2.2.1","2.3","2.4","2.4.1","3.0","3.0.1","3.0.2","3.1","3.1.1","3.2","3.2.1","3.2.2","4.0","4.0.1","4.0.2","4.1","4.1b2","4.2","4.2.1","4.2b1","4.3","4.3b1","4.3b2","4.4","4.4.1","4.4.2","4.4.3","4.4b1","4.5","4.5.1","4.5.2","4.5.3","4.5b1","4.5b2","5.0","5.0.1","5.0.2","5.0a1","5.0b1","5.1","5.1.1","5.1b1","6.0","6.0.1","6.0.2","6.0.3","6.0.4","6.0a1","6.0b1","6.1","6.1b1","6.1b2","6.2","6.2b1","6.2b2","6.3","6.3.1","6.3.2","6.3.3","6.3b1","6.4","6.4.1","6.4.2","6.4b1","6.5","6.5.1","6.5.2","6.5.3","6.5.4","6.5.5","6.5.6","6.5.7","6.5.8","6.5b1"]},{"source":"osv","ecosystem":"PyPI","package":"tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"6.5.9"}]}],"versions":["0.2","1.0","1.1","1.1.1","1.2","1.2.1","2.0","2.1","2.1.1","2.2","2.2.1","2.3","2.4","2.4.1","3.0","3.0.1","3.0.2","3.1","3.1.1","3.2","3.2.1","3.2.2","4.0","4.0.1","4.0.2","4.1","4.1b2","4.2","4.2.1","4.2b1","4.3","4.3b1","4.3b2","4.4","4.4.1","4.4.2","4.4.3","4.4b1","4.5","4.5.1","4.5.2","4.5.3","4.5b1","4.5b2","5.0","5.0.1","5.0.2","5.0a1","5.0b1","5.1","5.1.1","5.1b1","6.0","6.0.1","6.0.2","6.0.3","6.0.4","6.0a1","6.0b1","6.1","6.1b1","6.1b2","6.2","6.2b1","6.2b2","6.3","6.3.1","6.3.2","6.3.3","6.3b1","6.4","6.4.1","6.4.2","6.4b1","6.5","6.5.1","6.5.2","6.5.3","6.5.4","6.5.5","6.5.6","6.5.7","6.5.8","6.5b1"]},{"source":"osv","ecosystem":"Ubuntu:Pro:16.04:LTS","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["4.2.1-1ubuntu2","4.2.1-1ubuntu3","4.2.1-1ubuntu3.1","4.2.1-1ubuntu3.1+esm1","4.2.1-1ubuntu3.1+esm2","4.2.1-1ubuntu3.1+esm3","4.2.1-1ubuntu3.1+esm4","4.2.1-1ubuntu3.1+esm5"]},{"source":"osv","ecosystem":"Ubuntu:Pro:18.04:LTS","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["4.5.1-2.1~build2","4.5.2-1","4.5.3-1","4.5.3-1ubuntu0.1","4.5.3-1ubuntu0.2","4.5.3-1ubuntu0.2+esm1","4.5.3-1ubuntu0.2+esm2","4.5.3-1ubuntu0.2+esm3","4.5.3-1ubuntu0.2+esm4","4.5.3-1ubuntu0.2+esm5"]},{"source":"osv","ecosystem":"Ubuntu:Pro:20.04:LTS","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["5.1.1-4ubuntu1","5.1.1-4ubuntu5","6.0.3+really5.1.1-2","6.0.3+really5.1.1-2build1","6.0.3+really5.1.1-2build2","6.0.3+really5.1.1-3","6.0.3+really5.1.1-3ubuntu0.1~esm1","6.0.3+really5.1.1-3ubuntu0.1~esm3","6.0.3+really5.1.1-3ubuntu0.1~esm5","6.0.3+really5.1.1-3ubuntu0.1~esm7","6.0.3+really5.1.1-3ubuntu0.1~esm8"]},{"source":"osv","ecosystem":"Ubuntu:Pro:22.04:LTS","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["6.1.0-1build1","6.1.0-2","6.1.0-3","6.1.0-3build1","6.1.0-3ubuntu0.1~esm1","6.1.0-3ubuntu0.1~esm2","6.1.0-3ubuntu0.1~esm4","6.1.0-3ubuntu0.1~esm5","6.1.0-3ubuntu0.1~esm6","6.1.0-3ubuntu0.1~esm7"]},{"source":"osv","ecosystem":"Ubuntu:24.04:LTS","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["6.3.2-1","6.4.0-0ubuntu1","6.4.0-1","6.4.0-1build1","6.4.0-1ubuntu0.1","6.4.0-1ubuntu0.2","6.4.0-1ubuntu0.4","6.4.0-1ubuntu0.5","6.4.0-1ubuntu0.6","6.4.0-1ubuntu0.7"]},{"source":"osv","ecosystem":"Ubuntu:26.10","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["6.5.4-0.1","6.5.5-2","6.5.5-4","6.5.5-5","6.5.5-5ubuntu1.1","6.5.5-5ubuntu1.2"]},{"source":"osv","ecosystem":"Ubuntu:26.04:LTS","package":"python-tornado","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["6.4.2-3","6.5.2-3","6.5.4-0.1","6.5.4-0.1ubuntu0.1","6.5.4-0.1ubuntu0.2","6.5.4-0.1ubuntu0.3"]}],"products":[{"vendor":"tornadoweb","product":"tornado"}],"references":[{"url":"https://github.com/tornadoweb/tornado/security/advisories/GHSA-3hv7-mjh2-fv65","tags":["vendor-advisory","advisory","web","report"]},{"url":"https://github.com/tornadoweb/tornado/commit/03945136ea9746eccf61caf88edae39642e59c93","tags":["patch","fix","web","report"]},{"url":"https://www.vulncheck.com/advisories/tornado-before-6.5.9-denial-of-service-via-query-string","tags":["third-party-advisory","advisory","report"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-103261","tags":["web","advisory"]},{"url":"https://github.com/advisories/GHSA-2v9w-f3h6-74rj"},{"url":"https://github.com/tornadoweb/tornado/pull/3719","tags":["web"]},{"url":"https://github.com/tornadoweb/tornado/commit/8a61dd6005f42733015160f3c23a2bcffe200542","tags":["web"]},{"url":"https://github.com/tornadoweb/tornado/releases/tag/v6.5.9","tags":["web"]},{"url":"https://github.com/advisories/GHSA-3hv7-mjh2-fv65"},{"url":"https://github.com/tornadoweb/tornado/security/advisories/GHSA-c2m8-h5v5-343r","tags":["web"]},{"url":"https://github.com/tornadoweb/tornado/commit/437ab5f76565403b3403438d2e555d11f8128d32","tags":["web"]},{"url":"https://github.com/tornadoweb/tornado/commit/f9f50b8bab265a2abf9f2415a3d2da6264a5450b","tags":["web"]},{"url":"https://github.com/advisories/GHSA-c2m8-h5v5-343r"},{"url":"https://docs.bell-sw.com/security/cves/CVE-2026-103261","tags":["advisory"]},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/103xxx/CVE-2026-103261.json","tags":["advisory"]},{"url":"https://security-tracker.debian.org/tracker/CVE-2026-103261","tags":["advisory"]},{"url":"https://github.com/tornadoweb/tornado","tags":["package"]},{"url":"https://ubuntu.com/security/CVE-2026-103261","tags":["report"]},{"url":"https://www.cve.org/CVERecord?id=CVE-2026-103261","tags":["report"]}],"sources":{"cve":[{"id":"CVE-2026-103261","title":"Tornado before 6.5.9 Denial of Service via Query String","description":"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.","status":"published","published":"2026-10-01T10:42:04.23Z","modified":"2026-10-01T14:44:26.683Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N","level":"medium","assessor":"cna"},{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium","assessor":"cna"}],"cwes":["CWE-770"],"updated_at":"2026-11-02T13:34:48.658051Z"}],"euvd":[{"id":"EUVD-2026-90655","description":"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.","status":"published","published":"2026-10-01T10:42:04Z","modified":"2026-10-01T14:44:26Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N","level":"medium"}],"updated_at":"2026-11-02T17:27:45.695512Z"}],"ghsa":[{"id":"GHSA-2v9w-f3h6-74rj","title":"Tornado before 6.5.9 fails to limit the number of query string fields in HTTPServerRequest...","description":"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.","status":"published","published":"2026-10-01T12:31:16Z","modified":"2026-10-01T12:31:23Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS: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:X","level":"medium"},{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"}],"cwes":["CWE-770"],"updated_at":"2026-11-02T15:12:39.998127Z"},{"id":"GHSA-3hv7-mjh2-fv65","title":"Tornado: Unbounded query-string argument count allows event-loop-stalling DoS","description":"## Summary\n\n`HTTPServerRequest.__init__` in `tornado/httputil.py` parses the URL query string via\n`parse_qs_bytes()` with no field-count limit — 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 — 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 — 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's\n   single-threaded `IOLoop`. Several such requests in flight concurrently stall the event\n   loop, delaying processing of *all* other connections on that loop — not just the\n   attacker's 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 → `200 OK`; sent as POST body\n  (`application/x-www-form-urlencoded`) → `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) — 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 — 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 — 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's `_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 — unlike\n`parse_body_arguments`, which already wraps its call and converts `ValueError` into a clean\n`HTTPInputError`/400 — 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 ≤1000 query-string fields are unaffected;\nrequests with \u003e1000 fields are rejected with `400 Bad Request` (consistent with the POST-body\nbehavior); Tornado's own `httputil_test` and `web_test` suites (256 tests) pass unchanged.","status":"published","published":"2026-09-30T23:49:25Z","modified":"2026-09-30T23:49:26Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"}],"cwes":["CWE-770"],"updated_at":"2026-11-02T14:41:23.783773Z"},{"id":"GHSA-c2m8-h5v5-343r","title":"Tornado: StaticFileHandler follows symlinks outside static root (path traversal)","description":"### Summary\nStaticFileHandler allows an unauthenticated attacker to read arbitrary files from the server's filesystem by requesting a path that resolves to a symbolic link placed inside the static root directory. Any application that serves user-uploadable content, or whose static directory is populated by a build/deploy pipeline that creates symlinks (e.g. npm link, webpack, Docker volume mounts, CDN sync tools), is affected. An attacker who can trigger the creation of a symlink pointing outside the static root—or exploit one that already exists—can retrieve sensitive files such as /etc/passwd, private keys, configuration files, or application secrets.\n\n\n\n### Details\nThe vulnerability is in tornado.web.StaticFileHandler, specifically in the interaction between two methods in [tornado/web.py](vscode-webview://1grdaaa6v64itrol06qu70pq3d1b9h9im5c3npvvc9snaprc1q9f/tornado/web.py):\n- [get_absolute_path](https://github.com/tornadoweb/tornado/blob/0ca3c5f8279d402b245718d16522bc18a8f5d958/tornado/web.py#L2932) uses `os.path.abspath()` to resolve the requested path:\n- [validate_absolute_path](https://github.com/tornadoweb/tornado/blob/0ca3c5f8279d402b245718d16522bc18a8f5d958/tornado/web.py#L2935) uses the same `os.path.abspath()` on the root, then performs a string prefix check:\n\n`os.path.abspath()` normalises . and .. segments but does not resolve symbolic links. As a result, a path like `/var/www/static/link` passes the `startswith(\"/var/www/static/\")` check regardless of where `link` actually points.\n\nImmediately after, `os.path.exists()` and `os.path.isfile()`  **do follow symlinks**, so the file they ultimately open is the symlink's target. The fix would be to replace os.path.abspath() with os.path.realpath() in both methods, so the resolved real path of the symlink target is validated against the root, not just its string representation inside the static directory.\n\n### PoC\n_Complete instructions, including specific configuration details, to reproduce the vulnerability._\nPrerequisites: Python 3.x, Tornado installed.\n1. Create the environment\n```\nmkdir -p /tmp/static\necho \"DB_PASSWORD=s3cr3t\" \u003e /tmp/secret.conf   \nln -s /tmp/secret.conf /tmp/static/config.conf \n```\n2. Minimal vulnerable server (server.py):\n```\nimport tornado.web, tornado.ioloop\n\napp = tornado.web.Application([\n    (r\"/static/(.*)\", tornado.web.StaticFileHandler, {\"path\": \"/tmp/static\"}),\n])\napp.listen(8888)\ntornado.ioloop.IOLoop.current().start()\n```\n3. Exploit:\n`curl http://localhost:8888/static/config.conf`\n\n### Impact\nAny application using StaticFileHandler is potentially affected if:\n- the static directory contains symlinks pointing outside it (common with build tooling), or\n- the application allows file uploads into the static directory without stripping symlinks.\n\nAn unauthenticated remote attacker can read any file readable by the process user: application secrets, private TLS keys, database credentials, /etc/shadow, SSH keys, or source code (depending on the process's filesystem permissions).","status":"published","published":"2026-09-30T23:48:55Z","modified":"2026-09-30T23:48:56Z","cvss":[{"version":"4.0","score":8.2,"vector":"CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N","level":"high"}],"cwes":["CWE-59"],"updated_at":"2026-11-02T14:41:23.783773Z"}],"nvd":[{"id":"CVE-2026-103261","description":"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.","status":"published","published":"2026-10-01T11:17:20.847Z","modified":"2026-10-01T15:17:26.58Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS: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:X","level":"medium","assessor":"secondary"},{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium","assessor":"secondary"}],"cwes":["CWE-770"],"updated_at":"2026-11-02T14:12:53.097633Z"}],"osv":[{"id":"BELL-CVE-2026-103261","status":"published","published":"2026-10-02T06:05:04.836306Z","modified":"2026-10-07T07:15:15.984934595Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"}],"updated_at":"2026-11-02T15:28:23.037038Z"},{"id":"CVE-2026-103261","title":"Tornado before 6.5.9 Denial of Service via Query String","description":"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.","status":"published","published":"2026-10-01T10:42:04.23Z","modified":"2026-10-28T02:31:07.023282854Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N","level":"medium"}],"cwes":["CWE-770"],"updated_at":"2026-11-02T15:44:54.236592Z"},{"id":"DEBIAN-CVE-2026-103261","description":"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.","status":"published","published":"2026-10-01T11:17:20.847Z","modified":"2026-10-02T05:01:06.126312246Z","cvss":[{"version":"4.0","score":6.9,"vector":"CVSS: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:X","level":"medium"}],"updated_at":"2026-11-02T15:45:48.903138Z"},{"id":"GHSA-3hv7-mjh2-fv65","title":"Tornado: Unbounded query-string argument count allows event-loop-stalling DoS","description":"## Summary\n\n`HTTPServerRequest.__init__` in `tornado/httputil.py` parses the URL query string via\n`parse_qs_bytes()` with no field-count limit — 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 — 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 — 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's\n   single-threaded `IOLoop`. Several such requests in flight concurrently stall the event\n   loop, delaying processing of *all* other connections on that loop — not just the\n   attacker's 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 → `200 OK`; sent as POST body\n  (`application/x-www-form-urlencoded`) → `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) — 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 — 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 — 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's `_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 — unlike\n`parse_body_arguments`, which already wraps its call and converts `ValueError` into a clean\n`HTTPInputError`/400 — 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 ≤1000 query-string fields are unaffected;\nrequests with \u003e1000 fields are rejected with `400 Bad Request` (consistent with the POST-body\nbehavior); Tornado's own `httputil_test` and `web_test` suites (256 tests) pass unchanged.","status":"published","published":"2026-09-30T23:49:25Z","modified":"2026-10-02T03:56:09.601689224Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"}],"cwes":["CWE-770"],"updated_at":"2026-11-02T15:46:35.747385Z"},{"id":"GHSA-c2m8-h5v5-343r","title":"Tornado: StaticFileHandler follows symlinks outside static root (path traversal)","description":"### Summary\nStaticFileHandler allows an unauthenticated attacker to read arbitrary files from the server's filesystem by requesting a path that resolves to a symbolic link placed inside the static root directory. Any application that serves user-uploadable content, or whose static directory is populated by a build/deploy pipeline that creates symlinks (e.g. npm link, webpack, Docker volume mounts, CDN sync tools), is affected. An attacker who can trigger the creation of a symlink pointing outside the static root—or exploit one that already exists—can retrieve sensitive files such as /etc/passwd, private keys, configuration files, or application secrets.\n\n\n\n### Details\nThe vulnerability is in tornado.web.StaticFileHandler, specifically in the interaction between two methods in [tornado/web.py](vscode-webview://1grdaaa6v64itrol06qu70pq3d1b9h9im5c3npvvc9snaprc1q9f/tornado/web.py):\n- [get_absolute_path](https://github.com/tornadoweb/tornado/blob/0ca3c5f8279d402b245718d16522bc18a8f5d958/tornado/web.py#L2932) uses `os.path.abspath()` to resolve the requested path:\n- [validate_absolute_path](https://github.com/tornadoweb/tornado/blob/0ca3c5f8279d402b245718d16522bc18a8f5d958/tornado/web.py#L2935) uses the same `os.path.abspath()` on the root, then performs a string prefix check:\n\n`os.path.abspath()` normalises . and .. segments but does not resolve symbolic links. As a result, a path like `/var/www/static/link` passes the `startswith(\"/var/www/static/\")` check regardless of where `link` actually points.\n\nImmediately after, `os.path.exists()` and `os.path.isfile()`  **do follow symlinks**, so the file they ultimately open is the symlink's target. The fix would be to replace os.path.abspath() with os.path.realpath() in both methods, so the resolved real path of the symlink target is validated against the root, not just its string representation inside the static directory.\n\n### PoC\n_Complete instructions, including specific configuration details, to reproduce the vulnerability._\nPrerequisites: Python 3.x, Tornado installed.\n1. Create the environment\n```\nmkdir -p /tmp/static\necho \"DB_PASSWORD=s3cr3t\" \u003e /tmp/secret.conf   \nln -s /tmp/secret.conf /tmp/static/config.conf \n```\n2. Minimal vulnerable server (server.py):\n```\nimport tornado.web, tornado.ioloop\n\napp = tornado.web.Application([\n    (r\"/static/(.*)\", tornado.web.StaticFileHandler, {\"path\": \"/tmp/static\"}),\n])\napp.listen(8888)\ntornado.ioloop.IOLoop.current().start()\n```\n3. Exploit:\n`curl http://localhost:8888/static/config.conf`\n\n### Impact\nAny application using StaticFileHandler is potentially affected if:\n- the static directory contains symlinks pointing outside it (common with build tooling), or\n- the application allows file uploads into the static directory without stripping symlinks.\n\nAn unauthenticated remote attacker can read any file readable by the process user: application secrets, private TLS keys, database credentials, /etc/shadow, SSH keys, or source code (depending on the process's filesystem permissions).","status":"published","published":"2026-09-30T23:48:55Z","modified":"2026-10-02T03:56:10.964622908Z","cvss":[{"version":"4.0","score":8.2,"vector":"CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N","level":"high"}],"cwes":["CWE-59"],"updated_at":"2026-11-02T15:46:44.571161Z"},{"id":"UBUNTU-CVE-2026-103261","description":"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.","status":"published","published":"2026-10-01T11:17:00Z","modified":"2026-11-06T20:00:33.673623846Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"},{"version":"4.0","score":6.9,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N","level":"medium"}],"updated_at":"2026-11-06T20:07:08.647364Z"},{"id":"AZL-105372","title":"CVE-2026-103261 affecting package python-tornado 6.3.3-11","description":"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.","status":"published","published":"2026-10-01T11:17:20Z","modified":"2026-11-05T05:36:30Z","updated_at":"2026-11-05T16:41:17.719431Z"},{"id":"CLSA-2026-1790831290","title":"TuxCare security update for tornado (2 CVEs)","description":"TuxCare rebuilt tornado to address 2 CVEs. Fixed in 6.1.0.post9+tuxcare.","status":"published","published":"2026-10-01T05:08:10Z","modified":"2026-10-15T09:48:02.378415557Z","updated_at":"2026-11-02T15:43:48.65893Z"},{"id":"CLSA-2026-1790836405","title":"TuxCare security update for tornado (2 CVEs)","description":"TuxCare rebuilt tornado to address 2 CVEs. Fixed in 5.1.1.post5+tuxcare.","status":"published","published":"2026-10-01T06:33:25Z","modified":"2026-10-15T09:48:02.312073066Z","updated_at":"2026-11-02T15:43:48.65893Z"}]}}
