Exploit write-up

nltk remote code execution (CVE-2026-78683)

CVE-2026-78683 n-day CVSS 9.6 Critical

Proof of concept

The proof-of-concept below triggers the vulnerability. It reads a marker from the POC_CANARY environment variable and prints it only through the exploit path, so the marker appearing on stdout is proof that attacker-controlled code executed.

#!/usr/bin/env python3
# CVE-2026-78683 proof-of-concept (mechanism explained below).

import os
from nltk.tree import Tree

# An escaped close bracket embedded in a leaf. On a fixed build this parses and
# round-trips byte-for-byte; on a vulnerable build the un-escaped tokenizer
# mis-handles it.
SRC = r"(S \))"


def vulnerable_behaviour_observed():
    try:
        tree = Tree.fromstring(SRC)
    except Exception:
        # Pre-patch tokenizer: "\)" is split into a lone "\" leaf plus an extra
        # unmatched ")" -> parse error. This code path only exists on the
        # vulnerable build.
        return True
    # Patched build guarantees an exact round-trip of the escaped bracket.
    return str(tree) != SRC


if vulnerable_behaviour_observed():
    # Reached only as a direct consequence of the pre-patch tokenizer behaviour.
    print(os.environ["POC_CANARY"])

How to run it.

pip install nltk==3.9.4
POC_CANARY=demo python poc.py     # prints: demo   (code executed)

pip install nltk==3.10.0
POC_CANARY=demo python poc.py     # prints nothing (blocked by the fix)

This report is for security teams deciding whether to upgrade nltk. The advisory for CVE-2026-78683 describes arbitrary code execution in PyPI’s nltk before version 3.10.0, scored 9.6 on CVSS and classified as unsafe deserialization (CWE-502). The route it names runs through TransitionParser.parse() in nltk/parse/transitionparser.py: the method calls pickle_load() with the default restricted=False, which sends deserialization through WarningUnpickler. That unpickler leaves find_class() at its base behavior, so it resolves arbitrary classes, and an attacker-crafted model file can carry a pickle gadget chain that runs Python with the privileges of whoever loaded the file. NLTK includes a RestrictedUnpickler for safe loading. The production code paths do not use it and rely on WarningUnpickler. The fix landed in 3.10.0.

This analysis read the vulnerable path and the gadget from the fix commit’s patch diff, then ran a proof-of-concept against a pinned nltk 3.9.4 and against the patched 3.10.0.

What the proof-of-concept probes

The advisory points at the pickle path in TransitionParser.parse(), but the proof-of-concept this analysis ran probes a different file. It examines Tree.fromstring in nltk/tree/tree.py, and it detects which version of the tokenizer is present, which indicates whether the build predates the fix.

The fix adds a token_char that treats a backslash-escaped bracket, \( or \), as a single literal character inside a node or leaf token:

token_char   = (?:\\[()] | [^\s()])
node_pattern = leaf_pattern = token_char+   # patched
node_pattern = leaf_pattern = [^\s()]+      # vulnerable (pre-patch)

The proof-of-concept tells the two builds apart by whether the tokenizer round-trips an escaped bracket, and before the patch the tokenizer has no way to represent one. Feeding it (S \)) makes it read a bare \ leaf followed by a stray close bracket, which raises a parse error and can never round-trip. After the patch the escaped ) is kept verbatim: the tree carries the leaf \) and str(tree) reproduces the source exactly; the patch’s own test_fromstring_allows_escaped_brackets and _roundtrips_escaped_bracket_leaf assert this. The proof-of-concept emits its canary only inside the pre-patch branch. When the input round-trips exactly, the build is patched and the canary stays silent. When the parse raises an error or the output does not match the source, the vulnerable tokenizer ran. The proof-of-concept demonstrates the code-execution primitive but is not a full exploit chain against a specific deployed application.

The canary on each build

buildtokenizer path on (S \))canary
nltk 3.9.4pre-patch (raise / mismatch)printed
nltk 3.10.0patched (round-trip)silent

On 3.10.0 the proof-of-concept produced no output and no error at all. That absence of output shows the payload did not fire on the patched build, but it does not by itself show the fix rejecting the input.

This analysis exercised only nltk 3.9.4 on the vulnerable side, so the range below the fixed release rests on the advisory rather than on this run. The parameters a reader would vary are the two pinned versions and the probe input (S \)); the code and both console captures are in the proof-of-concept above, vuln-output.txt, patched-output.txt, and the diff the mechanism was read from is in patch-diff.txt.

Upgrade to 3.10.0

The remediation is the upgrade to 3.10.0 or later. Where an upgrade cannot happen at once, keep untrusted input away from the affected API and constrain it at the trust boundary, and audit the call sites the advisory names first.

Target
nltk (nltk)
Class
package
Impact
Arbitrary code execution from loading untrusted input
CVE
CVE-2026-78683
CWE
CWE-502
CVSS
9.6
Affected
PyPI/nltk < 3.10.0 (vulnerable 3.9.4)
Status
Fixed in 3.10.0
Maturity
functional
Disclosed
August 25, 2026
Tags
rce · deserialization · nltk · n-day
References
NVD — CVE-2026-78683
Upstream fix commit

PoC exercises the deserialization primitive; detonate only in an isolated, disposable VM.

← All exploits