NLTK: Allowlisted pickle loaders still permit code execution in current source
CriticalCVE-2026-79657 · Published Sep 8, 2026
### Summary The current source tree still allows arbitrary code execution during supposedly safer allowlisted pickle loading. The allowlist trusts whole module namespaces instead of exact safe globals, so crafted pickles can invoke dangerous in-namespace callables through pickle REDUCE. ### Details - **Vulnerability type:** Remote code execution via unsafe deserialization - **Affected component:** `nltk.picklesec.allowlisted_pickle_load`, `nltk.tokenize.punkt.punkt_pickle_load`, `nltk.parse.transitionparser.TransitionParser.parse` - **Affected versions:** Current source `v3.10.0-rc2`; published `3.9.4` was not the claim target for this bypass. - **Patched versions:** Not yet patched - **Root cause:** Module-prefix allowlists include dangerous callables such as `nltk.tokenize.repp.ReppTokenizer._execute` and `numpy.f2py.crackfortran.myeval`. `punkt_pickle_load()` allowlists both `nltk.tokenize.punkt` and the whole `nltk.tokenize` namespace, which exposes `ReppTokenizer._execute()` and its `subprocess.Popen(...)` sink during unpickling. `TransitionParser.parse()` uses `allowlisted_pickle_load(..., allowed_modules=("numpy", "scipy", "sklearn"))`, which permits `numpy.f2py.crackfor...
Affected versions
| Package | Affected | Fixed in |
|---|---|---|
| nltk PyPI | < 3.10.3 | 3.10.3 |
Details and references
### Summary The current source tree still allows arbitrary code execution during supposedly safer allowlisted pickle loading. The allowlist trusts whole module namespaces instead of exact safe globals, so crafted pickles can invoke dangerous in-namespace callables through pickle REDUCE. ### Details - **Vulnerability type:** Remote code execution via unsafe deserialization - **Affected component:** `nltk.picklesec.allowlisted_pickle_load`, `nltk.tokenize.punkt.punkt_pickle_load`, `nltk.parse.transitionparser.TransitionParser.parse` - **Affected versions:** Current source `v3.10.0-rc2`; published `3.9.4` was not the claim target for this bypass. - **Patched versions:** Not yet patched - **Root cause:** Module-prefix allowlists include dangerous callables such as `nltk.tokenize.repp.ReppTokenizer._execute` and `numpy.f2py.crackfortran.myeval`. `punkt_pickle_load()` allowlists both `nltk.tokenize.punkt` and the whole `nltk.tokenize` namespace, which exposes `ReppTokenizer._execute()` and its `subprocess.Popen(...)` sink during unpickling. `TransitionParser.parse()` uses `allowlisted_pickle_load(..., allowed_modules=("numpy", "scipy", "sklearn"))`, which permits `numpy.f2py.crackfortran.myeval()` and its attacker-controlled `eval(...)` path. I confirmed both gadgets create marker files before the caller returns or later aborts on type misuse. ### PoC **Preconditions** - The application loads an attacker-controlled tokenizer or model artifact through these public loaders. **Steps** 1. Create a pickle whose REDUCE callable is `ReppTokenizer._execute` and point its command to a harmless marker-file write. 2. Pass that payload to `punkt_pickle_load(BytesIO(payload))` and observe the marker file is created during unpickling. 3. Create a second pickle whose REDUCE callable is `numpy.f2py.crackfortran.myeval` and load it through `TransitionParser.parse()`. 4. Observe the second marker file is created before `TransitionParser.parse()` later fails on the returned object type. **Minimal reproducible excerpt** ```text {'punkt_marker': 'PUNKT_RCE', 'transitionparser_marker': 'TP_RCE'} ``` ### Impact Any caller that trusts these current allowlisted loaders can still execute attacker-controlled commands while loading model or tokenizer artifacts. This defeats the protection mechanism that replaced unrestricted pickle loading and creates a dangerous false sense of safety. ### Remediation Replace broad module-prefix allowlists with exact `(module, qualname)` pairs for the few safe classes or functions genuinely required. Do not allow entire namespaces such as `nltk.tokenize` or `numpy`, and keep post-load type validation only as a secondary defense. ### Resources - https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/tokenize/punkt.py#L120-L134 - https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/tokenize/repp.py#L111-L115 - https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/parse/transitionparser.py#L26-L30 - https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/parse/transitionparser.py#L565-L571 --- ## Fix + attack demonstration (verified) + tightened callers `find_class` now, before the allowlists: 1. **Rejects any dotted `name`** → closes 4489 with zero legit impact. 2. **Denies dangerous modules** (`os`, `subprocess`, `sys`, `builtins`, `numpy.f2py`, `nltk.tokenize.repp`, …) even under a broad `allowed_modules` , a defense-in-depth **backstop** so a future too-broad allowlist can't silently reopen RCE. 3. **`builtins` denied wholesale**; safe primitives (`int`, `str`, …) must be named exactly via `allowed_globals`. Callers tightened: punkt drops the broad `nltk.tokenize` (keeps `nltk.tokenize.punkt` + exact `collections.defaultdict`/`builtins.int`); transitionparser keeps numpy/scipy/sklearn (array unpickling needs their submodules) with the new guards blocking the gadgets. ## Full pickle-sink audit Every deserialization sink in the tree was reviewed: **no raw `pickle.load`** anywhere, and **no** joblib/numpy/torch/dill/yaml
- CVSS 4.0
- CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
- Severity from
- GitHub (reviewed advisory)
- Weakness
- CWE-502, CWE-693
- Also known as
- CVE-2026-79657, PYSEC-2026-3735
- github.com/nltk/nltk/security/advisories/GHSA-x99w-6fgc-pmfw
- nvd.nist.gov/vuln/detail/CVE-2026-79657
- github.com/nltk/nltk/commit/c3e37113742a1ebeeb4f2ca58941f320f98805ea
- github.com/nltk/nltk
- github.com/nltk/nltk/releases/tag/v3.10.3
- github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3735.yaml
- www.vulncheck.com/advisories/nltk-before-3.10.3-remote-code-execution-via-unsafe-pickle-deserialization
More NLTK advisories
All NLTK| Date | Advisory | Severity | Fixed in |
|---|---|---|---|
| Sep 8 | NLTK: Pl196xCorpusReader has quadratic ReDoS on malformed TEI blocks | Medium | 3.10.3 |
| Sep 8 | NLTK: ReDoS in nltk.tgrep via unvalidated user-supplied regular expressions | High | 3.10.3 |
| Sep 8 | NLTK: ReDoS in nltk.text.Text.findall() via unvalidated user-supplied regular expressions | High7.5 | 3.10.0 |
| Sep 8 | NLTK: Corpus Reader Sandbox Bypass | High | 3.10.3 |
| Sep 8 | NLTK: Corpus readers follow symlinks outside trusted roots despite pathsec enforcement | High | 3.10.3 |
| Sep 8 | NLTK: Entity-expansion DoS (billion laughs) via remaining raw ElementTree parses | High | 3.10.3 |