# REPRO-2026-00336: NLTK <3.10.3 RCE in AllowlistUnpickler — validates pickle module string but not global name; dotted-name traversal escapes allowlist to reach arbitrary callables ## Summary Status: published Severity: high CVSS: Unknown CWE: CWE-502 (Deserialization of Untrusted Data) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00336 CVE: CVE-2026-71513 ## Package Name: nltk/nltk Ecosystem: github Affected: nltk < 3.10.3 Fixed: 3.10.3 ## Root Cause # RCA Report: CVE-2026-71513 — NLTK AllowlistUnpickler Dotted-Name Traversal RCE ## Summary 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. ## Impact - Package/component: `nltk` — `nltk.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_ > ` 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`: ```python 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: ```python __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 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_.txt` containing `PRUVA_RCE_vuln` (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}.log` — `nltk=3.10.2`, `punkt_pickle_load returned: 0`, `MARKER CONTENT: PRUVA_RCE_vuln`, `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.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. ## Reproduction Details Reproduced: 2026-08-23T15:44:28.210Z Duration: 1419 seconds Tool calls: 144 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00336 pruva-verify CVE-2026-71513 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00336&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00336/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-71513 - Source: https://github.com/nltk/nltk/blob/v3.10.2/nltk/picklesec.py ## Artifacts - bundle/repro/rca_report.md (analysis, 6789 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 6911 bytes) - bundle/logs/harness_fixed_2.log (log, 526 bytes) - bundle/logs/harness_vuln_2.log (log, 423 bytes) - bundle/repro/harness.py (script, 3297 bytes) - bundle/repro/marker_vuln_2.txt (other, 16 bytes) - bundle/repro/payload_fixed1.pickle (other, 119 bytes) - bundle/repro/payload_fixed2.pickle (other, 119 bytes) - bundle/repro/payload_vuln1.pickle (other, 117 bytes) - bundle/repro/payload_vuln2.pickle (other, 117 bytes) - bundle/repro/runtime_manifest.json (other, 1429 bytes) - bundle/repro/validation_verdict.json (other, 934 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00336 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00336/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00336 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev