The vulnerability is a classic case of unsafe deserialization of untrusted data (CWE-502). The root cause lies in nltk.picklesec.AllowlistUnpickler.find_class, which was intended to safely load Python pickle files by restricting which modules and classes could be reconstructed. However, its implementation was flawed.
The primary flaw was that if a module namespace (e.g., nltk.tokenize or numpy) was allowlisted, any function or class within that namespace could be invoked. The check did not properly validate the specific object being requested within the allowed module. This created two main attack vectors:
-
Namespace Gadgets: Dangerous functions within an allowed namespace could be called. The advisory and patch explicitly name nltk.tokenize.repp.ReppTokenizer._execute (which uses subprocess.Popen) and numpy.f2py.crackfortran.myeval (which uses eval) as such gadgets.
-
Dotted Name Traversal: The unpickler could resolve dotted names, allowing an attacker to traverse from an allowed module to a dangerous one it might import, such as sklearn.os.system.
The functions nltk.tokenize.punkt.punkt_pickle_load and nltk.parse.transitionparser.TransitionParser.parse served as the primary entrypoints for this vulnerability. They both called the insecure allowlisted_pickle_load function with overly broad module-level allowlists, creating the conditions for exploitation when loading tokenizer or model files.
The fix, identified in commit c3e37113742a1ebeeb4f2ca58941f320f98805ea, involved a complete rewrite of AllowlistUnpickler.find_class to be much stricter. It now explicitly denies dotted names, dunder names, and maintains denylists for known dangerous modules and globals, providing defense-in-depth. The calling functions were also updated to use more precise, class-level allowlists where possible.