Vulnerability GHSA-8fx7-8jr8-84rv

High Risk
HIGH RISK
CVSS Score: 7.5
Score Range: 7.0–8.9
High severity vulnerabilities (CVSS 7.0–8.9). Serious vulnerabilities that should be prioritized soon after critical fixes.
4 hours ago
November 13, 2026 at 09:02 PM UTC
NLTK: Unbounded CPU consumption in SteppingRecursiveDescentParser.parse() with ambiguous or self-embedding grammars
0.8 and 0.9 and 0.9.3 - 0.9.9 and 2.0b4 and 2.0b5 and 2.0b6 and 2.0b7 and 2.0b8 and 2.0b9 and 2.0.1rc1 and 2.0.1rc3 and 2.0.1rc4 and 2.0.1 - 3.0.0b1 and 3.0.0b2 and 3.0.0 - 3.0.5 and 3.1 and 3.2 and 3.2.1 - 3.2.5 and 3.3 and 3.4 and 3.4.1 - 3.4.5 and 3.5b1 and 3.5 and 3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3
0.8 and 0.9 and 0.9.3 - 0.9.9 and 2.0b4 and 2.0b5 and 2.0b6 and 2.0b7 and 2.0b8 and 2.0b9 and 2.0.1rc1 and 2.0.1rc3 and 2.0.1rc4 and 2.0.1 - 3.0.0b1 and 3.0.0b2 and 3.0.0 - 3.0.5 and 3.1 and 3.2 and 3.2.1 - 3.2.5 and 3.3 and 3.4 and 3.4.1 - 3.4.5 and 3.5b1 and 3.5 and 3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3

Summary

NLTK: Unbounded CPU consumption in SteppingRecursiveDescentParser.parse() with ambiguous or self-embedding grammars

Details

Summary

nltk.parse.recursivedescent.SteppingRecursiveDescentParser.parse() performs an unbounded top-down search over ambiguous or self-embedding context-free grammars with no wall-clock or step limit. An actor who can influence the grammar and supply a short input to a caller that uses this parser can force a single parse() call to pin a CPU core indefinitely, producing a denial of service. The related base class RecursiveDescentParser gained a max_time wall-clock deadline, but the stepping subclass drives its own step loop and never arms or consults that deadline, and its constructor does not accept a max_time parameter, so the guard cannot be enabled on this path.

Root Cause

RecursiveDescentParser bounds its recursive search inside _parse: it arms a wall-clock deadline on the first step and raises TimeoutError once max_time (default 5.0 seconds) is exceeded. SteppingRecursiveDescentParser was designed for interactive single-stepping and overrides both parse() and _parse. Its parse() runs initialize() followed by a while self.step() is not None loop that never enters the guarded base _parse. Its _parse override is a state-capturing stub that records history and returns a sentinel, and it never references the inherited self._parse_deadline or self._max_time. Its __init__ signature omits max_time entirely, so a caller cannot enable or tighten a bound. The security invariant that a single parse must terminate within a bounded time is therefore never enforced on this parser.

Affected component: nltk.parse.recursivedescent.SteppingRecursiveDescentParser
- parse()      nltk/parse/recursivedescent.py:428-433  (drives its own step loop, no bound armed or checked)
- _parse()     nltk/parse/recursivedescent.py:643-668  (stub override; never consults self._parse_deadline / self._max_time)
- __init__()   nltk/parse/recursivedescent.py:410-418  (signature (self, grammar, trace=0); no max_time parameter)
Base guard for comparison: RecursiveDescentParser._parse nltk/parse/recursivedescent.py:141-150

Impact

An actor who controls or influences the grammar passed to SteppingRecursiveDescentParser and can supply a short token sequence to a caller that invokes parse() can cause that call to never return. The top-down search enumerates a derivation space that grows on the order of the Catalan numbers with no memoization of the search and no deadline, so one crafted grammar plus a short input deterministically consumes a CPU core. In a deployment that parses attacker-influenced input on a bounded worker pool, repeated requests exhaust the pool and deny service to legitimate users. No authentication, privilege, or user interaction is required in that worst-credible deployment. Exploitation requires that the target actually use this parser on untrusted input; because the stepping parser is primarily a debugging or interactive-stepping component that backs a GUI, real-world exposure is narrower than for the base parser, though parse() is a supported public method that behaves as a full parser.

Proof of Concept

Requires only the nltk package (3.10.3) and standard library; no data downloads or network access.

Reproduction steps:

  1. Build an ambiguous, self-embedding grammar such as S -> 'a' S S | 'a'.
  2. Confirm the base parser self-terminates: RecursiveDescentParser(g, max_time=2.0).parse(['a']*17) raises TimeoutError at about 2 seconds.
  3. Run the stepping parser on the same grammar and input and observe it never returns.
import time, inspect
from nltk import CFG
from nltk.parse.recursivedescent import RecursiveDescentParser, SteppingRecursiveDescentParser

g = CFG.fromstring("S -> 'a' S S | 'a'")

