# tornado: CurlAsyncHTTPClient enforces no response-size limit — decompression bomb drives unbounded memory accumulation to OOM

Tornado's CurlAsyncHTTPClient lacks any response-size limit, so a ~4 MB gzip bomb expands to 4 GiB and OOM-kills the process in ~3 s.

- Payout: Research
- Severity: high
- Program: Tornado
- Found by: iaohkut-from-NightWolf-Team, aoto-tech
- Disclosed: 2026-09-30
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-chx6-46f5-w4vp
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-chx6-46f5-w4vp
- Tags: area:devices domain:network domain:oss impact:dos kind:research platform:ghsa sev:high tech:curl tech:python technique:incomplete-fix technique:source-review vuln:dos

**Target:** tornado.curl_httpclient.CurlAsyncHTTPClient response write path (pycurl WRITEFUNCTION)
**Root cause:** libcurl auto-decompresses gzip before invoking WRITEFUNCTION, and the callback appends every decompressed byte to an unbounded BytesIO with no max_body_size accounting; the CVE-2026-49855 size gates live only in http1connection.py used by SimpleAsyncHTTPClient.
**Steps:**
1. Configure `AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")` and `fetch()` an attacker URL with default `decompress_response=True`.
2. Attacker replies `200` with `Content-Encoding: gzip`, `Transfer-Encoding: chunked`, body = gzip of zeros (4,174,525 wire bytes -> 4 GiB, 1029:1) sent in 64 KB chunks.
3. libcurl decodes at ~350 MB/s into `buffer.write`; RSS climbs linearly with no plateau until OOM kill (exit 137).
4. Control: same bomb against SimpleAsyncHTTPClient fails cleanly at ~65 MB via http1connection.py gates.
**How found:** Noticed the curl client is a sibling code path to the CVE-2026-49855 fix and checked curl_httpclient.py for max_body_size/MAXFILESIZE — zero occurrences — then verified the gap end-to-end with a gzip bomb and a SimpleAsyncHTTPClient control.
**Exploitation:** Malicious HTTP server serves a chunked gzip bomb; victim Tornado app fetching the URL accumulates decompressed bytes in memory until the process is OOM-killed, taking down the whole event loop and all served users. No privileges or interaction needed; wire cost ~available_memory/1000.
**Escalation:** Single malicious response kills the shared event loop, affecting all concurrent users; slow endless-body variant drains memory below detection thresholds.
**Insight:** When a fix lands in one HTTP client backend, audit sibling backends (curl vs simple) for the same gate; libcurl decompresses before WRITEFUNCTION, so size limits must count decompressed bytes in the write callback, not pycurl.MAXFILESIZE.

