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): ```python "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: ```python 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): 1. App configures `AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")` (documented for proxy support; proxies are only supported with the curl client). 2. App calls `fetch("http://attacker/...")` with defaults → request advertises `Accept-Encoding: gzip,deflate`. 3. 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). 4. 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. ## 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 ```bash 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 1. **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`. 2. **Attack:** start `evil_server.py`, wait for `EVIL_LISTENING`, then run `victim_curl.py` (the fetch itself is the attack; no further interaction). 3. **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. 4. **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. 5. **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. ### PoC source Full PoC source (**victim_curl.py**, stdlib only, no dependencies): [victim_curl.py](https://gist.github.com/afldl/211c0e7af8175c2eabab53ed037c435d) (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. ## 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. ### Suggested fix 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.) ### 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.py` only; `curl_httpclient.py` was not touched. ## Credit Reported by the diff/ambidiff security research effort (afldl).
Recommended action
Recommended action
Upgrade affected packages to a patched version: tornado 6.5.9.
Technical details
- Vendor
- Not specified
- Product
- tornado
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source