Oct 28, 2026·11 min read·4 visits
An unauthenticated remote attacker can send an HTTP/1.1 request or response with millions of 1-byte chunks to a Tornado server or client (< 6.6.0), monopolizing the Python asyncio event loop and causing approximately 20x memory amplification.
Tornado versions prior to 6.6.0 contain an uncontrolled resource consumption vulnerability (CWE-400) in the HTTP/1.1 chunked transfer encoding parser. When processing incoming chunked payloads that have already buffered in the socket read buffer, Tornado parses chunks in a tight synchronous loop without yielding to the event loop. Furthermore, non-streaming request handlers and SimpleAsyncHTTPClient store each chunk as a discrete Python bytes object inside a list, inducing an approximately 20-fold memory overhead for 1-byte chunks.
Tornado is an asynchronous networking library and web framework for Python that utilizes a non-blocking single-threaded event loop (IOLoop, built on top of standard library asyncio) to multiplex thousands of concurrent network connections. Because all connections within a single Tornado process share the same event loop thread, the responsiveness of the entire server depends on cooperative multitasking. Every coroutine must periodically yield execution back to the scheduler so that pending I/O events and timers for other connections can be processed.
In Tornado versions prior to 6.6.0, the HTTP/1.1 protocol implementation (tornado/http1connection.py) fails to yield control to the event loop while decoding Transfer-Encoding: chunked message bodies if the underlying data is already present in the socket read buffer. When a remote peer transmits a stream of diminutive chunks—such as millions of 1-byte chunks (1\r\nx\r\n)—that arrive inside the operating system and IOStream buffers simultaneously, the chunk parser processes them in an uninterrupted synchronous loop. This behavior violates the cooperative multitasking model and blocks the event loop thread for extended durations.
In addition to CPU monopolization, the vulnerability exposes a secondary memory exhaustion vector tracked under CWE-400 (Uncontrolled Resource Consumption) and CWE-770 (Allocation of Resources Without Limits or Throttling). For standard request handlers that do not explicitly apply the @stream_request_body decorator, as well as the internal SimpleAsyncHTTPClient, Tornado accumulates incoming chunks by appending each slice as an individual bytes object into a Python list. Due to CPython object header and list pointer overhead, storing 1-byte chunks consumes approximately 120 bytes of memory per chunk, resulting in roughly 20-fold memory amplification relative to the actual HTTP body length.
The primary root cause of the event loop starvation lies in how Tornado's IOStream interacts with Python's await keyword when reading buffered socket data. In tornado/http1connection.py, the _read_chunked_body coroutine iterates over incoming chunks using await self.stream.read_until(b"\r\n", max_bytes=64) to read the hexadecimal chunk length header, followed by await self.stream.read_bytes(...) to read the chunk payload and trailing CRLF terminator.
In Python's asyncio architecture, executing await future only suspends the coroutine and yields control back to the event loop scheduler if the awaited Future object is currently pending (future.done() is False). When IOStream reads from the socket, it pulls available data into an internal memory buffer in bulk. If an incoming TCP segment contains thousands of pre-formatted tiny chunks, subsequent calls to stream.read_until() and stream.read_bytes() are satisfied immediately from the in-memory buffer and return pre-completed Future objects (future.done() is True). Consequently, await resolves synchronously without returning control to the IOLoop. At an average parsing cost of approximately 6 microseconds per chunk, processing millions of buffered 1-byte chunks blocks the process thread for minutes at a time.
The second technical flaw involves the data structures used to buffer chunked bodies before assembling the final HTTPServerRequest.body or HTTPResponse.body. In tornado/httpserver.py (_PerRequestAdapter), tornado/web.py (_RequestDispatcher), and tornado/simple_httpclient.py (_HTTPConnection), the data_received callback accumulated each decoded chunk directly into self._chunks = [] via self._chunks.append(chunk). Upon completion of the request, the fragments were concatenated using b"".join(self._chunks).
When _read_chunked_body invoked delegate.data_received(chunk) once per chunk, a stream of 1-byte chunks created millions of distinct 1-byte bytes objects. On 64-bit CPython, a 1-byte bytes object requires 33 bytes of allocation (object reference count, type pointer, size field, hash cache, null terminator, and 1 byte of payload), plus allocator alignment padding, an 8-byte pointer inside the dynamically resizing list array, and garbage collector tracking structures. This architecture allowed an attacker to bypass the protective intent of max_body_size by multiplying memory consumption by a factor of roughly 20.
> [!NOTE]
> A third minor defect existed in the chunk termination check within _read_chunked_body. Tornado verified the trailing \r\n delimiter after each chunk payload using assert crlf == b"\r\n". When Python is executed with optimization flags (-O or PYTHONOPTIMIZE=1), assert statements are stripped from the bytecode, causing malformed chunk terminators to be ignored silently. In unoptimized runtimes, invalid terminators raised an unhandled AssertionError rather than a catchable httputil.HTTPInputError.
Commit ceab33581489751a66fba8566ad6d5960d4b01c8 resolves both the event loop starvation and memory amplification vulnerabilities across tornado/http1connection.py, tornado/httpserver.py, tornado/web.py, and tornado/simple_httpclient.py.
Prior to the patch, _read_chunked_body in tornado/http1connection.py processed chunks in a direct loop without tracking synchronous execution time or coalescing small reads:
# Vulnerable implementation in tornado/http1connection.py (prior to 6.6.0)
async def _read_chunked_body(self, delegate: httputil.HTTPMessageDelegate) -> None:
total_size = 0
while True:
chunk_len_str = await self.stream.read_until(b"\r\n", max_bytes=64)
try:
chunk_len = int(chunk_len_str.strip(), 16)
except ValueError:
raise httputil.HTTPInputError("invalid chunk size")
if chunk_len == 0:
break
total_size += chunk_len
if total_size > self._max_body_size:
raise httputil.HTTPInputError("chunked body too large")
bytes_to_read = chunk_len
while bytes_to_read:
chunk = await self.stream.read_bytes(
min(bytes_to_read, self.params.chunk_size), partial=True
)
bytes_to_read -= len(chunk)
if not self._is_client and self.stream.closed():
return
ret = delegate.data_received(chunk)
if ret is not None:
await ret
# assert is stripped under python -O and raises AssertionError instead of HTTPInputError
crlf = await self.stream.read_bytes(2)
assert crlf == b"\r\n"The patched implementation in tornado/http1connection.py introduces two module-level constants, _YIELD_THRESHOLD = 256 * 1024 (256 KB) and _CHUNK_YIELD_COST = 1024 (1 KB), alongside a local bytearray buffer (pending) to coalesce tiny chunks and periodically force an event loop turn via await asyncio.sleep(0):
# Patched implementation in tornado/http1connection.py (version 6.6.0)
_YIELD_THRESHOLD = 256 * 1024
_CHUNK_YIELD_COST = 1024
async def _read_chunked_body(self, delegate: httputil.HTTPMessageDelegate) -> None:
total_size = 0
pending = bytearray()
work = 0
async def flush() -> None:
if not pending:
return
data = bytes(pending)
pending.clear()
ret = delegate.data_received(data)
if ret is not None:
await ret
async def read(future: "Future[bytes]") -> bytes:
nonlocal work
if not _is_done(future):
# Stream must wait on network I/O; flush buffered chunks immediately
await flush()
work = 0
return await future
while True:
chunk_len_ bytes = await read(
self.stream.read_until(b"\r\n", max_bytes=64)
)
work += _CHUNK_YIELD_COST # Penalize each chunk header by 1 KB virtual weight
# ... [chunk_len parsing and max_body_size checks] ...
bytes_to_read = chunk_len
while bytes_to_read:
chunk = await read(
self.stream.read_bytes(
min(bytes_to_read, self.params.chunk_size), partial=True
)
)
bytes_to_read -= len(chunk)
pending += chunk
work += len(chunk)
if len(pending) >= self.params.chunk_size:
await flush()
if work >= _YIELD_THRESHOLD:
work = 0
await asyncio.sleep(0) # Force yield to IOLoop scheduler
crlf = await read(self.stream.read_bytes(2))
if crlf != b"\r\n":
raise httputil.HTTPInputError("improperly terminated chunk")
if work >= _YIELD_THRESHOLD:
work = 0
await asyncio.sleep(0)
await flush()To address the memory amplification in non-streaming handlers and the HTTP client, tornado/httpserver.py, tornado/web.py, and tornado/simple_httpclient.py were updated to replace list[bytes] with a contiguous bytearray() buffer:
# Patched chunk accumulation in tornado/httpserver.py, tornado/web.py, and tornado/simple_httpclient.py
# Before:
# self._chunks: list[bytes] = []
# self._chunks.append(chunk)
# self.request.body = b"".join(self._chunks)
# After:
self._chunks = bytearray()
# In data_received:
self._chunks += chunk
# In finish:
self.request.body = bytes(self._chunks)The fix is comprehensive and addresses all identified aspects of the vulnerability. By assigning a synthetic cost of _CHUNK_YIELD_COST (1,024 bytes) to every chunk header, the parser guarantees that await asyncio.sleep(0) is executed at least once every 256 chunks, even for 1-byte payloads (~1.5 ms of CPU time per yield). Simultaneously, coalescing chunks into pending = bytearray() and replacing self._chunks with bytearray() eliminates per-chunk Python object overhead for both streaming and non-streaming handlers. The _read_fixed_body method was similarly updated with _YIELD_THRESHOLD accounting to ensure large buffered fixed-length bodies also yield periodically.
Exploiting CVE-2026-115927 does not require authentication, special privileges, or complex state manipulation. The vulnerability can be triggered against any internet-facing Tornado HTTP/1.1 server directly, or against a client application utilizing SimpleAsyncHTTPClient if the client can be induced to connect to an attacker-controlled HTTP server.
To attack a Tornado server, a client establishes a standard TCP connection and sends an HTTP request (such as POST / HTTP/1.1) containing the header Transfer-Encoding: chunked. Instead of sending body data in standard buffer sizes, the attacker fills the TCP send window with consecutive 6-byte sequences representing 1-byte chunks: 1\r\nx\r\n (0x31 0x0d 0x0a 0x78 0x0d 0x0a).
When these packets arrive at the server and are read into the IOStream buffer, Tornado enters _read_chunked_body. With the default max_body_size of 100 MB (104,857,600 bytes), a single request can legally contain up to 104,857,600 individual 1-byte chunks before exceeding the body size limit. Even if an attacker sends a much smaller payload—such as 1,000,000 chunks (approximately 6 MB of wire traffic)—the server will spend several seconds continuously executing the loop without yielding to IOLoop, halting all concurrent requests on that worker process and allocating roughly 120 MB of heap memory if the target route does not use @stream_request_body.
The regression test added in commit ceab33581489751a66fba8566ad6d5960d4b01c8 (tornado/test/http1connection_test.py) demonstrates the mechanism used to verify event loop starvation:
# Adapted from tornado/test/http1connection_test.py (test_chunked_body_tiny_chunks)
num_chunks = 5000
payload = (
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+ b"1\r\nx\r\n" * num_chunks
+ b"0\r\n\r\n"
)
# Write the entire chunked stream into the socket buffer at once
yield self.server_stream.write(payload)
# Background coroutine that increments `ticks` on each event loop turn
ticks = 0
running = True
async def ticker():
nonlocal ticks
while running:
await asyncio.sleep(0)
ticks += 1
asyncio.ensure_future(ticker())
yield conn.read_response(delegate)
running = False
# On vulnerable Tornado (< 6.6.0), `ticks` remains 0 because read_response
# processes all 5,000 buffered chunks synchronously without yielding.
# On patched Tornado (>= 6.6.0), `ticks` is >= 10.The primary security impact of CVE-2026-115927 is denial of service (DoS) affecting availability, scored at CVSS v3.1 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L). There is no direct impact on confidentiality (C:N) or integrity (I:N), as the flaw does not permit out-of-bounds memory reads, buffer overwrites, or HTTP request smuggling.
In production deployments, the impact severity depends heavily on the deployment architecture:
@stream_request_body, sending 10 MB of 1-byte chunks (60 MB on the wire) forces the Python process to allocate roughly 1.2 GB of RAM for the self._chunks list, risking Out-Of-Memory (OOM) termination by the operating system kernel.tornado.simple_httpclient.SimpleAsyncHTTPClient to fetch user-supplied URLs (e.g., webhook dispatchers, web scrapers, or federated services) are vulnerable to the exact same event loop freeze and memory amplification if a remote server replies with a malicious chunked response.As of October 2026, the Exploit Prediction Scoring System (EPSS) assigns this vulnerability a score of 0.00384 (32.33rd percentile), and it is not listed in the CISA Known Exploited Vulnerabilities (KEV) catalog.
Administrators and developers should upgrade Tornado to version 6.6.0 or later, which includes commit ceab33581489751a66fba8566ad6d5960d4b01c8.
pip install --upgrade "tornado>=6.6.0"If an immediate upgrade to Tornado 6.6.0 is not feasible, the following architectural mitigations reduce exposure:
proxy_request_buffering on; is enabled by default. When active, NGINX reads the entire chunked payload from the client and forwards it to Tornado either with a fixed Content-Length header or in large coalesced buffers, neutralizing the tiny-chunk attack vector on the server side.max_body_size: While lowering max_body_size on HTTPServer or AsyncHTTPClient does not prevent synchronous loop execution while chunks are being read, reducing it to the minimum required by the application bounds both the maximum number of 1-byte chunks allowed per request and the peak memory amplification.SimpleAsyncHTTPClient: If the application uses SimpleAsyncHTTPClient to communicate with external servers, ensure it only connects to trusted endpoints, or switch to tornado.curl_httpclient.CurlAsyncHTTPClient (which delegates HTTP transport parsing to libcurl rather than tornado/http1connection.py).CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L| Product | Affected Versions | Fixed Version |
|---|---|---|
tornado tornadoweb | < 6.6.0 | 6.6.0 |
| Attribute | Detail |
|---|---|
| CVE ID | CVE-2026-115927 (GHSA-r4j5-9xpp-3985) |
| CWE Classification | CWE-400 (Uncontrolled Resource Consumption) / CWE-770 |
| CVSS v3.1 Score | 5.3 Medium (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L) |
| Attack Vector | Network (Unauthenticated HTTP/1.1 Chunked Transfer Encoding) |
| Affected Component | tornado/http1connection.py, httpserver.py, web.py, simple_httpclient.py |
| Security Impact | Process-wide IOLoop starvation and ~20x memory amplification |
| EPSS Score | 0.384% (32.33rd percentile) |
| Patched Version | Tornado 6.6.0 |
The software does not properly control the allocation or maintenance of a limited system resource, enabling an actor to consume more resources than intended.
Tornado versions prior to 6.6.0 contain an inefficient algorithmic complexity vulnerability in the HTTP header parser. When processing requests with obsolete line folding (obs-fold), the HTTPHeaders.parse_line method repeatedly concatenates immutable Python strings within a list structure. An unauthenticated remote attacker can send an HTTP request containing thousands of short header continuation lines to trigger quadratic O(N^2) CPU consumption on the main IOLoop thread, starving concurrent connections and resulting in a denial of service.
Tornado versions prior to 6.6.0 contain an open redirect vulnerability (CWE-601) in tornado.web.RedirectHandler. When route templates incorporate regular expression capture groups at the start of a path, directly after a fixed host without a trailing slash, or at the beginning of a destination template, unvalidated URL-decoded parameters allow attackers to manipulate the redirect scheme or network location. Because modern browsers normalize URLs according to the WHATWG specification—treating backslashes as forward slashes and stripping control characters—attackers can bypass naive prefix checks and redirect victims to arbitrary external domains.
A missing cryptographic signature verification vulnerability exists in Electrum Bitcoin Wallet versions prior to 4.8.3. In Electrum's Simplified Payment Verification (SPV) architecture, the client failed to validate input signatures on unconfirmed mempool transactions received from connected Electrum servers. A malicious Electrum server can exploit this flaw by intercepting a legitimate user broadcast, suppressing it, and returning a spoofed unconfirmed transaction with an invalid signature and zero fee that redirects the user's UTXOs to an attacker-controlled address. When the user initiates a Replace-By-Fee (RBF) action to accelerate the seemingly stuck transaction, Electrum signs the spoofed outputs with the user's private keys, resulting in the direct theft of funds.
CVE-2026-118254 (GHSA-r5wg-q8fc-p6w2) is a high-severity DOM-based Cross-Site Scripting (DOM XSS) vulnerability in the pagination module of Highcharts Grid (@highcharts/grid-lite and @highcharts/grid-pro). The flaw stems from the direct assignment of localization configuration strings (lang.pageInfo and lang.pageSizeLabel) and pagination UI labels to DOM elements via the innerHTML property instead of text-safe DOM APIs such as textContent or innerText. When an application passes attacker-controlled data into the Grid localization options with pagination enabled, arbitrary HTML and JavaScript execute automatically within the hosting document's origin context upon component rendering.
CVE-2026-118255 is a Cross-Site Scripting (XSS) vulnerability in Highcharts Grid and Highcharts Grid Pro. The flaw occurs because the credits component assigns the configuration-supplied credits.href property directly to an HTML anchor element without verifying the URI scheme against the internal Abstract Syntax Tree (AST) allowlist. An attacker who can influence the grid configuration can supply a javascript: URI, resulting in arbitrary JavaScript execution in the victim's browser session when the credits link is clicked.
The Model Context Protocol (MCP) TypeScript SDK contains an uncontrolled resource consumption vulnerability in its HTTP server transport implementations and middleware adapters. Affected versions fail to enforce upper bounds on incoming HTTP POST request body sizes and JSON-RPC 2.0 batch array lengths. In addition, middleware wrappers for Express and Hono parse request bodies into memory prior to validating Host and Origin headers. Unauthenticated remote attackers can exploit these flaws to trigger Out-Of-Memory (OOM) process crashes or monopolize the single-threaded Node.js event loop.