Summary
tornado: CurlAsyncHTTPClient enforces no response-size limit, decompression bomb drives unbounded memory accumulation to OOM
An unbounded memory accumulation (decompression bomb) in tornado.curl_httpclient.CurlAsyncHTTPClient, the client-side sibling gap of CVE-2026-49855, verified end-to-end on the 2026-08-15 master snapshot (6.6.dev1) and present unchanged in the latest release tag v6.5.8 and on master (checked 2026-08-17). When a Tornado application configures the curl client (the documented deployment for proxy support / advanced TLS options) and fetch()es an attacker-chosen or attacker-compromised URL with default decompress_response=True, a malicious server replying Content-Encoding: gzip with a ~2.8 MB wire bomb drove the client's RSS from 30,884 kB to 1,032,100 kB (~1008 MB) in 3.18 s (~350 MB/s, monotonic, no plateau) until the kernel OOM-killed the process (exit 137, cgroup OOMKilled=true), with the transfer only 67% complete and no client-side size check ever intervening: curl_httpclient.py contains zero occurrences of max_body_size/MAXFILESIZE. The identical bomb against the default SimpleAsyncHTTPClient fails cleanly at ~65 MB, because every size gate CVE-2026-49855 added (compressed CL, chunked total, cumulative decompressed size, http1connection.py:620,676,742) lives in code the curl client never executes. This is a distinct component from the published advisory (which fixed _GzipMessageDelegate/SimpleAsyncHTTPClient only) and from the other curl-client advisories (credential handle-reuse GHSA-pw6j-qg29-8w7f, header CRLF GHSA-w235-7p84-xx57); the file's full commit history (latest 2026-06-17) shows no response-size work.
Details
tornado/curl_httpclient.py (line numbers identical on master 6.6.dev1, v6.5.8, and the audited snapshot):
"buffer": BytesIO(), # :202, plain BytesIO, no accounting
...
else:
write_function = buffer.write # :359, every decompressed byte lands here
curl.setopt(pycurl.WRITEFUNCTION, write_function) # :360
...
if request.decompress_response: # default True (HTTPRequest)
curl.setopt(pycurl.ENCODING, "gzip,deflate") # :373-374, libcurl advertises + auto-decodes
libcurl decompresses the response before invoking WRITEFUNCTION, so the callback receives decompressed bytes, which are appended to an unbounded BytesIO until the transfer ends or the process dies. The only ceiling is request_timeout (default 20 s), at zlib's hundreds of MB/s that still permits many GB of accumulation; the PoC raised it to 300 s and the 1 GiB cgroup cap was hit in 3.2 s regardless. There is no pycurl.MAXFILESIZE, no max_buffer_size/max_body_size plumbing (the constructor accepts no body-size option), and streaming_callback users fare no better (the callback variant at :353-356 also performs zero accounting).
Contrast, SimpleAsyncHTTPClient path (tornado/http1connection.py), all absent from the curl path:
if cast(int, content_length) > self._max_body_size: # :620 Content-Length gate
if total_size > self._max_body_size: # :676 chunked total gate
if self._decompressed_body_size > self._max_body_size: # :742 CVE-2026-49855 decompressed gate
SimpleAsyncHTTPClient.initialize() defaults max_buffer_size = 104857600 (100 MiB) with max_body_size defaulting to it (simple_httpclient.py:117-121); CurlAsyncHTTPClient.initialize() has no corresponding parameter at all.
Attack chain (attacker = malicious HTTP server; victim = any Tornado app doing fetch() on attacker-influenced URLs, URL fetchers, webhook processors, link previewers, RSS/probe pollers):
- App configures
AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")(documented for proxy support; proxies are only supported with the curl client). - App calls
fetch("http://attacker/...")with defaults → request advertisesAccept-Encoding: gzip,deflate. - Attacker replies
200,Content-Encoding: gzip,Transfer-Encoding: chunked, body = a gzip stream of zeros (4,174,525 wire bytes expanding to 4 GiB, 1029:1, sent in 64 KB chunks). - libcurl auto-decodes at ~350 MB/s into
buffer.writewith no size accounting → process RSS climbs linearly until OOM. The response need not complete: the client died with 2,818,048/4,174,525 wire bytes delivered (67%).
Variant without compression: decompress_response=False plus an endless streaming body (no Content-Length, no final chunk) feeds the same unaccounted buffer.write, this client never enforces any cap on any path.
PoC
Verified end-to-end 2026-08-15 in a single container (cgroup --memory 1g --memory-swap 1g so the exhaustion endpoint is safe and fast to observe), two processes over a real TCP socket on 127.0.0.1:8081: a raw-socket malicious server (evil_server.py, builds the 4 GiB-of-zeros gzip bomb once, serves it with chunked framing) and the Tornado victim (victim_curl.py, configures CurlAsyncHTTPClient, fetch(..., request_timeout=300), samples /proc/self/status VmRSS every 0.2 s from a monitor thread so the curve survives the OOM kill).
Build & run the victim
git clone https://github.com/tornadoweb/tornado
cd tornado
pip install pycurl
python evil_server.py & # 127.0.0.1:8081; prints BOMB_BUILT wire_bytes=... ratio=... EVIL_LISTENING
python victim_curl.py # victim: CurlAsyncHTTPClient fetch -> expect linear RSS rise -> OOM
python victim_simple.py # control: default SimpleAsyncHTTPClient on the same bomb
Verified environment: debian:bookworm-slim, Python 3.11.2, python3-pycurl 7.45.2 (libcurl 7.88.1, zlib 1.2.13), tornado master snapshot 6.6.dev1 of 2026-08-15 run from the source tree (sys.path), container memory capped at 1 GiB. curl_httpclient.py verified byte-equivalent (still zero size-limit references) in tag v6.5.8 and on master as of 2026-08-17.
Reproduction steps
Precondition, the documented curl-client deployment fetching a remote URL. The vulnerability requires the application to use
CurlAsyncHTTPClient(the standard configuration when proxy support or advanced TLS options are needed) with defaultdecompress_response=True, and to fetch a URL whose server the attacker controls or has compromised.victim_curl.pyimplements exactly that (AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")→fetch()), and the server log confirms the ENCODING path engaged, the victim's request arrived withUser-Agent: Mozilla/5.0 (compatible; pycurl)andAccept-Encoding: gzip,deflate.Attack: start
evil_server.py, wait forEVIL_LISTENING, then runvictim_curl.py(the fetch itself is the attack; no further interaction).Expected:
victim_curl_rss.logshows VmRSS 30,884 → 1,032,100 kB over 3.18 s, rising ~350 MB/s with no plateau; the container kills the victim (exit 137, dockerOOMKilled=true); the server logsCLIENT_DIED_MID_TRANSFER wire_sent=2818048/4174525, the accumulation is bounded only by available memory, never by a client-side check, and the 4 GiB payload was never fully delivered.Variant: with
decompress_response=Falseand an unterminated chunked body (server keeps sending forever), the samebuffer.writepath accumulates unbounded plain bytes, no compression needed;request_timeoutonly extends the ceiling.Control:
victim_simple.pyfetches the identical bomb with the defaultSimpleAsyncHTTPClient→ cleanFETCH_FAILED: HTTP 599: Connection closed, RSS peak ~65 MB (24,984 → 66,712 kB), script exit 0, the CVE-2026-49855 accounting inhttp1connection.pyaborts the transfer, proving the gap is specific to the curl client.
PoC source
Full PoC source (victim_curl.py, stdlib only, no dependencies): victim_curl.py (secret gist, unlisted).
The gist carries evil_server.py (bomb server), victim_curl.py (victim), victim_simple.py (control), and REPRODUCE.md.
Captured output (2026-08-15 run, verbatim excerpts)
evil_server.log:
BOMB_BUILT wire_bytes=4174525 decompressed_bytes=4294967296 ratio=1029:1
EVIL_LISTENING 127.0.0.1:8081
REQUEST_FROM 127.0.0.1:57544 -> GET /bomb HTTP/1.1
REQUEST_HEADERS:
GET /bomb HTTP/1.1
Host: 127.0.0.1:8081
User-Agent: Mozilla/5.0 (compatible; pycurl)
Accept: */*
Accept-Encoding: gzip,deflate
WIRE_SENT 65536/4174525
WIRE_SENT 2162688/4174525
CLIENT_DIED_MID_TRANSFER wire_sent=2818048/4174525 err=ConnectionResetError(104, 'Connection reset by peer')
victim_curl_rss.log (VmRSS kB, every 0.2 s):
0.00 30884
0.61 231848
1.41 514720
2.22 794480
2.82 1000756
3.18 1032100 <- last sample before kill
driver_f1.log:
timeout 240 python3 /e2e/F1/victim_curl.py ... 606 Killed
VICTIM_CURL_EXIT=137 # docker inspect -> OomKilled: true
victim_simple.log (control, same bomb):
FETCH_FAILED: HTTP 599: Connection closed
SCRIPT_FINISHED_NORMALLY # exit 0, VmRSS peak 66712 kB
Honest framing of the endpoint: the OOM kill at ~1008 MB was forced by the test's 1 GiB cgroup cap as the observation instrument; the "unbounded" claim rests on the linear no-plateau RSS curve, death at 67% wire delivery, and the absence of any size accounting in the code path, with more memory the transfer would have continued to the full 4 GiB payload.
Affected versions
- <= 6.5.8 (latest tag; the curl client has never had a response-size limit) and master (
6.6.dev1, verified 2026-08-15/17). - 6.5.6's CVE-2026-49855 fix covered
SimpleAsyncHTTPClient/http1connection.pyonly;curl_httpclient.pywas not touched.
Credit
Reported by the diff/ambidiff security research effort (afldl).
Impact
Denial of service (memory exhaustion) of any Tornado application that uses the curl HTTP client and fetches attacker-influenced URLs. A single ~4 MB response kills a 1 GiB process in ~3 s; wire cost scales as available_memory / 1000. Because the accumulation happens on the shared event loop's client, one malicious response takes down the entire application (all concurrently served users), and a slow endless-body variant drains memory gradually below detection thresholds. The attack requires no privileges, no user interaction, and only that the victim's configured client visits the attacker's origin.
Crafted input forces the application to consume excessive CPU, memory, or other resources, degrading or denying service. Typical impact: denial of service.
GHSA-CHX6-46F5-W4VP has a CVSS score of 7.5 (High). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (6.5.9); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Enforce a byte budget in the write path of CurlAsyncHTTPClient: wrap the WRITEFUNCTION (both the buffer.write branch and the streaming_callback branch) in a counter that aborts the transfer (curl.setopt(pycurl.FAILONERROR)-style cancellation or raising from the callback) once the received total exceeds max_body_size, plumbed from initialize() with the same 100 MiB default as SimpleAsyncHTTPClient. Because libcurl decompresses before the write callback, the counter naturally measures decompressed bytes, the same semantics as the CVE-2026-49855 fix. (pycurl.MAXFILESIZE alone is insufficient: it applies to the compressed transfer size.)
Frequently Asked Questions
- What is GHSA-CHX6-46F5-W4VP? GHSA-CHX6-46F5-W4VP is a high-severity uncontrolled resource consumption vulnerability in tornado (pip), affecting versions <= 6.5.8. It is fixed in 6.5.9. Crafted input forces the application to consume excessive CPU, memory, or other resources, degrading or denying service.
- How severe is GHSA-CHX6-46F5-W4VP? GHSA-CHX6-46F5-W4VP has a CVSS score of 7.5 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of tornado are affected by GHSA-CHX6-46F5-W4VP? tornado (pip) versions <= 6.5.8 is affected.
- Is there a fix for GHSA-CHX6-46F5-W4VP? Yes. GHSA-CHX6-46F5-W4VP is fixed in 6.5.9. Upgrade to this version or later.
- Is GHSA-CHX6-46F5-W4VP exploitable, and should I be worried? Whether GHSA-CHX6-46F5-W4VP is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether GHSA-CHX6-46F5-W4VP is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix GHSA-CHX6-46F5-W4VP? Upgrade
tornadoto 6.5.9 or later.