検証したアドレスに繋いでいない — MLflow CVE-2026-64849 をバージョンから確かめる

SSRF の対策は、たいてい「宛先の URL を検証する」という形で書かれる。社内アドレスやリンクローカルを弾く関数を1つ用意して、外へ出る前に通す。それで守れているつもりになる。

守れているかどうかを決めるのは、検証した宛先と、実際に接続した宛先が同じかどうかである。この2つがずれる余地があれば、検証は通っていても守れていない。

MLflow の CVE-2026-64849 は、そのずれの教科書のような例だ。URL 検証の関数はちゃんと存在する。呼ばれてもいる。それでも守れていない。

影響範囲は 3.15.0 より前、修正版は 3.15.0 である。CVSS は 9.3 Critical、CISA は 2026年8月19日に KEV カタログへ収載し、是正期限を 2026年9月2日としている。

この記事では、なぜ検証が素通りするのかを仕組みから読み解き、最後に自分の環境が該当するかを判定する依存なしのスクリプトを置く。

何が起きるのか

MLflow の Tracking Server には webhook の機能がある。イベントが起きたとき、設定した URL へ HTTP を投げる。その webhook が正しく動くかを試すための、テスト用のエンドポイントがある。

NVD の記述はこうだ。

3.15.0 より前では、未認証の POST /api/2.0/mlflow/webhooks/{id}/testmlflow/utils/validation.py_validate_webhook_url() を元の URL に対してだけ呼ぶ一方、mlflow/webhooks/delivery.py はリダイレクトを追ってホスト名を再解決し、検証済みアドレスを固定しない。

3つのことが同時に起きている。

1つ目。このエンドポイントは未認証で叩ける。 既定構成の Tracking Server は、webhook のエンドポイントに認証を要求しない。つまりサーバに到達できること自体が、攻撃の前提のすべてになる。

2つ目。検証は「元の URL」に対してだけ行われる。 _validate_webhook_url() は渡された URL のホスト名を見る。攻撃者は自分が支配する、ごく普通の外向きドメインを渡す。検証は当然通る。

3つ目。配送はリダイレクトを追う。 ベンダーの GitHub Security Advisory は、配送側が allow_redirects=False を指定しておらず、検証済みの IP を固定していないと明記している。攻撃者のサーバが 302 を返し、Location:http://169.254.169.254/... を入れる。配送側はそれを追い、ホスト名を引き直して接続する。

検証したのは1つ目の URL である。接続したのは2つ目の URL である。この間に何のチェックも無い。

これは TOCTOU(検査時と使用時のずれ)そのものだ。時間差が「検証してから接続するまで」ではなく「検証してからリダイレクトを追い終わるまで」に伸びているだけで、構造は同じである。

なぜ「読める」のが致命的なのか

SSRF には、応答が返ってこないブラインド型と、応答が読める型がある。この2つは危険度が桁で違う。

このエンドポイントはテスト用なので、投げた結果を呼び出し元に返す。NVD の記述にある response_statusresponse_body がそれだ。つまり内部サービスに投げた結果が、そのまま攻撃者の手元に返ってくる。

だから狙われる先は決まっている。クラウドのインスタンスメタデータである。AWS なら 169.254.169.254 で、IMDSv1 が有効なら単純な GET で一時的な IAM 認証情報が読める。読めた認証情報は、そのサーバのロールが持つ権限をそのまま攻撃者に渡す。

アドバイザリはもう1つの経路も挙げている。307 と 308 はメソッドを保つので、POST が POST のまま内部へ届く。応答は読めないが、内部の管理エンドポイントに対する書き込みが成立し得る。読み取りだけを塞いでも終わらない、という意味でこちらのほうが厄介だ。

該当するかを判定する

判定そのものは単純で、MLflow のバージョンが 3.15.0 未満かどうかに尽きる。単純なのに間違えやすいのは、バージョン比較を文字列でやってしまうからだ。

1
"3.9.0" > "3.15.0"   // 文字列比較だと true。9 > 1 なので

3.9.03.15.0 より古いのに、文字列で比べると新しいと判定される。該当する環境を「対象外」と読み違える方向に間違うので、この壊れ方はいちばん質が悪い。数値として比べなければならない。

依存なしで書くと、こうなる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/env node
// mlflow-affected.js — MLflow が CVE-2026-64849 の影響範囲かを判定する
// 使い方: node mlflow-affected.js --version 3.14.2
// 終了コード: 1=該当 / 0=非該当 / 2=引数の誤り

const FIXED = [3, 15, 0]; // 3.15.0 で修正

/** "3.14.2" や "3.15.0rc1" を [major, minor, patch, isPreRelease] にする。 */
function parseVersion(raw) {
const s = String(raw).trim().replace(/^v/i, "");
const m = /^(\d+)\.(\d+)(?:\.(\d+))?(.*)$/.exec(s);
if (!m) return null;
const rest = m[4] || "";
// 数字のあとに何か続いていれば、それはプレリリース(rc / a / b / dev)とみなす
const isPre = rest !== "" && !/^\+/.test(rest);
return [Number(m[1]), Number(m[2]), Number(m[3] || 0), isPre];
}

/** a < b なら負、等しければ 0、a > b なら正。数値として比べる。 */
function cmp(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}

