The Guard Ran, Then the Request Left: How CVE-2026-64849 Turns MLflow's /test Webhook Into an Unauthenticated Read of 169.254.169.254
We at BlueTeamAutomation spent most of Wednesday afternoon working through every writeup of the MLflow SSRF we could find, and the thing that kept pulling us back is not the class of the bug. Server-side request forgery in a webhook validator is well-trodden ground. What made CVE-2026-64849 stop the scroll is the shape of the endpoint doing the fetch. POST /api/2.0/mlflow/webhooks/{id}/test reflects the upstream response body straight back to whoever called it, and on a default install nobody has to be authenticated to call it. That is not a blind SSRF you have to smuggle data out of; it is a fully narrated read primitive with a JSON API in front.
Someone on your data science team stood up an MLflow tracking server nine months ago so nobody had to email pickle files around. This week, a scanner sent one POST at that server and pulled its AWS IAM instance credentials out of the metadata service, and nothing in your SOC's queries knows the request was interesting.
The endpoint that was built to help you debug
MLflow's model registry supports webhooks, callbacks that fire when a registered model gets created, transitioned between stages, or tagged. To keep those webhooks tolerable to configure, the tracking server exposes POST /api/2.0/mlflow/webhooks/{id}/test. You register a webhook URL, hit the test endpoint with a JSON body that looks like {"webhook_id":"...","event":{"entity":"REGISTERED_MODEL","action":"CREATED"}}, and the server fires a real HTTP request at your URL, then hands you back the status code and the response body so you can debug your integration. On a stock mlflow server launch the endpoint is unauthenticated, the backend is SQLite, and the model-registry API is reachable from anywhere the tracking server is reachable, which is usually more of the internet than anyone thinks.
Where the guard lives and where the fetch actually happens
MLflow's authors are not naive about SSRF. The library ships _validate_webhook_url() in mlflow/utils/validation.py, and it rejects private, loopback, and link-local addresses. A naive POST /api/2.0/mlflow/webhooks/create that names http://127.0.0.1:6379/ or http://169.254.169.254/ is refused before the row lands in SQLite. Until August that guard looked like it did its job.
The failure is not in the guard, it is in the fact that the guard runs in one file and the network call runs in another, and the resolved IP is never carried across. Inside mlflow/webhooks/delivery.py the delivery function builds a plain HTTPAdapter(max_retries=retry_strategy) and calls session.post(webhook.url) with no allow_redirects=False, which means the underlying requests library will happily follow a 302 Location: header wherever it points. There is a quieter TOCTOU flaw on the same code path: because getaddrinfo() is called once at validation and again at the actual connection, a hostname that resolved to a public IP one second ago can resolve to 169.254.169.254 on the request the server actually makes. DNS rebinding gives you the same primitive without needing the redirect at all.
Following a 302 into the metadata service
The whole exploit is four steps and one HTTP header. An attacker stands up https://harmless.example/webhook, which resolves to a real public IP and satisfies _validate_webhook_url(). They register that URL through the model-registry API and get a webhook ID back. Next, they send POST /api/2.0/mlflow/webhooks/{id}/test with the standard test payload. The tracking server fetches https://harmless.example/webhook, which responds with HTTP/1.1 302 Found and Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/. The requests session follows the redirect, IMDSv1 answers with the current IAM role's temporary credentials, and the tracking server dutifully echoes that JSON back to the caller as response_body. On GCP the target is metadata.google.internal; on Azure it is 169.254.169.254/metadata/identity/oauth2/token with the required header, which the attacker also gets to control. watchTowr's Attacker Eye honeypots caught this pattern in the wild within hours of the CVE getting a number on August 17, and CISA added the flaw to its Known Exploited Vulnerabilities catalog two days later with a fourteen-day remediation clock for federal agencies under BOD 26-04.
The 307 variant that nobody is talking about
The 302 path is the read primitive, but the same bug supports blind writes. If the attacker's redirect returns 307 or 308, the requests library preserves the original POST method and body, so the tracking server ends up POSTing that JSON test payload to whatever internal endpoint the attacker chose to name. On a network where the tracking server sits next to a Docker daemon at http://127.0.0.1:2375/containers/create, an unauthenticated Elasticsearch on 127.0.0.1:9200/_snapshot/, or a Spring Boot actuator at 127.0.0.1:8080/actuator/env, that is the difference between reading credentials and running containers. The blind-write path does not reflect a body back, but the attacker already got the map of your internal services from the read path.
Why your cloud posture tooling never fired
This bug slips past a normal blue team not because it is stealthy, but because every packet in the chain looks legitimate to every product involved. To the CNAPP watching cloud posture, the outbound 169.254.169.254 request came from the EC2 instance itself, which is exactly what the metadata service expects. On the host, EDR sees mlflow server making an outbound HTTPS call, same as it did a thousand times yesterday when it delivered real webhooks. At the edge, the WAF sees an inbound POST carrying well-formed JSON to a documented API path. GuardDuty may eventually fire UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.InsideAWS when the stolen credentials get used from an attacker's own IP later, but by then the attacker has already assumed the role and is doing whatever the role can do. The primitive that failed you is a valid API on a valid service making a valid call, so the compensating control has to look at intent instead of shape.
What to hunt on the host and in the VPC
The observable footprint is thin but real, and it is enough to build a hunt around today. On the host, the mlflow process opening a TCP connection to 169.254.169.254:80, metadata.google.internal:80, or any RFC1918 destination it does not normally speak to is worth alerting on; EDR audit rules or auditd connect() filters keyed on the MLflow uid catch it cleanly. In VPC flow logs, look for the tracking server's ENI generating flows to 169.254.169.254 outside its own boot window, and for link-local destinations that are not DHCP or NTP. In application logs, POST /api/2.0/mlflow/webhooks/*/test from a source address that is not your developer estate, or a burst of webhooks/create followed within seconds by a webhooks/*/test against the same ID, is the two-line IOC. Map the activity to MITRE T1552.005 Unsecured Credentials: Cloud Instance Metadata API, preceded by T1190 Exploit Public-Facing Application, and pivot from there.
The upgrade, the credential rotation, and the two-minute proof
Patch to MLflow 3.15.0 or later, which introduces SSRFProtectedHTTPAdapter in a new mlflow/webhooks/ssrf.py. That adapter installs custom urllib3 connection classes that call sock.getpeername() immediately after connect() and reject any non-public IP before the TLS handshake, so every redirect target and every DNS-rebound name gets re-validated at the socket rather than at the URL. If you cannot patch today, put the tracking server behind auth (a basic-auth reverse proxy or one of MLflow's auth plugins) and pull any public ingress in front of it, because the entire exploit assumes an unauthenticated /test. Regardless of when you patch, rotate the IAM role's temporary credentials, any GCP service account keys attached to the workload, and any Azure managed identity tokens the server could have reached, because a scanner that hit you on August 17 already has them.
To prove the upgrade actually took, run a short local test: start MLflow 3.15.0, create a webhook against an attacker-controlled endpoint that 302s to http://169.254.169.254, and confirm the /test response comes back with SSRFProtectionError instead of the metadata service's greeting. That single check is what separates a patched service from an assumption based on a build number.
This is the loop BlueTeamAutomation automates end to end: BASzy fires safe emulations of the redirect and DNS-rebinding primitives against your MLflow instances, correlates the outbound fetch attempts with your EDR and VPC-flow telemetry, and hands the result to your SOAR as proof the patch held or as an open ticket if it did not. Detection and the proof it works stop being a one-off scramble and become continuous.
Turn one-off patch verification into a standing control
BASzy exercises the exact primitives behind bugs like CVE-2026-64849 against your fleet and proves in your own telemetry whether your defenses caught it.
Talk to BTA →