CVE-2025-15379
Description
A command injection vulnerability exists in MLflow's model serving container initialization code, specifically in the _install_model_dependencies_to_env() function. When deploying a model with env_manager=LOCAL, MLflow reads dependency specifications from the model artifact's python_env.yaml file and directly interpolates them into a shell command without sanitization. This allows an attacker to supply a malicious model artifact and achieve arbitrary command execution on systems that deploy the model. The vulnerability affects versions 3.8.0 and is fixed in version 3.8.2.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mlflowPyPI | < 3.8.1 | 3.8.1 |
Affected products
1Patches
2a22ce7157f64fix(security): prevent command injection via malicious model artifacts (#19583)
2 files changed · +244 −4
mlflow/models/container/__init__.py+11 −4 modified@@ -8,6 +8,7 @@ import logging import multiprocessing import os +import shlex import shutil import signal import sys @@ -138,12 +139,18 @@ def _install_model_dependencies_to_env(model_path, env_manager) -> list[str]: env_conf = conf[mlflow.pyfunc.ENV] if env_manager == em.LOCAL: - # Install pip dependencies directly into the local environment python_env_config_path = os.path.join(model_path, env_conf[em.VIRTUALENV]) python_env = _PythonEnv.from_yaml(python_env_config_path) - deps = " ".join(python_env.build_dependencies + python_env.dependencies) - deps = deps.replace("requirements.txt", os.path.join(model_path, "requirements.txt")) - if Popen(["bash", "-c", f"python -m pip install {deps}"]).wait() != 0: + + pip_args = [sys.executable, "-m", "pip", "install"] + for dep in python_env.build_dependencies + python_env.dependencies: + dep_args = shlex.split(dep) + for i, arg in enumerate(dep_args): + if arg == "requirements.txt" or arg.endswith("/requirements.txt"): + dep_args[i] = os.path.join(model_path, "requirements.txt") + pip_args.extend(dep_args) + + if Popen(pip_args).wait() != 0: raise Exception("Failed to install model dependencies.") return []
tests/models/test_container.py+233 −0 added@@ -0,0 +1,233 @@ +""" +Tests for mlflow.models.container module. + +Includes security tests for command injection prevention. +""" + +import os +from unittest import mock + +import pytest +import yaml + +from mlflow.models.container import _install_model_dependencies_to_env +from mlflow.utils import env_manager as em + + +def _create_model_artifact(model_path, dependencies, build_dependencies=None): + """Helper to create a minimal model artifact for testing.""" + with open(os.path.join(model_path, "MLmodel"), "w") as f: + yaml.dump( + { + "flavors": { + "python_function": { + "env": {"virtualenv": "python_env.yaml"}, + "loader_module": "mlflow.pyfunc.model", + } + } + }, + f, + ) + + with open(os.path.join(model_path, "requirements.txt"), "w") as f: + f.write("") + + with open(os.path.join(model_path, "python_env.yaml"), "w") as f: + yaml.dump( + { + "python": "3.12", + "build_dependencies": build_dependencies or [], + "dependencies": dependencies, + }, + f, + ) + + +def test_command_injection_via_semicolon_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["numpy; echo INJECTED > /tmp/test_injection_semicolon.txt; #"], + ) + + evidence_file = "/tmp/test_injection_semicolon.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via semicolon succeeded!" + + +def test_command_injection_via_pipe_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["numpy | echo INJECTED > /tmp/test_injection_pipe.txt"], + ) + + evidence_file = "/tmp/test_injection_pipe.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via pipe succeeded!" + + +def test_command_injection_via_backticks_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["`echo INJECTED > /tmp/test_injection_backtick.txt`"], + ) + + evidence_file = "/tmp/test_injection_backtick.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via backticks succeeded!" + + +def test_command_injection_via_dollar_parens_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["$(echo INJECTED > /tmp/test_injection_dollar.txt)"], + ) + + evidence_file = "/tmp/test_injection_dollar.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via $() succeeded!" + + +def test_command_injection_via_ampersand_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["numpy && echo INJECTED > /tmp/test_injection_ampersand.txt"], + ) + + evidence_file = "/tmp/test_injection_ampersand.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via && succeeded!" + + +def test_legitimate_package_install(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["pip"], + build_dependencies=[], + ) + + result = _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + assert result == [] + + +def test_requirements_file_reference(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["-r requirements.txt"], + build_dependencies=["pip"], + ) + + with open(os.path.join(model_path, "requirements.txt"), "w") as f: + f.write("# empty requirements\n") + + result = _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + assert result == [] + + +def test_requirements_path_replacement(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["-r requirements.txt"], + ) + + with open(os.path.join(model_path, "requirements.txt"), "w") as f: + f.write("six\n") + + with mock.patch("mlflow.models.container.Popen") as mock_popen: + mock_popen.return_value.wait.return_value = 0 + + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + call_args = mock_popen.call_args[0][0] + assert isinstance(call_args, list), "Should use list args, not shell string" + + assert "-r" in call_args + req_index = call_args.index("-r") + req_path = call_args[req_index + 1] + assert req_path == os.path.join(model_path, "requirements.txt") + + +def test_no_shell_execution(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["pip"], + ) + + with mock.patch("mlflow.models.container.Popen") as mock_popen: + mock_popen.return_value.wait.return_value = 0 + + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + call_args = mock_popen.call_args + assert isinstance(call_args[0][0], list) + assert call_args[1].get("shell") is not True + + +def test_build_dependencies_processed(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["pip"], + build_dependencies=["setuptools", "wheel"], + ) + + with mock.patch("mlflow.models.container.Popen") as mock_popen: + mock_popen.return_value.wait.return_value = 0 + + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + call_args = mock_popen.call_args[0][0] + assert "setuptools" in call_args + assert "wheel" in call_args + assert "pip" in call_args + + +def test_package_name_with_requirements_substring_not_modified(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["my-requirements.txt-parser", "requirements.txt-tools"], + ) + + with mock.patch("mlflow.models.container.Popen") as mock_popen: + mock_popen.return_value.wait.return_value = 0 + + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + call_args = mock_popen.call_args[0][0] + assert "my-requirements.txt-parser" in call_args + assert "requirements.txt-tools" in call_args + assert not any(model_path in arg for arg in call_args if "parser" in arg or "tools" in arg)
361b6f620adffix(security): prevent command injection via malicious model artifacts (#19583)
2 files changed · +244 −4
mlflow/models/container/__init__.py+11 −4 modified@@ -8,6 +8,7 @@ import logging import multiprocessing import os +import shlex import shutil import signal import sys @@ -138,12 +139,18 @@ def _install_model_dependencies_to_env(model_path, env_manager) -> list[str]: env_conf = conf[mlflow.pyfunc.ENV] if env_manager == em.LOCAL: - # Install pip dependencies directly into the local environment python_env_config_path = os.path.join(model_path, env_conf[em.VIRTUALENV]) python_env = _PythonEnv.from_yaml(python_env_config_path) - deps = " ".join(python_env.build_dependencies + python_env.dependencies) - deps = deps.replace("requirements.txt", os.path.join(model_path, "requirements.txt")) - if Popen(["bash", "-c", f"python -m pip install {deps}"]).wait() != 0: + + pip_args = [sys.executable, "-m", "pip", "install"] + for dep in python_env.build_dependencies + python_env.dependencies: + dep_args = shlex.split(dep) + for i, arg in enumerate(dep_args): + if arg == "requirements.txt" or arg.endswith("/requirements.txt"): + dep_args[i] = os.path.join(model_path, "requirements.txt") + pip_args.extend(dep_args) + + if Popen(pip_args).wait() != 0: raise Exception("Failed to install model dependencies.") return []
tests/models/test_container.py+233 −0 added@@ -0,0 +1,233 @@ +""" +Tests for mlflow.models.container module. + +Includes security tests for command injection prevention. +""" + +import os +from unittest import mock + +import pytest +import yaml + +from mlflow.models.container import _install_model_dependencies_to_env +from mlflow.utils import env_manager as em + + +def _create_model_artifact(model_path, dependencies, build_dependencies=None): + """Helper to create a minimal model artifact for testing.""" + with open(os.path.join(model_path, "MLmodel"), "w") as f: + yaml.dump( + { + "flavors": { + "python_function": { + "env": {"virtualenv": "python_env.yaml"}, + "loader_module": "mlflow.pyfunc.model", + } + } + }, + f, + ) + + with open(os.path.join(model_path, "requirements.txt"), "w") as f: + f.write("") + + with open(os.path.join(model_path, "python_env.yaml"), "w") as f: + yaml.dump( + { + "python": "3.12", + "build_dependencies": build_dependencies or [], + "dependencies": dependencies, + }, + f, + ) + + +def test_command_injection_via_semicolon_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["numpy; echo INJECTED > /tmp/test_injection_semicolon.txt; #"], + ) + + evidence_file = "/tmp/test_injection_semicolon.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via semicolon succeeded!" + + +def test_command_injection_via_pipe_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["numpy | echo INJECTED > /tmp/test_injection_pipe.txt"], + ) + + evidence_file = "/tmp/test_injection_pipe.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via pipe succeeded!" + + +def test_command_injection_via_backticks_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["`echo INJECTED > /tmp/test_injection_backtick.txt`"], + ) + + evidence_file = "/tmp/test_injection_backtick.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via backticks succeeded!" + + +def test_command_injection_via_dollar_parens_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["$(echo INJECTED > /tmp/test_injection_dollar.txt)"], + ) + + evidence_file = "/tmp/test_injection_dollar.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via $() succeeded!" + + +def test_command_injection_via_ampersand_blocked(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["numpy && echo INJECTED > /tmp/test_injection_ampersand.txt"], + ) + + evidence_file = "/tmp/test_injection_ampersand.txt" + if os.path.exists(evidence_file): + os.remove(evidence_file) + + with pytest.raises(Exception, match="Failed to install model dependencies"): + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + assert not os.path.exists(evidence_file), "Command injection via && succeeded!" + + +def test_legitimate_package_install(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["pip"], + build_dependencies=[], + ) + + result = _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + assert result == [] + + +def test_requirements_file_reference(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["-r requirements.txt"], + build_dependencies=["pip"], + ) + + with open(os.path.join(model_path, "requirements.txt"), "w") as f: + f.write("# empty requirements\n") + + result = _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + assert result == [] + + +def test_requirements_path_replacement(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["-r requirements.txt"], + ) + + with open(os.path.join(model_path, "requirements.txt"), "w") as f: + f.write("six\n") + + with mock.patch("mlflow.models.container.Popen") as mock_popen: + mock_popen.return_value.wait.return_value = 0 + + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + call_args = mock_popen.call_args[0][0] + assert isinstance(call_args, list), "Should use list args, not shell string" + + assert "-r" in call_args + req_index = call_args.index("-r") + req_path = call_args[req_index + 1] + assert req_path == os.path.join(model_path, "requirements.txt") + + +def test_no_shell_execution(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["pip"], + ) + + with mock.patch("mlflow.models.container.Popen") as mock_popen: + mock_popen.return_value.wait.return_value = 0 + + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + call_args = mock_popen.call_args + assert isinstance(call_args[0][0], list) + assert call_args[1].get("shell") is not True + + +def test_build_dependencies_processed(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["pip"], + build_dependencies=["setuptools", "wheel"], + ) + + with mock.patch("mlflow.models.container.Popen") as mock_popen: + mock_popen.return_value.wait.return_value = 0 + + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + call_args = mock_popen.call_args[0][0] + assert "setuptools" in call_args + assert "wheel" in call_args + assert "pip" in call_args + + +def test_package_name_with_requirements_substring_not_modified(tmp_path): + model_path = str(tmp_path) + _create_model_artifact( + model_path, + dependencies=["my-requirements.txt-parser", "requirements.txt-tools"], + ) + + with mock.patch("mlflow.models.container.Popen") as mock_popen: + mock_popen.return_value.wait.return_value = 0 + + _install_model_dependencies_to_env(model_path, env_manager=em.LOCAL) + + call_args = mock_popen.call_args[0][0] + assert "my-requirements.txt-parser" in call_args + assert "requirements.txt-tools" in call_args + assert not any(model_path in arg for arg in call_args if "parser" in arg or "tools" in arg)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/mlflow/mlflow/commit/361b6f620adf98385c6721e384fb5ef9a30bb05envdPatchWEB
- huntr.com/bounties/dc9c1c20-7879-4050-87df-4d095fe5ca75nvdExploitThird Party AdvisoryWEB
- github.com/advisories/GHSA-r23q-823p-vmf7ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-15379ghsaADVISORY
- github.com/mlflow/mlflow/commit/a22ce7157f646bdce4c95106fc38ccc9ca289205ghsaWEB
News mentions
0No linked articles in our index yet.