FlawOpen

flawopen.com/AI Security/mlflow-webhook-ssrf-cve-2026-64849

● CVE-2026-64849 · CVSS 8.6 · 高
セキュリティ研究 · FlawOpen

CVE-2026-64849:MLflow WebhookのSSRF脆弱性

CVE-2026-64849は、MLflowのWebhook配信における未認証のサーバーサイドリクエストフォージェリ(SSRF)です (High、CVSS 3.1:8.6)。最初のURL検証ではリダイレクト先や実際の接続先IPが制限されず、テスト用endpointが上流レスポンス本文を呼び出し元へ返す可能性がありました。[1]

💡 わかりやすい解説 (ELI5)

受付担当者が荷物の住所を確認した後、転送メモに書かれた新しい住所を確認せず配達員に進ませる状況を想像してください。MLflowは接続前にWebhook URLを確認しましたが、リダイレクトや再度のDNS検索で別の宛先へ接続する可能性がありました。修正ではHTTPデータを送る前に実際の接続先peerを検査します。[1], [2]

主要な概念と専門用語

SSRF
呼び出し元が選んだネットワーク要求をサーバーが実行し、サーバーのネットワーク信頼境界を越える問題。[1]
Webhookテストendpoint
設定済みWebhookへテストイベントを送るMLflow API。影響を受けるデフォルト構成では上流レスポンス本文を返した。[1]
DNS rebinding / TOCTOU
検証時には公開IPへ解決されるホスト名が、接続時には別の非公開IPへ解決される状態。[1], [2]

フレームワーク対応

ステップ・バイ・ステップの攻撃フロー

ステップ 1

到達可能なMLflowサーバーがWebhookテストを受け付ける

アドバイザリはデフォルトの未認証Tracking Serverを説明しています。認証プラグインや配置制御で到達性は変わるため、実構成を確認します。 CISAは2026年8月19日にこのCVEをKnown Exploited Vulnerabilitiesカタログへ追加しました。既知の悪用を示しますが、個別の環境が侵害された証拠ではありません。[1], [4], [5]

ステップ 2

最初のURL検証を通過する

_validate_webhook_urlは元のホスト名を解決して検査しますが、確認したアドレスを破棄し、後続のsocketに結び付けません。[1]

ステップ 3

リダイレクトまたは再解決で別の宛先へ接続する

HTTPクライアントはリダイレクトを追跡し、接続時に名前を再解決できます。テストendpointは上流本文を返すため、応答を読めるSSRFになります。[1]

ステップ 4

上流修正は接続peerを検証する

PR #24258は接続直後、TLS/HTTPデータの前にsocket peerを検査し、環境proxyも無効化します。CVEはMLflow 3.15.0で修正され、別のIPv6移行回避策は3.16.0で修正されました。 アドバイザリでは、最初の非公開報告者としてfreeman-bb、独立した発見者としてAUTHENSORがクレジットされています。[1], [2], [3], [6]

ソースコード比較:脆弱 vs 堅牢化

コード断片はURL検証と実際の要求の境界、接続時のpeer検証を示します。悪用要求や内部宛先は含みません。3.16.0のIPv6正規化は後続の独立した強化です。[2]

脆弱な実装
# PR #24258 適用前の mlflow/webhooks/delivery.py の抜粋。
_validate_webhook_url(webhook.url)  # checks the initial URL only
return session.post(
    webhook.url, data=payload_bytes, headers=headers, timeout=timeout
)  # redirects follow by default; the validated IP is not pinned
堅牢化されたセキュアパッチ
# 接続時に接続先ピアを検証する upstream 修正の抜粋。
def _assert_public_peer(sock):
    if _MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS.get():
        return
    try:
        peer_ip = sock.getpeername()[0]
    except OSError as exc:
        sock.close()
        raise SSRFProtectionError(
            f"Could not determine webhook connection peer address: {exc}"
        ) from exc
    try:
        ip = ipaddress.ip_address(peer_ip)
    except ValueError as exc:
        sock.close()
        raise SSRFProtectionError(
            f"Webhook connection resolved to an invalid IP address: {peer_ip!r}"
        ) from exc
    if not ip.is_global:
        sock.close()
        raise SSRFProtectionError(
            f"Webhook connection blocked: {ip} is not a public IP address. "
            "This may indicate a DNS rebinding attempt."
        )


class _SSRFProtectedHTTPConnection(HTTPConnection):
    def _new_conn(self):
        sock = super()._new_conn()
        _assert_public_peer(sock)
        return sock


class _SSRFProtectedHTTPSConnection(HTTPSConnection):
    def _new_conn(self):
        sock = super()._new_conn()
        _assert_public_peer(sock)
        return sock

adapter = SSRFProtectedHTTPAdapter(max_retries=retry_strategy)
session = requests.Session()
session.trust_env = False
session.mount("http://", adapter)
session.mount("https://", adapter)

エンジニアリング&システム堅牢化チェックリスト

参考資料

← AI Security Directory →