{"repro_id":"REPRO-2026-00336","version":7,"title":"NLTK <3.10.3 RCE in AllowlistUnpickler — validates pickle module string but not global name; dotted-name traversal escapes allowlist to reach arbitrary callables","repro_type":"security","status":"published","severity":"high","description":"NLTK before 3.10.3 contains a remote code execution vulnerability in AllowlistUnpickler. The allowlist validation only checks the pickle module string, not the global name being resolved. An attacker can craft a malicious pickle payload that uses dotted-name attribute traversal to resolve callables outside the allowlisted namespace (e.g., reaching os.system or other dangerous globals), achieving arbitrary code execution when NLTK loads an untrusted model/data file. Fixed in 3.10.3. Reproduction: install nltk==3.10.2, craft a pickle that passes the module allowlist but resolves a non-allowlisted global via attribute traversal, trigger load, observe code execution.","root_cause":"# RCA Report: CVE-2026-71513 — NLTK AllowlistUnpickler Dotted-Name Traversal RCE\n\n## Summary\n\nNLTK before 3.10.3 ships `nltk.picklesec.AllowlistUnpickler`, a `pickle.Unpickler`\nsubclass meant to safely load untrusted model/data pickles by allowing only\naudited globals. Its `find_class(module, name)` hook validated **only the module\nstring** against a prefix allowlist (`allowed_modules`) / exact-pair allowlist\n(`allowed_globals`) and never inspected `name`. For pickle protocol >= 4,\n`pickle.Unpickler.find_class` resolves the global by `getattr`-chaining the\n(possibly dotted) `name` starting from the imported module. An attacker can\ntherefore keep the module string inside an allowlisted namespace (e.g.\n`nltk.tokenize`) while putting the escape into the *name*:\n`stanford_segmenter.os.system`. The allowlist passes, the dotted traversal\nreaches `os.system`, and a following `REDUCE` executes an arbitrary shell\ncommand while NLTK loads the \"model\". Fixed in NLTK 3.10.3.\n\n## Impact\n\n- Package/component: `nltk` — `nltk.picklesec.AllowlistUnpickler`, reached via\n  the public data-loading entrypoints `nltk.tokenize.punkt.punkt_pickle_load`\n  (legacy Punkt pickle models, allowlist `(\"nltk.tokenize.punkt\", \"nltk.tokenize\")`)\n  and `nltk.parse.transitionparser.TransitionParser` model loading\n  (allowlist `(\"numpy\", \"scipy\", \"sklearn\")`).\n- Affected versions: nltk < 3.10.3 (confirmed on 3.10.2).\n- Risk: high — arbitrary code execution with the privileges of the Python\n  process that loads an attacker-controlled pickle (e.g. a downloaded\n  \"compatible\" Punkt model or parser model file).\n\n## Impact Parity\n\n- Disclosed/claimed maximum impact: code execution (RCE).\n- Reproduced impact from this run: code execution — the attacker command\n  `echo PRUVA_RCE_<attempt> > <marker>` ran via `os.system` on 2/2 vulnerable\n  attempts through the real public entrypoint; marker contents verified.\n- Parity: `full`.\n- Not demonstrated: nothing material — the claimed impact was demonstrated\n  end-to-end against the real library API.\n\n## Root Cause\n\n`nltk/picklesec.py` (3.10.2), `AllowlistUnpickler.find_class`:\n\n```python\ndef find_class(self, module: str, name: str) -> Any:\n    if (module, name) in self._allowed_globals or self._module_allowed(module):\n        return super().find_class(module, name)\n    raise pickle.UnpicklingError(...)\n```\n\nOnly `module` is checked against the prefix allowlist. The base-class\nimplementation for protocol >= 4 does:\n\n```python\n__import__(module)\nreturn _getattribute(sys.modules[module], name)  # getattr-chains \"a.b.c\"\n```\n\nso `name=\"stanford_segmenter.os.system\"` with `module=\"nltk.tokenize\"`\nresolves `nltk.tokenize.stanford_segmenter` (a submodule that `import os`) →\n`os` → `system`, a callable the module allowlist never intended to expose.\nNLTK 3.10.3 fixes this in `nltk/picklesec.py` by rejecting dotted and dunder\nnames before resolution (Guard 1/2), adding a denied-module prefix backstop\n(`os`, `subprocess`, `builtins`, `nltk.internals`, ...) that applies even under\na broad allowlist (Guards 3–5), and re-checking the resolved object's true\n`__module__`/`__qualname__` after resolution (`_resolve`). Fix reference:\nGHSA-4489 / GHSA-x99w hardening in `nltk.picklesec` (nltk 3.10.3 release).\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; reuses the prepared\n   project cache for wheels/site dirs, falling back to `pip` + a local\n   artifacts dir).\n2. The script installs `nltk==3.10.2` (vulnerable) and `nltk==3.10.3` (fixed)\n   into isolated `--target` site dirs, verifies the dotted-name guard is absent\n   in 3.10.2 and present in 3.10.3, then runs `bundle/repro/harness.py` twice\n   per side. The harness crafts a protocol-4 pickle\n   `REDUCE(GLOBAL(\"nltk.tokenize\", \"stanford_segmenter.os.system\"), (cmd,))`\n   and feeds it to the real public entrypoint\n   `nltk.tokenize.punkt.punkt_pickle_load`.\n3. Expected evidence: each vulnerable attempt creates\n   `repro/marker_vuln_<n>.txt` containing `PRUVA_RCE_vuln<n>` (harness exit 10);\n   each fixed attempt raises\n   `UnpicklingError: ... has a dotted name, which is forbidden` and creates no\n   marker (harness exit 11). Script exits 0 only if 2/2 + 2/2 hold.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` / `reproduction_steps_run2.log` — full\n  script output for two consecutive runs (both exit 0).\n- `bundle/logs/harness_vuln_{1,2}.log` — `nltk=3.10.2`,\n  `punkt_pickle_load returned: 0`, `MARKER CONTENT: PRUVA_RCE_vuln<n>`,\n  `RESULT: VULNERABLE - attacker command executed`.\n- `bundle/logs/harness_fixed_{1,2}.log` — `nltk=3.10.3`, `BLOCKED with\n  UnpicklingError: global 'nltk.tokenize.stanford_segmenter.os.system' has a\n  dotted name, which is forbidden (attribute-traversal pickle RCE, GHSA-4489)`.\n- `bundle/repro/marker_vuln_{1,2}.txt` — files created by the attacker command.\n- `bundle/repro/payload_{vuln,fixed}{1,2}.pickle` — exact 87-byte malicious\n  pickles used.\n- Environment: Python 3.14.4, pip 25.1.1, linux x86_64.\n  Vulnerable wheel `nltk-3.10.2-py3-none-any.whl` sha256\n  `2c7ccacb765c5e26b0cb60fb1b57080af522c6924d12a714a243305ba3637412`;\n  fixed wheel `nltk-3.10.3-py3-none-any.whl` sha256\n  `ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c`.\n- `bundle/repro/runtime_manifest.json` — structured runtime evidence\n  (`entrypoint_kind=function_call`, `target_path_reached=true`).\n\n## Recommendations / Next Steps\n\n- Upgrade to nltk >= 3.10.3.\n- Fix approach (already upstream): reject dotted/dunder global names before\n  resolution; apply a denied-module backstop even under prefix allowlists;\n  re-verify the resolved object's true `__module__`/`__qualname__`; refuse\n  module-object results.\n- Defense in depth for downstream users: never load pickle data from untrusted\n  or unauthenticated sources even behind an allowlisting unpickler; prefer\n  non-pickle model formats.\n- Testing: regression-test that `find_class` rejects `(\"nltk.tokenize\",\n  \"stanford_segmenter.os.system\")`, `(\"sklearn\", \"os.system\")`, in-namespace\n  gadgets (`numpy.f2py.crackfortran.myeval`, `ReppTokenizer._execute`), and\n  that legitimate single-qualname model pickles still load.\n\n## Additional Notes\n\n- Idempotency: `reproduction_steps.sh` ran twice consecutively, both exit 0;\n  the second run reused the cached site dirs/wheels.\n- The escape gadget `stanford_segmenter.os.system` works because\n  `nltk.tokenize/__init__.py` imports the `stanford_segmenter` submodule, which\n  itself does `import os`; any allowlisted package with an `os`-importing\n  submodule in its attribute tree is equally exposed (e.g. `sklearn.os.system`\n  per the upstream regression test).\n- No sanitizer or mock was used; the proof executes the real library code path\n  and observes a real command side effect.\n","cve_id":"CVE-2026-71513","cwe_id":"CWE-502","source_url":"https://github.com/nltk/nltk/blob/v3.10.2/nltk/picklesec.py","package":{"name":"nltk/nltk","ecosystem":"github","affected_versions":"nltk < 3.10.3","fixed_version":"3.10.3"},"reproduced_at":"2026-08-23T15:44:28.210117+00:00","duration_secs":1419.0,"tool_calls":144,"handoffs":2,"total_cost_usd":2.959065,"agent_costs":{"claim_matcher":0.014879,"judge":0.351421,"learning_policy":0.010616,"repro":0.824332,"support":0.047583,"vuln_variant":1.710234},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.014879},"judge":{"gpt-5.6-sol":0.351421},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.010616},"repro":{"accounts/fireworks/models/kimi-k3":0.824332},"support":{"accounts/fireworks/models/kimi-k3":0.047583},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.710234}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-23T15:44:28.743408+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":6789,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6911,"category":"reproduction_script"},{"path":"bundle/logs/harness_fixed_2.log","filename":"harness_fixed_2.log","size":526,"category":"log"},{"path":"bundle/logs/harness_vuln_2.log","filename":"harness_vuln_2.log","size":423,"category":"log"},{"path":"bundle/repro/harness.py","filename":"harness.py","size":3297,"category":"script"},{"path":"bundle/repro/marker_vuln_2.txt","filename":"marker_vuln_2.txt","size":16,"category":"other"},{"path":"bundle/repro/payload_fixed1.pickle","filename":"payload_fixed1.pickle","size":119,"category":"other"},{"path":"bundle/repro/payload_fixed2.pickle","filename":"payload_fixed2.pickle","size":119,"category":"other"},{"path":"bundle/repro/payload_vuln1.pickle","filename":"payload_vuln1.pickle","size":117,"category":"other"},{"path":"bundle/repro/payload_vuln2.pickle","filename":"payload_vuln2.pickle","size":117,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1429,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":934,"category":"other"}]}