MLflow's Unpatched Webhook Is a Cloud Credential Stealer: CVE-2026-64849 in Hours

CVE-2026-64849 makes it possible against any MLflow tracking server that anyone can reach: a single unauthenticated HTTP request makes the server fetch the metadata endpoint for you and return the body.

Share

Executive summary

169.254.169.254 is the cloud metadata service. Ask it for the right path and it hands back the instance's IAM credentials, its API tokens, its identity. You are not supposed to be able to ask it from the internet. CVE-2026-64849 makes it possible against any MLflow tracking server that anyone can reach: a single unauthenticated HTTP request makes the server fetch the metadata endpoint for you and return the body.

MLflow is the experiment-tracking backend that a large share of self-hosted ML and AI stacks quietly run, and its default is an unauthenticated server on port 5000 or 5001 with a local SQLite database. That default is exactly what this bug consumes. NVD scores it 9.3 critical, CISA added it to the KEV catalog on 2026-08-19 with a due date of 2026-09-02, and watchTowr saw attackers scanning exposed instances within hours of the CVE being assigned on 2026-08-17.

The fix shipped on 2026-07-31 in MLflow 3.15.0, listed in the release notes as an ordinary bug fix with zero security keywords, and there is no 3.14.x backport. If you run MLflow below 3.15.0 on a cloud VM, treat the instance's cloud identity as compromised until you say otherwise. If you run it in a homelab, the same request still reaches your Docker daemon and your internal admin panels. Check your logs first, then patch. Both take minutes.

How the bug works: a webhook that answers for you

MLflow's model registry has a webhook feature: register a URL, and MLflow POSTs to it when events happen. The part that matters here is the test endpoint, POST /api/2.0/mlflow/webhooks/{id}/test. Fire it and the server delivers a test webhook to the registered URL synchronously, then hands you the result: the upstream HTTP status code and the full response body.

A reflection endpoint is an SSRF amplifier. Normally SSRF is blind: you make a server talk to an internal address and guess at the response through timing. Here the server reads the internal response and sends it straight back to you. The advisory proof-of-concept on 3.13.0 used it to read a planted secret file off a local service. Nothing in the chain requires authentication, because the default mlflow server has no authentication.

MLflow is not new to webhook SSRF. CVE-2026-2393, published in May 2026, let an authenticated attacker register a webhook pointing anywhere, and the answer was a guard added in 3.10.0 that validates webhook URLs at creation time: HTTPS only by default, and the hostname must resolve to a public IP. Loopback, private ranges, and link-local, including 169.254.169.254, are rejected.

The guard resolves the IP, checks it, and throws the answer away. The delivery code that actually opens the connection re-resolves the name independently and follows HTTP redirects with no re-check of where the redirect points. Two bypasses follow from that one design gap.

The redirect-follow variant is the one being exploited. The attacker points the webhook at their own public HTTPS host, which passes the guard. The test call goes out, the host answers with 302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/, and the server follows it, fetching the instance role name. One more test call against the role path returns the temporary credentials. No DNS tricks, no race condition. The advisory calls it reliable.

There is also a blind-write variant: a 307 or 308 redirect preserves the POST method and body, so an attacker can push payloads into internal endpoints that act on POST. The advisory lists a Docker daemon /stop, an Elasticsearch /_close, and a Spring Boot Actuator /shutdown. I have seen no in-the-wild use of it; it is capability, not observed activity. Yet for a homelab operator it is the one that stings, because your Docker API is exactly that kind of endpoint.

The DNS rebinding variant needs no redirect server. The attacker controls a domain that answers the validation-time DNS lookup with a public IP, then the connect-time lookup with a link-local one. The guard passes the public answer; the connection lands on 169.254.169.254 anyway. It is a check-then-use race, and it is the variant the fix is named after.

The credential theft chain

The payload differs per cloud, but the shape is always the same: make the server GET the metadata service, and read what comes back.

  • AWS: http://169.254.169.254/latest/meta-data/iam/security-credentials/ returns the instance profile's role name; append the role path and the temporary IAM credentials come back. This is the target named in the proof-of-concept and in the exploitation reports, and it is the one to assume against.
  • GCP: http://metadata.google.internal/ serves service account tokens and keys.
  • Azure: http://169.254.169.254/metadata/identity/oauth2/token (with the standard Metadata: true header) serves managed-identity tokens.
  • Internal services: 127.0.0.1 and RFC1918 targets give the same read against your admin panels and management APIs.

Once the credentials are in the attacker's hand, the rest is standard cloud abuse. watchTowr's telemetry describes the first half: indiscriminate scanning of exposed instances within hours of the CVE assignment, targeting cloud-hosted systems for credential extraction. Nobody has named a threat actor, and no victim has been publicly detailed. Rescana's report describes confirmed incidents in which the stolen credentials were used to deploy cryptominers and establish persistence via new IAM users or roles; I am repeating that as its claim, because nothing else corroborates it. What is not in dispute: the scanning started on schedule, and the due date is two weeks out.

The patch situation: fixed quietly, then exploited

