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.
tornado/curl_httpclient.py (line numbers identical on master 6.6.dev1 , v6.5.8 , and the audited snapshot):
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:
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(" with defaults → request advertises Accept-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.write with 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.
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
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.
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 default decompress_response=True , and to fetch a URL whose server the attacker controls or has compromised. victim_curl.py implements exactly that ( AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient") → fetch() ), and the server log confirms the ENCODING path engaged — the victim's request arrived with User-Agent: Mozilla/5.0 (compatible; pycurl) and Accept-Encoding: gzip,deflate .
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 default decompress_response=True , and to fetch a URL whose server the attacker controls or has compromised. victim_curl.py implements exactly that ( AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient") → fetch() ), and the server log confirms the ENCODING path engaged — the victim's request arrived with User-Agent: Mozilla/5.0 (compatible; pycurl) and Accept-Encoding: gzip,deflate .
Attack: start evil_server.py , wait for EVIL_LISTENING , then run victim_curl.py (the fetch itself is the attack; no further interaction).
Attack: start evil_server.py , wait for EVIL_LISTENING , then run victim_curl.py (the fetch itself is the attack; no further interaction).
Expected: victim_curl_rss.log shows 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 , docker OOMKilled=true ); the server logs CLIENT_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.
Expected: victim_curl_rss.log shows 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 , docker OOMKilled=true ); the server logs CLIENT_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=False and an unterminated chunked body (server keeps sending forever), the same buffer.write path accumulates unbounded plain bytes — no compression needed; request_timeout only extends the ceiling.
Variant: with decompress_response=False and an unterminated chunked body (server keeps sending forever), the same buffer.write path accumulates unbounded plain bytes — no compression needed; request_timeout only extends the ceiling.
Control: victim_simple.py fetches the identical bomb with the default SimpleAsyncHTTPClient → clean FETCH_FAILED: HTTP 599: Connection closed , RSS peak ~65 MB (24,984 → 66,712 kB), script exit 0 — the CVE-2026-49855 accounting in http1connection.py aborts the transfer, proving the gap is specific to the curl client.
Control: victim_simple.py fetches the identical bomb with the default SimpleAsyncHTTPClient → clean FETCH_FAILED: HTTP 599: Connection closed , RSS peak ~65 MB (24,984 → 66,712 kB), script exit 0 — the CVE-2026-49855 accounting in http1connection.py aborts the transfer, proving the gap is specific to the curl client.
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)
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.
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.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H → 7.5 High
AV:N — attacker is any HTTP server the victim fetches from (network)
AC:L — one gzip response; no special conditions beyond the documented curl-client configuration (which the project itself documents for proxy support); rated consistent with CVE-2026-49855 , which the maintainers scored high for the equivalent Simple-client gap
PR:N / UI:N — attacker controls their own origin; no privileges or user action needed
A:H — whole-process OOM kill of the client application; C / I unaffected
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.)
<= 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.py only; curl_httpclient.py was not touched.
Reported by the diff/ambidiff security research effort (afldl). Happy to coordinate a disclosure timeline; will request a CVE once confirmed.
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
