CVE-2026-54293 NLTK: URL-Encoded Path Traversal in nltk.data.load()
1. Introduction
This article examines a path-traversal flaw in nltk.data.load() in NLTK 3.9.4 and earlier, tracked as CVE-2026-54293 with a CVSS v3.1 score of 7.5 (High) and fixed in 3.10.0. It is a decode-after-check bug: a safety check runs on a resource string before that string is percent-decoded. We reproduced it in an isolated Docker lab (see Reproduction).
nltk ships two independent mitigations against resource paths escaping the configured data directory. In 3.9.4 both fail, for different reasons. The first, a regular expression, is applied to the resource string before percent-decoding: it catches literal traversal forms such as ../, but it checks the still-encoded string, so percent-encoded traversal can pass. nltk.pathsec, the second of the two, resolves the resulting path and checks it against a set of allowed roots. In the lab layout used here the target file sat outside every allowed root, so it noticed; but with ENFORCE = False it emits a warning and returns the file’s contents anyway.
The gap between them is a single url2pathname() call. That function percent-decodes, so a payload written as ..%2f is not a traversal sequence when the guard looks at it, and becomes one on the way to the filesystem.
nltk.data.load(“nltk:..%2foutside%2fsecret.txt”) returned the contents of a file outside the configured data root on every recorded run of 3.9.4: CPython 3.10 to 3.14 on Linux (Debian slim, native arm64), plus an emulated amd64 run, an Alpine (musl) run, a PyPy 3.10 run and a native macOS run (arm64, CPython 3.14.8). 3.10.0 refuses the same payload.
2. NLTK Description
NLTK (Natural Language Toolkit) is an open-source Python library of modules, datasets and tutorials for natural language processing research and development. Its nltk.data module locates and loads resource files, such as corpora and tokenizer models, from a list of data directories (nltk.data.path). A resource can be named as a plain path or as an nltk: URL, for example nltk:corpora/words.txt. The nltk: form is the one this vulnerability abuses.
3. Vulnerability Severity
| Field | Value |
|---|---|
| CVE | CVE-2026-54293 |
| Advisory | GHSA-p4gq-832x-fm9v |
| Package | nltk (pip) |
| Class | CWE-22, path traversal via decode-after-check |
| Severity | CVSS v3.1 7.5 (High), AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N, as published in the GitHub advisory and the CVE record |
| Related identifiers | CVE-2026-12243, a huntr-assigned ID for the same percent-encoding bypass, also reported publicly in upstream issue #3504 (published 2026-06-30; its CVE record shows it rejected by its CNA on 2026-08-18, giving no reason) |
| Demonstrated impact | File read outside the data root. Not shown for this CVE: code execution, a file write, or a network attack surface. (A separate retrieve() write path is described under Related Issues.) |
The public dates describe different events. The fix was committed on 2026-06-06; the repository advisory was published on 2026-06-11; the CVE ID was reserved on 2026-06-12; the advisory was reviewed and published to the GitHub Advisory Database on 2026-06-16 (OSV shows the same date); and the CVE record and the NVD entry were published on 2026-06-22.
The advisory’s CVSS v3.1 vector is CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N. It assumes an application exposes attacker-controlled input to load() or find() over a network. The lab called the functions directly and did not exercise a network path, so the network metric describes an application-level assumption, not a lab observation.
4. Affected and Fixed Versions
| Item | Versions |
|---|---|
| Affected (per the advisory) | <= 3.9.4 |
| Fixed (per the advisory) | >= 3.10.0 |
| Tested in the lab | 3.9.4, 3.10.0, 3.10.3 |
| CPython versions tested (3.9.4) | 3.10.22, 3.11.17, 3.12.15, 3.13.16, 3.14.8 |
| Other platforms (3.9.4 and 3.10.0) | linux/amd64 (emulated), Alpine (musl), PyPy 3.10, macOS arm64 (native) |
| Not tested | 3.10.0-rc1 (a git tag only; see below), releases earlier than 3.9.4 |
The CVE record says fixed in 3.10.0-rc1 while the advisory says patched >=3.10.0; both agree with the tags, because v3.10.0-rc1 contains the fix commit. PyPI never published 3.10.0rc1 (pip index versions nltk –pre lists no such release), so 3.10.0 is the first installable release after 3.9.4. 3.9.4 declares requires_python >=3.10, so it cannot be installed on CPython 3.9. Source line numbers in this article refer to the wheels published on PyPI for 3.9.4, 3.10.0 and 3.10.3.
5. Root Cause: Why the Guard Misses It
The guard is a single compiled pattern:
_UNSAFE_NO_PROTOCOL_RE = re.compile(r”(?:\.\./|\.\.$|^/|\\|[A-Za-z]:[/\\])”)
It is applied to the resource name after normalize_resource_name(), which runs os.path.normpath and does not decode percent-escapes, so the regex sees the undecoded text. The route from there to the filesystem runs through three steps:
| Step | Function | Effect on ..%2f |
|---|---|---|
| Normalize | normalize_resource_name() | unchanged, still ..%2f |
| Guard | _UNSAFE_NO_PROTOCOL_RE | no match |
| Resolve | url2pathname() | decodes to ../ |
..%2f cannot match \.\./. After the two dots comes %, not a separator. The guard is not weak in expression; it is applied at the wrong moment, before the string it inspects has been decoded into the form that actually reaches open(). The guard runs in find() (3.9.4 data.py line 617); for nltk: URLs normalize_resource_url() does not apply it.

