Vulnerability GHSA-fg9w-wfp5-j933
Summary
NLTK: BllipParser passes caller-supplied model paths to the bllipparser C extension with no pathsec check, enabling arbitrary read
Details
Summary
BllipParser in nltk/parse/bllip.py hands caller-supplied filesystem paths to the native bllipparser C extension without routing them through NLTK's pathsec validation. The constructor parameters parser_model, reranker_features, and reranker_weights, and the alternative from_unified_model_dir() entry point, flow directly to the C extension's load_parser_model() and load_reranker_model() calls, which open and read the given path at the OS level. Any application that passes an attacker-influenced path to this API reads a file of the attacker's choosing from anywhere on the filesystem, outside the NLTK data roots that every other NLTK tool wrapper confines access to.
Root Cause
As of NLTK 3.10.0, SECURITY.md documents a centralized I/O security module (nltk.pathsec) with strict enforcement enabled by default, under which file-backed resources must resolve inside allowed NLTK data directories. For paths handed to external tools and native extensions that open files themselves, the module provides validate_tool_path(), which runs name-safety checks, rejects option-shaped, URL-shaped, and traversal inputs, opens the path with O_NOFOLLOW requiring a regular single-linked file, and returns the validated string the caller must use. Every sibling tool wrapper applies it. BllipParser applies none of it: the module imports only ParserI, safe_print, and Tree, with zero pathsec references, so the three model-path parameters reach the C extension unchecked. The violated invariant is that NLTK tool wrappers must bound caller-supplied native-tool paths to the data roots before the native code opens them.
nltk/parse/bllip.py:125-131 BllipParser.__init__ accepts parser_model,
reranker_features, reranker_weights (no validation)
nltk/parse/bllip.py:164 self.rrp.load_parser_model(parser_model, ...) -> C extension open
nltk/parse/bllip.py:166-170 self.rrp.load_reranker_model(features_filename=...,
weights_filename=...) -> C extension open
nltk/parse/bllip.py:218-255 from_unified_model_dir(model_dir) -> __init__ (no validation)
nltk/parse/bllip.py:9-11 imports: ParserI, safe_print, Tree (no pathsec import)
Impact
An application that uses BllipParser (requiring the optional bllipparser package to be installed) and that passes a model path derived from untrusted input to the constructor or to from_unified_model_dir() allows that input to designate an arbitrary filesystem path. The native extension opens and reads the designated file into process memory to parse it as model data. This defeats the pathsec data-root confinement that NLTK applies to all other native-tool model loads. The attacker-controlled path can target files outside the NLTK data directories, for example local configuration or credential files readable by the process. Content disclosure to the attacker depends on how the application surfaces parser errors or results; file existence, size, and open behavior provide an oracle regardless. Because the same unvalidated path also skips the checks that refuse a non-regular file or an over-large model, the attacker can instead designate a FIFO, socket, device, or directory, or an oversized regular file, so the native load blocks or reads unbounded data into memory and the component performing the parse suffers reduced performance or a stalled worker. The primitive demonstrated by source analysis is attacker control of which file the process opens and reads.
Proof of Concept
Requires the optional bllipparser C extension to be installed
(pip install bllipparser) so that RerankingParser.load_parser_model
is available. No NLTK data download is needed to exercise the path.
# An application forwards an untrusted "model" path into BllipParser.
from nltk.parse.bllip import BllipParser
# Attacker-controlled value reaches the constructor unchanged.
untrusted_path = "/etc/passwd"
BllipParser(parser_model=untrusted_path)
# The bllipparser C extension opens and reads untrusted_path at the OS
# level with no NLTK pathsec confinement to the data roots.
Structural confirmation without the optional extension installed:
$ grep -n "pathsec\|validate_path\|validate_tool" nltk/parse/bllip.py
(no output)
$ grep -n "validate_tool_path" nltk/tag/crf.py nltk/tag/hunpos.py \
nltk/tag/stanford.py nltk/parse/malt.py
nltk/tag/crf.py:140
nltk/tag/crf.py:311
nltk/tag/hunpos.py:111
nltk/tag/stanford.py:85
nltk/tag/stanford.py:137
nltk/parse/malt.py:340
nltk/parse/malt.py:344
BllipParser is the only native-tool wrapper with no validate_tool_path call.
Attack Chain
- Exposure: An application imports
BllipParser(exported atnltk/parse/bllip.py:83and re-exported atnltk/parse/__init__.py:47) and constructs it with a model path influenced by untrusted input. - Control: The attacker controls the
parser_model,reranker_features, orreranker_weightsstring (or themodel_dirpassed tofrom_unified_model_dir()atnltk/parse/bllip.py:218); it is accepted as an arbitrary string with no type or content constraint. - Path: The value flows unchanged from
BllipParser.__init__(nltk/parse/bllip.py:125) toself.rrp.load_parser_model(parser_model, ...)atnltk/parse/bllip.py:164, and the reranker values toself.rrp.load_reranker_model(...)atnltk/parse/bllip.py:166-170. - Guard: No pathsec guard is present on this path.
nltk/parse/bllip.pyimports no pathsec symbol, sovalidate_tool_path()(the guard applied by every sibling wrapper) is never invoked before the native open. - Primitive: The
bllipparserC extension performs an OS-level open and read of the supplied path to parse model data, reaching the filesystem directly. - Result: A file at an attacker-chosen path, including locations outside the NLTK data roots, is opened and read by the process, bypassing the pathsec data-root confinement enforced elsewhere.
Bypass Evidence
The relevant protection is nltk.pathsec.validate_tool_path() (nltk/pathsec.py:950), documented as bounding "a filesystem path handed to an external tool to read or write" and designed exactly for native loaders that open the file themselves. The strongest counter-argument is that SECURITY.md disclaims being "a general operating-system sandbox" and does "not prevent all unsafe behavior an application might perform outside NLTK." That disclaimer concerns application code outside NLTK, not NLTK's own tool wrappers: the project added validate_tool_path() and applied it to CRFTagger.set_model_file() (nltk/tag/crf.py:140), HunposTagger (nltk/tag/hunpos.py:111), StanfordTagger (nltk/tag/stanford.py:85,137), and MaltParser (nltk/parse/malt.py:340,344), with the CRFTagger and HunposTagger wrappers citing a prior tool-path advisory and explaining that pathsec.open cannot be used because the C extension does its own open. BllipParser is the single native-tool wrapper that omits the guard. The alternative from_unified_model_dir() path offers no mitigation: get_unified_model_parameters() belongs to the third-party bllipparser package and performs no NLTK data-root validation, so the derived sub-paths reach the constructor and the C extension unchecked.
Affected Versions
- Ecosystem:
pip - Package:
nltk - Confirmed affected range:
>= 3.10.0, <= 3.10.3 - Latest release checked:
3.10.3(PyPI) - Fix status: 3.10.4
The lower bound is 3.10.0 because the pathsec enforcement model and the validate_tool_path() guard were introduced in that release; before 3.10.0 no such boundary existed for NLTK to violate. The missing validation persists unchanged through 3.10.3, the current PyPI release, and on the repository default branch (no pathsec, validate_path, or validate_tool reference exists in nltk/parse/bllip.py). No fixed release is established.
Suggested Fix
Validate every caller-supplied native-tool path in BllipParser before it reaches the bllipparser C extension, matching the pattern already used by the other wrappers. In BllipParser.__init__, pass parser_model, reranker_features, and reranker_weights through nltk.pathsec.validate_tool_path() (with max_bytes=MAX_TOOL_MODEL_BYTES and require_private=True, as CRFTagger.set_model_file() does) and build the C-extension arguments from the returned strings so a path swapped after the check is not reopened. Because parser_model denotes a model directory for this loader, the fix must cover the directory argument and the reranker file arguments, and should apply equally to the paths derived inside from_unified_model_dir(). As an interim measure, applications should refuse to pass untrusted input as any BllipParser model path and should load models only through nltk.data.find(), which already enforces pathsec confinement.
Related Vulnerabilities
Other vulnerabilities affecting the same packages