# Base parser is bounded and self-terminates.
try:
    list(RecursiveDescentParser(g, max_time=2.0).parse(['a'] * 17))
except TimeoutError:
    print("base: TimeoutError (guarded)")

# Stepping subclass cannot be bounded: no max_time parameter.
print("stepping __init__ sig:", inspect.signature(SteppingRecursiveDescentParser.__init__))

# Stepping parser runs unbounded on the same input.
t0 = time.perf_counter()
list(SteppingRecursiveDescentParser(g).parse(['a'] * 17))  # does not return
print("stepping: returned after", time.perf_counter() - t0)
base: TimeoutError (guarded)
stepping __init__ sig: (self, grammar, trace=0)
# stepping len3: 1 parse in 0.001s ; len7: 5 in 0.017s ; len9: 14 in 0.075s ; len11: 42 in 0.36s
# stepping len17: still running past a 10s watchdog, process alive, no RecursionError

Attack Chain

  1. Exposure: A caller uses nltk.parse.SteppingRecursiveDescentParser, a public exported symbol (nltk/parse/__init__.py:97), invoking parse(tokens) on grammar and input influenced by an actor.
  2. Control: The actor influences the grammar (an ambiguous, self-embedding CFG such as S -> 'a' S S | 'a') and supplies a short token list (for example 17 copies of 'a'). Both survive to the parser unchanged.
  3. Path: parse() (recursivedescent.py:428) calls initialize() then loops while self.step() is not None; step() (:479) calls match()/expand() (:552/:512) which call the inherited _match/_expand (:172/:214), which resolve self._parse by MRO to the subclass stub (:643).
  4. Guard: The max_time wall-clock deadline exists only in the base _parse (:141-150) and is never entered on this path; the stub _parse records state and returns without consulting the deadline, and __init__ exposes no max_time to enable a bound.
  5. Primitive: The unbounded top-down search enumerates a Catalan-number-sized derivation space with no memoization and no deadline.
  6. Result: The parse() call never returns and pins a CPU core; repeated calls exhaust a bounded worker pool, denying service.

Bypass Evidence

The prior fix for this class of issue added a max_time wall-clock deadline to the base RecursiveDescentParser._parse (recursivedescent.py:141-150, default 5.0 seconds). This report demonstrates that the fix does not cover the sibling SteppingRecursiveDescentParser entry point, and the maintainers separately hardened other parsers (chart, earley chart, viterbi, shift-reduce, non-projective dependency) with max_time while leaving this subclass unbounded.

Challenges attempted and their outcomes:

  • Hypothesis that the inherited self._max_time value (observed at 5.0) makes the path guarded: refuted; the value is inherited but never read on the stepping path, and the parser runs unbounded despite it being set.
  • Hypothesis that a caller can bound the parser via the constructor: refuted; inspect.signature(SteppingRecursiveDescentParser.__init__) is (self, grammar, trace=0), with no max_time parameter.
  • Hypothesis that the failure is merely an early RecursionError rather than CPU exhaustion: refuted; the step loop is iterative over an explicit history stack, so no exception is raised within a 10 second watchdog, CPU time grows exponentially with input length, and a small negative control input returns in about 0.001 seconds.
  • Hypothesis that the _tried_e/_tried_m records collapse the search: refuted; those records are keyed by the frozen full partial tree, so they deduplicate only identical productions at identical states and do not prevent the exponential enumeration.

Affected Versions

  • Ecosystem: PyPI
  • Package: nltk
  • Confirmed affected range: <= 3.10.3
  • Latest release checked: 3.10.3 (PyPI sdist nltk-3.10.3.tar.gz)
  • Fix status: 3.10.4

The stepping subclass has never carried a time or step bound in any released version; the prior fix added the guard only to the base class. The vulnerable methods were verified present in the exact published PyPI 3.10.3 source distribution (parse() at lines 428-433, stub _parse at 643-668 with no deadline reference, and __init__ without max_time). Because the class predates the prior fix and no specific historical introduction tag was pinned, the lower bound is left open rather than invented; the upper bound is verified against the current published artifact (3.10.3). No released fix exists.

Suggested Fix

Enforce a bounded parse on the stepping parser: give SteppingRecursiveDescentParser its own wall-clock or step-count budget that is checked inside its step loop (for example in parse() and/or the _parse/step path), and add a max_time (or equivalent step-limit) parameter to __init__ that is propagated and honored, mirroring the base RecursiveDescentParser and the other hardened sibling parsers. As an interim mitigation without a code change, callers should avoid invoking SteppingRecursiveDescentParser.parse() on grammars or input derived from untrusted sources, run any such parsing under an external timeout or a separate resource-limited process, and prefer a chart parser for untrusted input.

Related Vulnerabilities

Other vulnerabilities affecting the same packages

