Tornado: CurlAsyncHTTPClient leaks per-request credentials on handle reuse
- When
- Where
- Global (internet)
- Category
- cyber_advisory · pip
# CurlAsyncHTTPClient leaks per-request credentials on handle reuse ## Summary `CurlAsyncHTTPClient` pools and reuses `pycurl` handles across requests but does not reset them between requests, and several per-request options are applied with no clearing branch. As a result, sensitive state set by one request persists onto a later request on the same client that does not set it. Two credential vectors are demonstrated below — a client TLS certificate (`SSLCERT`/`SSLKEY`) and proxy basic-auth credentials (`PROXYUSERPWD`) — both leaking to a different, unintended host. This affects all released versions through 6.5.6. ## Details In `tornado/curl_httpclient.py`, handles are created once and returned to a free list for reuse (`_process_queue` pops the handle at line 200, `_finish` re-appends it at line 245), and `_curl_setup_request` is never preceded by `curl.reset()`. The function clears *some* carried-over state on the reused handle — `unsetopt(PROXYUSERPWD)` in the no-proxy branch (line 394), `unsetopt(USERPWD)` when no auth is set (line 495), and the HTTP-method flag reset (lines 428-432) — but other options have no equivalent clearing path and persist until a later request sets them again. **Vector A — client TLS certificate (`SSLCERT`/`SSLKEY`).** Set-only, no clearing branch: ```python # tornado/curl_httpclient.py (v6.5.6), lines 498-502 if request.client_cert is not None: curl.setopt(pycurl.SSLCERT, request.client_cert) if request.client_key is not None: curl.setopt(pycurl.SSLKEY, request.client_key) ``` A request that sets `client_cert` leaves the certificate on the handle; a later request without `client_cert` presents it during its TLS handshake. **Vector B — proxy credentials (`PROXYUSERPWD`).** `PROXYUSERPWD` is set only inside the credentials branch and unset only in the no-proxy `else` branch: ```python # tornado/curl_httpclient.py (v6.5.6), lines 371-394 if request.proxy_host and request.proxy_port: curl.setopt(pycurl.PROXY, request.proxy_host) curl.setopt(pycurl.PROXYPORT, request.proxy_port) if request.proxy_username: # only place PROXYUSERPWD is set ... curl.setopt(pycurl.PROXYUSERPWD, credentials) ... else: try: curl.unsetopt(pycurl.PROXY) except TypeError: curl.setopt(pycurl.PROXY, "") curl.unsetopt(pycurl.PROXYUSERPWD) # only place it is unset ``` A request that sets a *new* `proxy_host` without `proxy_username` updates `PROXY`/`PROXYPORT` but never reaches the `else`, so the previous request's credentials persist and are sent to the new proxy. The same class also affects `INTERFACE` (lines 365-366: set only when `request.network_interface` is truthy, with no clearing branch), which is a lower-severity instance — a later request can be bound to a network interface it did not request. A single fix addresses all three (see Mitigation). ## PoC Both reproduce against the pinned release using public API only (`CurlAsyncHTTPClient`, `HTTPRequest`, and the documented per-request arguments). ### Vector A — client TLS certificate The two servers listen on different ports, so request B opens a fresh TCP+TLS connection; the certificate can only reach server 2 via the persisted handle option, not connection or session reuse. ``` python3 -m venv venv ./venv/bin/pip install "tornado==6.5.6" pycurl cryptography ./venv/bin/python poc_client_cert.py ``` ```python import asyncio import datetime import ipaddress import os import socket import ssl import sys import tempfile import threading from cryptography import x509 from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from tornado.httpclient import HTTPRequest from tornado.curl_httpclient import CurlAsyncHTTPClient def _key(): return rsa.generate_private_key(public_exponent=65537, key_size=2048) def _ca(): key = _key() name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PoC-CA")]) now = datetime.datetime.now(datetime.timezone.utc) cert = ( x509.CertificateBuilder() .subject_name(name).issuer_name(name) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now - datetime.timedelta(minutes=1)) .not_valid_after(now + datetime.timedelta(days=1)) .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) .sign(key, hashes.SHA256()) ) return cert, key def _leaf(cn, ca_cert, ca_key, ips=None, client=False): key = _key() name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) now = datetime.datetime.now(datetime.timezone.utc) b = ( x509.CertificateBuilder() .subject_name(name).issuer_name(ca_cert.subject) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now - datetime.timedelta(minutes=1)) .not_valid_after(now + datetime.timedelta(days=1)) .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) ) if ips: b = b.add_extension( x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(i)) for i in ips]), critical=False, ) if client: b = b.add_extension( x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False ) return b.sign(ca_key, hashes.SHA256()), key def _pem(path, cert, key=None): with open(path, "wb") as fh: fh.write(cert.public_bytes(serialization.Encoding.PEM)) if key is not None: fh.write(key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption(), )) class TLSServer: def __init__(self, srv_pem, ca_pem, require): self.captures = [] self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind(("127.0.0.1", 0)) self.sock.listen(4) self.port = self.sock.getsockname()[1] self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) self.ctx.load_cert_chain(srv_pem) self.ctx.load_verify_locations(ca_pem) self.ctx.verify_mode = ssl.CERT_REQUIRED if require else ssl.CERT_OPTIONAL threading.Thread(target=self._serve, daemon=True).start() def _serve(self): while True: try: conn, _ = self.sock.accept() except OSError: return try: s = self.ctx.wrap_socket(conn, server_side=True) self.captures.append(s.getpeercert() or None) try: s.recv(4096) s.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") except Exception: pass s.close() except Exception: self.captures.append("handshake-failed") conn.close() def stop(self): try: self.sock.close() except Exception: pass def _cn(peer): if not peer or not isinstance(peer, dict): return None for rdn in peer.get("subject", ()): for k, v in rdn: if k == "commonName": return v return None async def main(): with tempfile.TemporaryDirectory() as tmp: ca_cert, ca_key = _ca() s1_cert, s1_key = _leaf("server1.local", ca_cert, ca_key, ips=["127.0.0.1"]) s2_cert, s2_key = _leaf("server2.local", ca_cert, ca_key, ips=["127.0.0.1"]) cli_cert, cli_key = _leaf("trusted-client", ca_cert, ca_key, client=True) ca_pem = os.path.join(tmp, "ca.pem") s1_pem = os.path.join(tmp, "s1.pem")
Sources
- GitHub Advisory Database ↗ · first seen 2026-06-15 20:37 UTC
Defaxon links out to the original reporting and never republishes article text.
Correlated events
Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.