function main(argv) {
const i = argv.indexOf("--version");
if (i < 0 || !argv[i + 1]) {
process.stderr.write("--version <mlflowのバージョン> が必要です\n");
return 2;
}
const raw = argv[i + 1];
const v = parseVersion(raw);
if (!v) {
process.stderr.write(`バージョンを解釈できません: ${raw}\n`);
return 2;
}

const diff = cmp(v, FIXED);
// 3.15.0 のプレリリース(3.15.0rc1 など)は 3.15.0 未満として扱う。
// 判定できないものを「安全側」に倒すのではなく、修正が入った版だけを非該当にする。
const affected = diff < 0 || (diff === 0 && v[3]);

const notes = [];
if (v[3]) notes.push("プレリリース版として解釈しました(正式版より前として扱います)");
if (!affected) {
notes.push("バージョンのみの判定です。到達性や認証の設定はこの判定に含みません");
}

process.stdout.write(
JSON.stringify(
{
cve: "CVE-2026-64849",
input: raw,
parsed: `${v[0]}.${v[1]}.${v[2]}${v[3] ? "(pre)" : ""}`,
fixedIn: FIXED.join("."),
affected,
notes,
},
null,
2,
) + "\n",
);
return affected ? 1 : 0;
}

process.exit(main(process.argv.slice(2)));

実行するとこうなる。

1
2
3
4
5
6
7
8
9
10
11
$ node mlflow-affected.js --version 3.9.0
{
"cve": "CVE-2026-64849",
"input": "3.9.0",
"parsed": "3.9.0",
"fixedIn": "3.15.0",
"affected": true,
"notes": []
}
$ echo $?
1

終了コードは該当時が 1、非該当が 0、引数の誤りが 2 である。CI や構成管理から回す前提でこうしてある。

意図的にそうしている点が2つある。バージョン以外を判定材料にしていないのは、到達性や認証の設定まで見ようとすると環境ごとに答えが変わり、再現しなくなるためだ。見ていないことは notes に必ず出す。プレリリースを正式版より前として扱うのも同じ考え方で、3.15.0rc1 を「修正済み」と読むほうが危険な間違いになる。

手元のバージョンは pip show mlflow か、Python から mlflow.__version__ で取れる。コンテナで動かしているなら、イメージの中で確認すること。ホストに入っている mlflow は本番の Tracking Server とは別物であることが多い。

対処

基本は 3.15.0 以上への更新である。修正はプルリクエスト #24258 で入っており、検証したアドレスを接続時に固定するようになっている。

問題は、ベンダーのアドバイザリが回避策を提示していないことだ。更新できない期間は経路側で塞ぐしかない。優先度の高い順に3つ挙げる。

1. Tracking Server を直接触れる場所に置かない。 到達性そのものが前提条件なので、ここが一番効く。VPC 内部や社内網に閉じて、リバースプロキシや ID 認識型プロキシで認証を前置する。未認証で叩けるエンドポイントが問題なのだから、その手前に認証を1枚置くだけで攻撃面はほぼ消える。

2. サーバから外向きの通信を絞る。 webhook の配送はサーバが外へ HTTP を出す機能である。送信先を許可リストで限定できるならそれが最良で、できなくとも、リンクローカル(169.254.0.0/16)とループバック、社内の管理系セグメントへの到達をネットワーク側で落とす。アプリの中の URL 検証と違い、こちらはリダイレクトの先にも効く。

3. メタデータ側を固める。 AWS なら IMDSv2 を必須化する。HttpTokensrequired にすると、トークンを取るための PUT が先に必要になり、単純な GET の転送では読めなくなる。あわせて HttpPutResponseHopLimit を 1 にしておく。これは MLflow に限らず SSRF 全般に効く恒常的な設定で、この CVE の読み取り経路は IMDSv2 必須化だけでも成立しなくなる。

GCP と Azure のメタデータサーバは、もともと専用ヘッダ(Metadata-Flavor: Google / Metadata: true)を要求する。単純な GET の転送では読めないので、AWS ほど直接的ではない。それでも、リダイレクト経由でヘッダごと転送される構成になっていないかは確認しておきたい。

そしてもう1つ。更新前から公開されていたサーバは、既に認証情報が抜かれている前提で扱う。 KEV に収載されているということは、実際に悪用が確認されているということだ。更新に加えて、当該サーバに紐づくロールの一時認証情報を失効させ、そのロールで行われた API 呼び出しを監査ログで確認するところまでが対処になる。

まとめ

この欠陥で壊れているのは、検証の中身ではない。検証した対象と、使った対象が別物になっていたという、繋ぎ目のほうである。

同じ形は自分のコードにも起こり得る。URL を検証してから requestsfetch に渡すとき、リダイレクトを追う設定になっていないか。ホスト名を検証したあと、接続時にもう一度 DNS を引いていないか。この2つは、SSRF 対策を書いたつもりのコードで最も見落とされる。

実務としてやることは3つに絞られる。バージョンを確認して 3.15.0 以上へ上げる。上げるまでは到達性と外向き通信を経路側で塞ぐ。そして IMDSv2 必須化を、この機会に全台で確認しておく。

CVE の詳細と各環境(AWS / GCP / Azure / Linux)の対応状況は 脆弱性対応ウォッチ に整理してある。


検証したアドレスに繋いでいない — MLflow CVE-2026-64849 をバージョンから確かめる
https://blog.hashito.biz/2026/08/20/mlflow-cve-2026-64849-webhook-ssrf-redirect-imds/
著者
hashito
作成日
2026年8月20日
著作権