Kestrel
대시보드로 돌아가기
CVE-2026-81723MEDIUM· 3.7MITRENVDGHSA대응게시일: 2026. 08. 27.수정일: 2026. 09. 02.

NLTK: Quadratic CPU Exhaustion in `XMLCorpusView._read_xml_fragment()`

DoS

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
3.7medium

이론적 심각도 점수

EPSS
0.2%상위 87.3%

30일 내 악용 확률 예측

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도높음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향없음
무결성 영향없음
가용성 영향낮음
버전별 점수
CVSS 3.13.7MODERATE
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L

상세 설명

Summary

XMLCorpusView._read_xml_fragment() reads a corpus file in 1 KiB blocks, appending
each block to a growing fragment string, then calls _VALID_XML_RE.match(fragment)
on the full accumulated buffer every iteration. Because each iteration rescans the
entire accumulated fragment, the total amount of work grows quadratically with input
size.

Commit c9c332284 (CWE-1333) made each match() call linear. The quadratic behavior
is separate: the loop calls match() once per 1 KiB block, each time on a longer
buffer.

On the test system, an 8 MiB malformed XML file consumed approximately 48 CPU-seconds
through the public BNCCorpusReader.words() API with no source modification. Absolute
timings vary by hardware. _read_xml_fragment() imposes no limit on fragment size or
iteration count.

Details

File: nltk/corpus/reader/xmldocs.py
Function: XMLCorpusView._read_xml_fragment(), lines 261–308

The relevant loop:

bash
1fragment = ""
2while True:
3 fragment += stream.read(self._BLOCK_SIZE) # grows by 1 KiB per iteration
4 if self._VALID_XML_RE.match(fragment): # rescans full buffer each time
5 return fragment
6 ...
7 last_open_bracket = fragment.rfind("<")
8 if last_open_bracket > 0: # False for single-'<' payload
9 if self._VALID_XML_RE.match(fragment[:last_open_bracket]):
10 return ...
11 # loop continues

For a payload of b'<' + b'a' * (N-1):

  • For this malformed input, _VALID_XML_RE.match(fragment) does not succeed because
    the unterminated tag prevents the expression from matching before EOF.
  • fragment.rfind("<") returns 0; the guard last_open_bracket > 0 is False, so
    the backtrack branch is never taken.
  • The only exit is EOF, after all N bytes are consumed.

Affected readers -> readers that rely on XMLCorpusView, including
BNCCorpusReader, NPSChatCorpusReader, SemcorCorpusReader, MTECorpusReader,
NKJPCorpusReader, FrameNetCorpusReader, VerbNetCorpusReader, and direct
XMLCorpusView instantiation. XMLCorpusReader.xml() is not affected -> it calls
defusedxml.safe_parse().

PoC

Requires only pip install nltk. No corpus data needed.

python
1from pathlib import Path
2from tempfile import TemporaryDirectory
3from time import perf_counter
4from nltk.corpus.reader.bnc import BNCCorpusReader
5
6SIZES_KIB = (256, 512, 1024, 2048, 4096, 8192)
7results = []
8with TemporaryDirectory() as directory:
9 root = Path(directory)
10 malformed = root / "unterminated.xml"
11 for kib in SIZES_KIB:
12 malformed.write_bytes(b"<" + b"a" * (kib * 1024 - 1))
13 t = perf_counter()
14 try:
15 list(BNCCorpusReader(str(root), [malformed.name]).words())
16 except ValueError as e:
17 assert "tag not closed" in str(e)
18 results.append(perf_counter() - t)
19
20print("KiB seconds growth")
21for i, (kib, elapsed) in enumerate(zip(SIZES_KIB, results)):
22 ratio = "-" if i == 0 else f"{elapsed / results[i-1]:.2f}x"
23 print(f"{kib:5d} {elapsed:9.3f} {ratio}")

Runtime should increase by approximately fourfold for each doubling of input size,
although absolute timings vary by hardware.

During verification, _VALID_XML_RE.match() was instrumented to record the size of
each input. For a 256 KiB malformed file it was invoked 257 times on monotonically
increasing buffers (1024, 2048, …, 262144 bytes), with the final call occurring after
EOF. This confirms that every iteration rescans the accumulated fragment.

Impact

Applications that process attacker-controlled XML corpus files through an affected reader
are vulnerable. The attacker needs only write access to a path the reader will open. No
NLTK credentials or special privileges required. Offline tools reading only trusted
local corpora are not at risk.

Affected versions: Verified in NLTK 3.9.4, 3.10.0, and the current develop branch.
Historical inspection indicates the same loop structure has existed since the
introduction of XMLCorpusView (2007), but only the listed versions were
experimentally verified. No patch exists in any published release.

This issue results in CPU exhaustion and may allow denial of service in applications
that process attacker-controlled XML corpus files.

Suggested Fix

Avoid rescanning the accumulated fragment from the beginning after each 1 KiB read.
Incremental parsing, bounded fragment accumulation, or another streaming approach would
eliminate the quadratic behavior while preserving existing semantics.

A regression test should verify that BNCCorpusReader.words() raises ValueError
within a fixed timeout (e.g. 5 seconds) against a 2 MiB malformed input. The existing
test_xmldocs_security.py covers only the prior ReDoS payloads and does not exercise
this path.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.