High Risk
1 hour ago
NLTK: Quadratic-time CPU denial of service in CCG lexicon parser (fromstring) on wide bracket bodies and operator chains
0.8 and 0.9 and 0.9.3 - 0.9.9 and 2.0b4 and 2.0b5 and 2.0b6 and 2.0b7 and 2.0b8 and 2.0b9 and 2.0.1rc1 and 2.0.1rc3 and 2.0.1rc4 and 2.0.1 - 3.0.0b1 and 3.0.0b2 and 3.0.0 - 3.0.5 and 3.1 and 3.2 and 3.2.1 - 3.2.5 and 3.3 and 3.4 and 3.4.1 - 3.4.5 and 3.5b1 and 3.5 and 3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3 GHSA-89p3-fcch-88ph
0.8 and 0.9 and 0.9.3 - 0.9.9 and 2.0b4 and 2.0b5 and 2.0b6 and 2.0b7 and 2.0b8 and 2.0b9 and 2.0.1rc1 and 2.0.1rc3 and 2.0.1rc4 and 2.0.1 - 3.0.0b1 and 3.0.0b2 and 3.0.0 - 3.0.5 and 3.1 and 3.2 and 3.2.1 - 3.2.5 and 3.3 and 3.4 and 3.4.1 - 3.4.5 and 3.5b1 and 3.5 and 3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3 GHSA-89p3-fcch-88ph
High Risk
1 hour ago
NLTK: Quadratic-time CPU denial of service in Tree.chomsky_normal_form right-factor binarization via list.pop(0)
0.8 and 0.9 and 0.9.3 - 0.9.9 and 2.0b4 and 2.0b5 and 2.0b6 and 2.0b7 and 2.0b8 and 2.0b9 and 2.0.1rc1 and 2.0.1rc3 and 2.0.1rc4 and 2.0.1 - 3.0.0b1 and 3.0.0b2 and 3.0.0 - 3.0.5 and 3.1 and 3.2 and 3.2.1 - 3.2.5 and 3.3 and 3.4 and 3.4.1 - 3.4.5 and 3.5b1 and 3.5 and 3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3 GHSA-r53h-rw34-8h97
0.8 and 0.9 and 0.9.3 - 0.9.9 and 2.0b4 and 2.0b5 and 2.0b6 and 2.0b7 and 2.0b8 and 2.0b9 and 2.0.1rc1 and 2.0.1rc3 and 2.0.1rc4 and 2.0.1 - 3.0.0b1 and 3.0.0b2 and 3.0.0 - 3.0.5 and 3.1 and 3.2 and 3.2.1 - 3.2.5 and 3.3 and 3.4 and 3.4.1 - 3.4.5 and 3.5b1 and 3.5 and 3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3 GHSA-r53h-rw34-8h97
High Risk
1 hour ago
NLTK: Quadratic-time CPU denial of service in ConllCorpusReader SRL instance builder via nested span rescan
0.8 and 0.9 and 0.9.3 - 0.9.9 and 2.0b4 and 2.0b5 and 2.0b6 and 2.0b7 and 2.0b8 and 2.0b9 and 2.0.1rc1 and 2.0.1rc3 and 2.0.1rc4 and 2.0.1 - 3.0.0b1 and 3.0.0b2 and 3.0.0 - 3.0.5 and 3.1 and 3.2 and 3.2.1 - 3.2.5 and 3.3 and 3.4 and 3.4.1 - 3.4.5 and 3.5b1 and 3.5 and 3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3 GHSA-v8f3-6phw-6mh9
0.8 and 0.9 and 0.9.3 - 0.9.9 and 2.0b4 and 2.0b5 and 2.0b6 and 2.0b7 and 2.0b8 and 2.0b9 and 2.0.1rc1 and 2.0.1rc3 and 2.0.1rc4 and 2.0.1 - 3.0.0b1 and 3.0.0b2 and 3.0.0 - 3.0.5 and 3.1 and 3.2 and 3.2.1 - 3.2.5 and 3.3 and 3.4 and 3.4.1 - 3.4.5 and 3.5b1 and 3.5 and 3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3 GHSA-v8f3-6phw-6mh9
Medium Risk
2 hours ago
NLTK: BllipParser passes caller-supplied model paths to the bllipparser C extension with no pathsec check, enabling arbitrary read
3.10.0 - 3.10.3 GHSA-fg9w-wfp5-j933
3.10.0 - 3.10.3 GHSA-fg9w-wfp5-j933
Medium Risk
4 hours ago
NLTK: Quadratic CPU exhaustion in LegalitySyllableTokenizer.tokenize via unguarded onset reversal loop
3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3 GHSA-gpwc-27cw-rh9r
3.6 and 3.6.1 - 3.6.7 and 3.7 and 3.8 and 3.8.1 and 3.9b1 and 3.9 and 3.9.1 - 3.10.3 GHSA-gpwc-27cw-rh9r
View all vulnerabilities for these packages

Impacted packages

Timeline

Published
4 hours ago
November 13, 2026 at 09:02 PM UTC
Fixed (3.10.4)
23 days ago
October 21, 2026 at 11:34 AM UTC
Last Modified
4 hours ago
November 13, 2026 at 09:15 PM UTC