Figure 1. Read from the installed module, not re-implemented. The 3.9.4 and 3.10.3 wheels carry a byte-identical guard regex, and url2pathname() turns ..%2f into ../outside/secret.txt in both. The probe calls that function directly to show what it does in isolation; in the real load() path on the fixed releases the payload never reaches it, which is what Figure 2 measures. The relevant addition is _assert_no_encoded_bypass (data.py also changed elsewhere between these releases): absent in 3.9.4, guard verdict ACCEPTS; present in 3.10.3, verdict REJECTS.
6. Why the Second Layer Does Not Save It
nltk.pathsec is a resolver-level check. In the lab layout it ran, and it was correct:
Security Violation [pathsec.open]: Unauthorized path /lab/outside/secret.txt
It still returned the file. In 3.9.4 the module is 284 lines and defines ENFORCE = False. The Unauthorized path branch raises PermissionError only when ENFORCE is true; otherwise it warns and returns. Here, enforcement existed and was off.

Figure 2. The two layers separated. On 3.9.4 layer A misses the encoding and layer B runs (validate_path ran=1) but only warns. On 3.10.0, the first fixed release, validate_path runs zero times. The payload is refused before resolution, so the ENFORCE setting never comes into play. 3.10.3 behaves identically in this layout (/lab, outside every allowed root); its pathsec.py differs from 3.10.0’s. Long lines wrap at the terminal width.
Two limits on that statement. First, pathsec is an allowlist, not a data-root check: its allowed roots are nltk.data.path, $NLTK_DATA, ~/nltk_data, /usr/share/nltk_data and the system temporary directory. The lab file sits under /lab, outside all of them, so pathsec reached its warning branch. Calling validate_path on the released wheels outside the lab, with nltk.data not loaded, shows that in 3.9.4 and 3.10.0 a path under the temp directory is allowed silently even with ENFORCE = True; 3.10.3 no longer allows it. So flipping ENFORCE would not have stopped reads of files under those roots. Second, that call exercises the allowlist logic in isolation; it is not an end-to-end read.
The wording of the warning also depends on the working directory. The lab probes delete and recreate /lab at start-up, which removes the process’s own working directory; os.getcwd() then fails and pathsec‘s current-directory fallback is skipped, so the path is reported as unauthorized. With a valid working directory that contains the target, 3.9.4 warns allowed via CWD instead (as in the reproduction below), and the read still succeeds. With ENFORCE = True both cases are refused with PermissionError. This was checked by calling validate_path from the 3.9.4 wheel under each condition, outside the lab.
The counter settles the ordering. For this payload on 3.10.0 and 3.10.3 the resolver is not consulted at all, so the fix rejects it before resolution rather than making the resolver stricter.
7. Which Spellings Work
On 3.9.4 seven spellings of the same traversal escape, grouped into the first five rows below; the other five rows are negative controls:
| Spelling | Decodes to | Outcome |
|---|---|---|
| ..%2f, ..%2F | ../outside/secret.txt | escapes |
| %2e%2e/, %2E%2E/ | ../outside/secret.txt | escapes |
| %2e./ first dot only | ../outside/secret.txt | escapes |
| .%2e/ second dot only | ../outside/secret.txt | escapes |
| %2e.%2f mixed | ../outside/secret.txt | escapes |
| ….// double-dot segment | ….//outside/… | blocked by the guard |
| %2E.%2e/ | …/outside/secret.txt | not traversal, LookupError |
| %252e%252e%252f | literal %2e%2e%2f | not traversal, LookupError |
| ..%c0%af overlong UTF-8 | surrogates | not traversal, LookupError |
| ..;/ path parameters | ..;/outside/… | not traversal, LookupError |
The rule is simple: encoding either the dots or the separator is enough. Overlong UTF-8, the classic bypass in other stacks, does nothing here, because Python’s unquote does not decode it. The guard regex is identical in 3.9.4, 3.10.0 and 3.10.3 and accepts all five escaping rows, including %2e%2e/, where only the dots are encoded and the separator stays literal (an easy form to overlook); only the decode-then-check step added in 3.10 refuses them. Every spelling in the five escaping rows is refused on 3.10.0. Of the five rows that never traversed on 3.9.4, two are refused by a guard on 3.10.0 (….// and %2E.%2e/) and three simply fail with LookupError, as they did on 3.9.4.
Three notes on the cases that do not work:
- Double-encoding decodes one level only, leaving a literal %2e%2e%2f filename. LookupError on every version tested. The fix mirrors url2pathname() with a single unquote() pass, so it does not over-decode either.
- Encoded backslash (..%5c) is not a separator on POSIX. LookupError on 3.9.4. On 3.10.3 the new check rejects it outright, because the decoded form contains a backslash and the guard rejects those too.
- Literal ../ is refused everywhere, in every version.
8. The Fix
9. Impact and Scope
An attacker who controls the resource string passed to nltk.data.load() can make it return a file from outside the data root, limited to what the calling process can read. The simplest exposed pattern is a handler that passes a user-supplied resource identifier to nltk.data.load():

In the lab, a small handler of this shape, given ..%2foutside%2fsecret.txt as the identifier, returned the contents of a file outside its data directory on 3.9.4 (together with the pathsec warning), and returned 400, ValueError: Unsafe resource path on 3.10.0. The identifier arrived on the command line, so no socket was opened.
This is a library, not a service. It is exploitable only where an application passes an attacker-influenced resource string into nltk.data.load() or nltk.data.find(). Through load() the payload uses the nltk: URL form, as tested in the Reproduction section. A bare encoded name passed straight to find() is decoded after the same check: in a host-level check on 3.9.4, find(“..%2foutside%2fsecret.txt”) returned a pointer to the outside file and reading it succeeded, while 3.10.0 refused it. That check ran against the released wheels with two dependencies stubbed, not in the lab. With the nltk: prefix left on, find() raises LookupError because it does not strip the scheme.
In the 3.10.0 wheel, the library modules that call load() or find() include corpus/util.py, sem/glue.py, tokenize/punkt.py, sentiment/vader.py and grammar.py. Corpus loaders such as wordnet, punkt and stopwords go through corpus/util.py, which calls find() with a name built from the corpus name. Whether any application lets an attacker choose that name was not investigated. No network-facing attack surface was demonstrated.
The read is bounded by the filesystem permissions of the calling process.
10. Reproduction
This reproduces the finding with one script and no NLTK data download. It needs Docker on Linux and network access to install nltk from PyPI. Each run uses a fresh container, so no cleanup is needed. The commands pin the base image by digest and nltk‘s dependencies to the versions the lab resolved, so the environment can be rebuilt as recorded; the pip freeze lines in the output show what was installed.
- Save the script below as minrepro.py in an empty working directory.

- Run it against the vulnerable release:

Expected on stdout (the pip freeze lines show the pinned versions; the data-root path shows the container’s home directory):

A RuntimeWarning from nltk.pathsec appears on stderr, and its text depends on the working directory. From / (as above) it reads Security Warning [pathsec.open]: Path /root/.nltk-repro/outside/secret.txt allowed via CWD. With -w /tmp instead it reads Security Violation [pathsec.open]: Unauthorized path /root/.nltk-repro/outside/secret.txt. The read succeeds either way. pip also prints its own notices (a root-user warning and a new-release notice), which can be ignored.
- Run it against the fixed release:
Expected:

4. Success criterion. The script writes a file outside the data root and prints VULNERABLE only if the bytes returned by load() contain that file’s contents. On 3.9.4 that line must appear; on 3.10.0 it must not, and the refused line must.


Lab setup
The lab ran in Docker on Linux (python:3.12-slim, Python 3.12.15, native linux/arm64); the macOS check described above ran natively.
Each probe starts a fresh container, writes a marker file outside the data root, calls load(), and checks whether the returned content contains the marker. A successful process exit is never treated as proof; only the presence of the marker counts. Every load() call passes cache=False, so a cached copy cannot stand in for a read. In the screenshots, lines marked # note: are the author’s annotations, not program output.

Figure 3. Host and Docker versions, SHA-256 of the three wheel files, and for each image the nltk version it reports and the SHA-256 pip recorded for the wheel it installed, which matches the wheel file.
Control and bypass
The guard works, for the unencoded form.

Figure 4. On the vulnerable version, nltk:../outside/secret.txt is refused with ValueError: Unsafe resource path. What follows is therefore a bypass of a working guard.
The same payload, percent-encoded, on the vulnerable release and on the first fixed release:

Figure 5. With the data root set to /lab/data, the percent-encoded resource returns NLTK-TRAVERSAL-MARKER-a1b2c3, the marker written to /lab/outside/secret.txt, outside that root. ESCAPE_OBSERVED=YES and exit_status=10 are printed by the probe after it checks that the returned content contains the marker.

Figure 6. The identical payload on 3.10.0 is refused with ValueError and no marker is returned. The fix does decode the string, once with unquote(), but only to inspect it; the rejection happens before url2pathname() and before any path is resolved or opened.
The probe repeated against all three releases:

Figure 7. The same probe and marker run against all three releases, each in a fresh container.
11. Related Issues
CVE-2026-62388: the insecure default
CVE-2026-62388 (GHSA-p3m8-78j2-g5p3, high, CWE-1188) records the insecure default as its own issue, for versions before 3.10.0. It is a separate flaw from the traversal above. Two of its examples check out on 3.9.4: pathsec.open(“/etc/passwd”) opened the file and emitted a warning instead of raising, and validate_network_url(“http://169.254.169.254/…”) returned without raising and warned about the link-local address. On 3.10.0 both raise PermissionError.
One detail in that advisory’s code sample does not match the released wheels. It shows ENFORCE read from NLTK_PATHSEC_ENFORCE, but in the shipped 3.9.4 wheel ENFORCE is a module-level literal at pathsec.py:24, and that variable name appears nowhere in the file. Enabling enforcement on 3.9.4 means editing the constant or patching it at runtime, not setting an environment variable. 3.10.0 and 3.10.3 flip the constant to True and leave the old line commented out.
The advisory also names pickle-deserialization protections. Those were not exercised here.
Adjacent findings
Two notes from the same lab, neither part of CVE-2026-54293.
retrieve() writes without a pathsec check in 3.9.4. In the 3.9.4 source, retrieve() writes the download with a plain open(filename, “wb”) (data.py line 769). In 3.10.0 the same call goes through _secure_open (line 913), so it is subject to the pathsec checks. A scripted test (retrieve() with a file: source inside the data root and a caller-supplied filename outside it) wrote the file on 3.9.4 with no warning at all. On 3.10.0 the same call raised PermissionError naming the write target, and created nothing. It is a separate gap, not the decode-after-check issue above and not the ENFORCE = False default: in 3.9.4 the write never reaches pathsec, so enabling enforcement would not have stopped it. The 3.10.0 release notes list a dedicated fix that routes the write through the pathsec sandbox (PR #3659, which they tie to CVE-2026-12871).
Windows was never tested. A simulation using the standard library’s own Windows path converter suggests 3.9.4 would also traverse there via %5c, since a backslash is a separator on Windows. The 3.10.0 column is a model of the new check (one unquote(), then the same regex), not a run of 3.10.0 code. That is reasoning from the source and the stdlib, not an observation on a Windows host.
12. Mitigation
Checking whether you are affected

What matters is not the version alone but whether an untrusted string reaches nltk.data.load() or nltk.data.find(). A project pinning 3.9.4 that only ever passes literal, developer-chosen resource names is not exposed to this traversal, though it still carries the warn-only default from CVE-2026-62388.
Remediation
Upgrade to nltk >= 3.10.0; prefer the latest 3.10.x, since the standalone pathsec check shows 3.10.3 no longer treats the temporary directory as an allowed root while 3.10.0 does. That release line contains both fixes: the decode-then-check step that stops the traversal, and the switch of nltk.pathsec.ENFORCE to True that makes the surrounding checks enforce rather than warn. The second is CVE-2026-62388 and it covers more than path traversal, since the same flag gates the module’s other checks. Applications that pin an older release should treat any RuntimeWarning from nltk.pathsec as a failure and handle it, because in 3.9.4 that warning is emitted immediately before the read succeeds.
13. Conclusion
CVE-2026-54293 is a decode-after-check flaw: NLTK 3.9.4 and earlier checked a resource name for traversal forms such as ../ before url2pathname() percent-decoded it, so ..%2f passed the check and became a traversal on the way to the filesystem. nltk.pathsec noticed in the lab layout but only warned, because ENFORCE defaulted to False. In the lab, 3.9.4 returned a file from outside the data root for each of the seven tested spellings that decoded to ../, and 3.10.0 refused all seven. The demonstrated impact is a file read in a lab layout, and exposure depends on whether an application passes untrusted names to nltk.data.load() or nltk.data.find(). Upgrade to 3.10.0 or later, preferably the latest 3.10.x.

