GitHub Advisoryadvisory · PyPI
GHSA-chx6-46f5-w4vp tornado: CurlAsyncHTTPClient enforces no response-size limit — decompression bomb drives unbounded memory accumulation to OOM
HIGH
Official page ↗
- Ecosystem
- PyPI
- Packages
- tornado
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
- Weaknesses
- CWE-400, CWE-409
- Reviewed
- 2026-09-30
Affected packages
• PyPI tornado: < 6.5.9 → fixed in 6.5.9
Description
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 (secret gist, unlisted).
The gist carries `evil_server.py` (bomb server), `victim_curl.py` (victim), `victim_simple.py` (control), and `REPRODUCE.md`.
Captured ou…
Related
References
- https://github.com/tornadoweb/tornado/security/advisories/GHSA-chx6-46f5-w4vp web
- https://github.com/tornadoweb/tornado/pull/3719 web
- https://github.com/tornadoweb/tornado/commit/15f056080d8e456f92cca8ad0c33e5a5480414ac web
- https://github.com/tornadoweb/tornado/commit/6564e0a0922c16f239d3a18adccd04736e380c89 web
- https://github.com/tornadoweb/tornado/commit/aa2eb0d989716ac00fe8d7a9919b81c1eccd6ec4 web
- https://github.com/tornadoweb/tornado/commit/e412435febba4569c552c5c9054f1bf92bd171a9 web
- https://github.com/tornadoweb/tornado package
- https://github.com/tornadoweb/tornado/releases/tag/v6.5.9 web