NLTK: Corpus readers follow symlinks outside trusted roots despite pathsec enforcement
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
30일 내 악용 확률 예측
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS 벡터 정보 없음
상세 설명
Summary
Several corpus readers still step outside NLTK's symlink-aware trusted-root model. They derive in-root paths from trusted corpus state, convert those paths back into plain strings, and reopen them with built-in open() rather than nltk.pathsec.open().
Details
- Vulnerability type: Path traversal and symlink boundary bypass
- Affected component:
nltk.corpus.reader.ipipan,nltk.corpus.reader.crubadan,nltk.corpus.reader.lin - Affected versions: Published
3.9.4and current sourcev3.10.0-rc2both reproduced. - Patched versions: Not yet patched
- Root cause: Root-derived paths are reopened with raw
open()without preserving the trusted-root boundary.
IPIPANCorpusReader opens header.xml derived from morph.xml, CrubadanCorpusReader opens table.txt directly, and LinThesaurusCorpusReader opens simN.lsp paths returned from its own root helpers. Under pathsec.ENFORCE=True, a symlink placed inside the trusted corpus root can point outside the root and still be parsed successfully. It was confirmed parsed outside-root content is returned through public methods such as channels(), domains(), categories(), langs(), crubadan_to_iso(), synonyms(), and scored_synonyms().
PoC
Preconditions
- The application processes attacker-influenced corpora inside a trusted NLTK data root or trusted corpus directory.
Steps
- Create a trusted corpus root and keep
pathsec.ENFORCE=Truewith that root allowlisted. - Place symlinked reader inputs such as
header.xml,table.txt, orsimN.lspinside the root and point them to external files. - Instantiate the corresponding corpus reader and call its normal public methods.
- Observe that parsed outside-root values are returned even though
pathsec.open()blocks the same symlink targets.
Minimal reproducible excerpt
1{'ipipan': ['LEAK', 'TOPSECRET', 'CLASSIFIED'], 'crubadan': ['LEAK'], 'lin': [('LEAK', 9.5)]}Impact
An attacker who can stage corpus files or symlinks under a trusted data root can disclose outside-root content through normal corpus-reader results, defeating the boundary NLTK documents for shared and untrusted-input environments.
Remediation
Preserve PathPointer and required_root semantics end to end. Replace direct open() calls with nltk.pathsec.open() or a reader helper that keeps the trusted-root boundary intact.
References
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/ipipan.py#L162-L192
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/crubadan.py#L74-L98
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/lin.py#L40-L43
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/pathsec.py#L521-L545
Fix + full-codebase audit (verified)
I swept every raw file open in the corpus readers, not just the three the umbrella named:
| Reader | Site | Advisory | Root scoping |
|---|---|---|---|
| crubadan | table.txt + <code>-3grams.txt | p4rw / j5pw | required_root=self.root |
| lin | simN.lsp | p4rw | required_root=self.root |
| xmldocs | XMLCorpusView bare-string fileid | 934p (base reader) | global fallback (view has no root) |
| pl196x | textids index | found by audit | required_root=self._root |
| mte | MTEFileReader | mvf5 | required_root threaded through 8 call sites |
| toolbox | StandardFormat.open codecs.open | cr8c | global sandbox (low-level parser) |
| named_entity | load_ace_file ann/text | 7qj2 | global sandbox |
| nkjp | XML_Tool source file | p4rw class | required_root=self._root |
ipipan already validates via the earlier #3727 fix — unchanged.
Fix
Each site now calls nltk.pathsec.validate_path(path, required_root=…) before opening. Where the reader has a concrete corpus root, the check is scoped with required_root (rejects any escape outside that root). XMLCorpusView carries no root, so it falls back to the global data-root sandbox via getattr(self, "_root", None) — which also avoids an AttributeError on the bare-string path.
Reproduced (captured)
1raw open(symlink) reads: 'TOPSECRET_OUTSIDE_ROOT' <- the bypass 2validate_path(symlink, required_root): ValueError -> BLOCKS the escape 3validate_path(legit in-root): PASSED <- loads normallyHonest residual
The global-sandbox fallback (toolbox, named_entity, xmldocs-view) is only as tight as the allowed-roots list, which currently includes the system temp dir. Scoping every reader with required_root and removing the temp dir from the allowed roots would harden it further (separate advisory / task).
Tests
test_corpus_reader_pathsec.py — symlink escape rejected, in-root file allowed, XMLCorpusView string-fileid no AttributeError, MTEFileReader out-of-root rejected. 46 existing corpus/toolbox tests pass; all edited modules import (no circular import). pre-commit (black/isort/ruff) clean.
Scope caveat
validate_path blocks every symlink escape variant (verified) and equals pathsec.open()'s guarantee, but does NOT block hardlinks (no symlink to resolve; tracked separately as GHSA-f794-5jv7-7672) or the validate-then-open TOCTOU race (shared by pathsec.open; needs O_NOFOLLOW/openat).
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 7
링크 내용 불러오는 중…