# 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_<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`:

```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_<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}.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.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.
