logo

Database

Asymmetric denial of service - ReDoS In tornado

Description

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 (secret gist, unlisted). The gist carries evil_server.py (bomb server), victim_curl.py (victim), victim_simple.py (control), and REPRODUCE.md. ### Captured

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Component
Affected version
Patched versions