---
id: GHSA-chx6-46f5-w4vp
title: >-
  tornado: CurlAsyncHTTPClient enforces no response-size limit — decompression
  bomb drives unbounded memory accumulation to OOM
summary: >-
  tornado: CurlAsyncHTTPClient enforces no response-size limit — decompression
  bomb drives unbounded memory accumulation to OOM
severity: high
cvss: 7.5
cwe:
  - CWE-400
  - CWE-409
vendor: tornado
product: tornado
ecosystem: pip
affected:
  - tornado <= 6.5.8
patched:
  - tornado 6.5.9
published: '2026-09-30'
updated: '2026-09-30'
sourceUpdated: '2026-09-30T23:49:14Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-chx6-46f5-w4vp'
references:
  - url: >-
      https://github.com/tornadoweb/tornado/security/advisories/GHSA-chx6-46f5-w4vp
  - url: 'https://github.com/tornadoweb/tornado/pull/3719'
  - url: >-
      https://github.com/tornadoweb/tornado/commit/15f056080d8e456f92cca8ad0c33e5a5480414ac
  - url: >-
      https://github.com/tornadoweb/tornado/commit/6564e0a0922c16f239d3a18adccd04736e380c89
  - url: >-
      https://github.com/tornadoweb/tornado/commit/aa2eb0d989716ac00fe8d7a9919b81c1eccd6ec4
  - url: >-
      https://github.com/tornadoweb/tornado/commit/e412435febba4569c552c5c9054f1bf92bd171a9
  - url: 'https://github.com/tornadoweb/tornado/releases/tag/v6.5.9'
  - url: 'https://github.com/advisories/GHSA-chx6-46f5-w4vp'
tags:
  - ghsa
  - pip
ingestedAt: '2026-10-01T00:33:27.738Z'
---

## Overview

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).

## Affected packages

- `tornado <= 6.5.8`

## Remediation

Upgrade to a patched release:

- `tornado 6.5.9`