The timeline matters, because it explains why so many people are still exposed. The fix, PR #24258, merged on 2026-07-02 and shipped in 3.15.0 on 2026-07-31. The release notes read [Model Registry] Fix DNS-rebinding SSRF bypass in webhook delivery, no security section, no CVE reference anywhere in the changelog. The public writeup of the bypass had been sitting in GitHub issue #24179 since 2026-06-26. Then, on 2026-08-17, the CVE was assigned and the advisory published with the proof-of-concept. The exploitation wave followed the disclosure, not the patch.

Two practical consequences. First, anyone on 3.14.0 cannot patch in place. A 3.14.1 backport was labeled on the fix PR and never cut; 3.14.1 does not exist on GitHub or PyPI. The first fixed release is 3.15.0, a minor version bump you have to test. Second, 3.15.0 also carried two same-day authorization CVEs, CVE-2026-69146 and CVE-2026-69148, so the upgrade closes three CVEs at once.

The fix closes both variants with one mechanism: a new HTTP adapter validates the peer IP of the actual connected socket immediately after connect(), before the TLS handshake. Every redirect hop opens a new connection through that adapter, so redirect targets are checked too, and a rebinding attacker has no second DNS resolution to exploit. A MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS=true escape hatch disables the check for development; treat it as development-only.

Detection: the log line and the IAM audit

The tracking server log is the primary forensic artifact. Successful exploitation is two or more webhook test calls, and the reflected response body will contain the metadata content. On the host running the server:

# webhook test calls, all of them
grep -n "webhooks" /var/log/mlflow/server.log | grep "test"

# the giveaway: internal and metadata URLs in webhook responses
grep -nE "169\.254\.169\.254|metadata\.google\.internal|127\.0\.0\.1|10\.[0-9]+\.[0-9]+\.[0-9]+" /var/log/mlflow/server.log

Adjust the path to wherever your tracking server writes. If the server sits behind a reverse proxy, its access log is an additional signal: an anonymous POST to any webhooks path that you did not generate.

The IAM side is where the damage shows up. In the cloud console or with aws iam and CloudTrail, look for IAM users and roles created around the exposure window that nobody on your team remembers, and for API calls under the instance profile's role that you do not recognize. A cloud bill with a new compute line item is also a tell, and it does not require log access. If you find hits, rotate the instance profile credentials and everything derived from them, and assume the attacker held the instance's identity from the exposure until the patch.

A quick exposure check for the unauthenticated half, run from outside the server:

curl -s -o /dev/null -w "%{http_code}\n" http://TARGET:5000/api/2.0/mlflow/webhooks
# a 401 means auth is in front; a 200/4xx JSON error means anyone can talk to the webhook API

And the version check that decides everything: mlflow version. Anything below 3.15.0 is vulnerable.

Remediation

  1. Upgrade to 3.15.0 or later. This is the only fix. If you are on 3.14.0 there is no 3.14.x patch to grab; plan a minor-version bump and test it before you point production at it.
  2. Verify. mlflow version should report 3.15.0 or newer. Confirm the API is no longer anonymously reachable with the curl check above.
  3. Enable authentication on the tracking server. The unauthenticated default is what made this an unauthenticated exploit. MLflow ships an authentication plugin (Basic auth or OIDC), and putting the server behind a reverse proxy that requires SSO and restricts source addresses to your lab segment achieves the same thing. The webhook API should not be reachable from the internet, full stop.
  4. Network controls. If the tracking server only serves your own machines, keep port 5000/5001 off the public internet and the guest Wi-Fi. On cloud instances, the security group should not hand 5000 to 0.0.0.0/0.
  5. Private-IP webhooks only deliberately. If you need webhooks pointed at internal targets, MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS does that on 3.15.0+, but set it only where you mean it, and never on a server anyone else can reach.

What this means for a homelab

Your MLflow box on the home network will not hand out cloud credentials, because it does not have any. The exposure is the same class, just different targets: this is the experiment-tracking layer of a self-hosted stack, the component most operators stand up without a second thought. The blind-write primitive reaches your Docker daemon, your Elasticsearch, and your reverse proxy admin if the port is open, and the full-read primitive reads your internal admin panels back to the attacker.

The pattern is the one we keep seeing: exposure plus a public CVE equals exploitation within hours, and the detection is the same every time. Look for the test calls in the logs, audit the identity layer, then close the exposure. For the inference layer, see Ollama on the Open Internet: 300,000 Exposed AI Inference Servers Under Active Attack for the scanner behavior, Local LLM Security Hardening: Threat Model for Self-Hosted AI Infrastructure for the standing checklist, and Detection Guidance: Auditing Your Homelab for Exposed MCP Servers for the same audit applied to your MCP endpoints.

Sources

  • GHSA-7gwp-5pfp-969j (GitHub Advisory Database, published 2026-08-17): mechanism, both bypass variants, proof-of-concept
  • NVD CVE-2026-64849: CVSS 3.1 base 9.3, vector AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N, CWE-918, SSVC active/automatable
  • CISA Known Exploited Vulnerabilities catalog, entry added 2026-08-19, due 2026-09-02
  • MLflow PR #24258 and v3.15.0 release notes (2026-07-31): fix mechanism, release flagging
  • watchTowr via The Hacker News, 2026-08-18: in-the-wild scanning telemetry
  • Rescana, 2026-08-20: exploitation report (cryptominer/IAM persistence claims attributed, not independently corroborated)
  • IONIX Threat Center and SecureLayer7 writeups: corroborating technical detail
Topics: