送信元ポートは秘密だったはずだ — Unbound CVE-2026-50252 をスレッド数から確かめる

DNS キャッシュポイズニングの防御は、突き詰めると当てにくい乱数を1つ増やすことでできている。攻撃者は本物の権威サーバより早く、偽の応答をリゾルバに信じさせたい。そのためには応答の中身を当てる必要があり、当てるべき値が増えれば成功率は下がる。

歴史的に、当てるべき値はまずトランザクションID(16ビット)だった。2008年のカミンスキー攻撃で16ビットでは足りないことが実証され、そこにUDPの送信元ポートが足された。リゾルバが外向きの問い合わせを出すとき、送信元ポートを毎回ランダムに選ぶ。攻撃者はトランザクションIDと送信元ポートの両方を当てなければならなくなり、必要な試行回数が跳ね上がった。

この防御が成り立つ前提は1つだけである。送信元ポートが秘密であること。

Unbound の CVE-2026-50252 は、その前提が崩れていたという報告だ。バッファオーバーフローでもメモリ破壊でもない。性能のために入れた負荷分散の仕組みが、秘密であるべき値を観測可能な目印に変えてしまっていた。

影響範囲は 1.4.22 から 1.25.1 まで(両端を含む)、修正版は 1.25.2 である。下限が 1.4.22 と古いので、長く動かしている社内リゾルバはほぼ全部が範囲に入ると考えたほうがよい。

この記事では、なぜ秘密が漏れるのかを仕組みから読み解き、エントロピーが具体的に何ビット減るのかを計算し、最後に自分の環境が該当するかを判定する依存なしのスクリプトを置く。

何が起きるのか

Unbound は複数のワーカースレッドで問い合わせを処理する。ここに so-reuseport という設定が関わる。

SO_REUSEPORT は Linux のソケットオプションで、複数のソケットが同じポートを待ち受けられるようにする。カーネルが届いたパケットをソケットのどれかに振り分けるので、スレッド間の取り合いが減って性能が上がる。Unbound では so-reuseport: yes が既定値である。つまり明示的に no にしていなければ有効になっている。

問題は、この振り分けと送信元ポートの選び方が噛み合ってしまうところにある。アドバイザリの記述を追うと、次の3段になっている。

  1. 起動時に、Unbound は利用可能なUDP送信元ポート空間を、ほぼ等分の「互いに素な」部分集合へ無作為に分割する。 そして各部分集合をワーカースレッド1つに割り当てる。互いに素、つまり部分集合どうしは重ならない
  2. 問い合わせが届くと、カーネルの SO_REUSEPORT による負荷分散が、その問い合わせを特定のスレッドのソケットへ決定論的に割り当てる。 決定論的、つまり同じ送信元IPと同じ送信元ポートから来た問い合わせは、毎回同じスレッドに行く。
  3. その問い合わせを解決する過程で外向きに出る問い合わせは、担当スレッドに割り当てられた部分集合の中からしか送信元ポートを選ばない。

この3つを並べると、何が漏れるかが見える。部分集合はスレッド間で重ならないのだから、権威サーバ側で観測できる外向きクエリの送信元ポートを見れば、どのスレッドが処理したかが分かる。ポートの値そのものが、スレッドの識別子として機能してしまう。

そして2番の「決定論的」が効いてくる。攻撃者は自分の送信元IPを固定したまま、送信元ポートを変えながら問い合わせを投げられる。それぞれの問い合わせがどのスレッドに行ったかは、外向きクエリのポートを観測すれば分かる。つまり攻撃者は「自分のどの送信元ポートが、どのワーカースレッドに対応するか」の対応表を作れる

対応表ができると何が起きるか。攻撃者は狙ったスレッドに問い合わせを投げられるようになる。そしてそのスレッドが使う送信元ポートは、全体ではなく割り当てられた部分集合の中だけである。当てるべきポートの候補が、全体の何分の1かに減る。

CWE の分類は CWE-349(信頼できないデータの受理) が当てられている。

エントロピーはどれだけ減るのか

「何分の1か」を具体的に出しておくと判断しやすい。分割が「ほぼ等分の互いに素な部分集合」なのだから、計算は素直である。

送信元ポートの総数を P、ワーカースレッド数を T とすると、1スレッドあたりのポート母数は P / T になる。エントロピーはビットで見るのが分かりやすい。

1
2
3
全体のエントロピー   = log2(P)
1スレッドあたり = log2(P / T) = log2(P) - log2(T)
減少ぶん = log2(T) ビット

減るのはちょうど log2(T) ビット、つまりスレッド数の2を底とする対数ぶんである。ここが要点で、失われる量はスレッド数だけで決まり、ポート総数には依存しない

Unbound の outgoing-range(外向きに使うポート数、既定 4096)と num-threads で当てはめると次のようになる。

num-threads outgoing-range 1スレッドあたりの母数 減少
1 4096 4096 0 ビット
4 4096 1024 2 ビット
8 8192 1024 3 ビット
16 8192 512 4 ビット

スレッド数を増やすほど減少幅が大きくなる。性能のためにスレッドを増やした構成ほど、この欠陥の影響が大きいという関係になっている。

念のため書いておくと、3ビットや4ビットは「桁違いに危険になる」規模ではない。トランザクションID16ビットは残っているので、これ単独で即座に成立するという話ではない。ただしキャッシュポイズニングは試行回数を積める攻撃であり、母数が8分の1や16分の1になることは、成立までの時間がそのぶん短くなることを意味する。防御の設計としては、減ってよい値ではない。

なぜ評価が 9.3 と 5.7 に割れているのか

このCVEを調べると、深刻度が2つ出てくる。数字だけ見ると混乱するので、整理しておく。

  • NVD(Primary, nvd@nist.gov … CVSS 3.1 で 9.3 Critical。ベクタは AV:A/AC:L/PR:N/UI:N/S:C/C:N/I:H/A:H
  • 開発元の NLnet Labs(sep@nlnetlabs.nl … CVSS 4.0 で 5.7 Medium。ベクタは AV:A/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:H/SA:H/E:P

4点近く離れているが、どちらかが間違っているのではない。評価軸とCVSSのバージョンが違う。

まず両者が一致している部分を見るのが実務上は有益だ。どちらも AV:A、つまり攻撃元は「隣接ネットワーク」である。インターネットの任意の場所から誰でも撃てる、という評価にはなっていない。これが対応の優先度を決める一番の手がかりになる。

差が出ているのはスコープと機密性の扱いである。NVD は S:C(スコープ変更あり)と見ており、これは汚染されたキャッシュの影響がリゾルバ自身ではなく、それを信じるクライアント側に及ぶためだ。被害の広がり方を考えるうえでは、この見方のほうが実態に近い。一方で C:N(機密性への直接影響なし)は両者共通で、この欠陥が直接漏らすのは「どのスレッドが処理したか」であって、DNSの中身ではない、という理解と整合する。

NLnet Labs 側の CVSS 4.0 には AT:P(Attack Requirements: Present)が入っている。成立に前提条件が要る、という評価で、実際にこの攻撃には対応表を作る手間と、狙った権威サーバの応答を観測できる位置が必要になる。

社内で報告を書くときは、9.3 と 5.7 のどちらの評価元の値かを必ず併記すること。片方だけ書くと、受け取った側の判断が変わる。

なお、2026年8月17日時点で CISA の KEV カタログには収載されていない(カタログ版 2026.08.14 で確認)。悪用が確認された脆弱性の一覧には入っていない、という状態である。

自分の環境が該当するかを判定する

見るべきものは3つしかない。

1. バージョン。 1.4.22 以上 1.25.1 以下なら範囲に入る。

1
unbound -V | head -1

2. so-reuseport の値。 既定が yes なので、明示的に no にしていなければ有効である。設定ファイルを目で追うより、Unbound 自身に解決させたほうが確実だ。include を展開した最終的な値が返る。

1
unbound-checkconf -o so-reuseport

3. そのリゾルバに問い合わせを投げられる相手。 AV:A なので、ここが優先度を決める。

  • 社内クライアント全台から引ける共有リゾルバ、マルチテナントのVPCやコンテナネットワークで共有しているリゾルバ、ゲスト用Wi-Fiと同じセグメントに置いてあるリゾルバは、攻撃者が隣接に入りうるので優先度が高い。
  • 逆に、1台のホスト内から localhost 経由でしか引かれないリゾルバなら優先度は下がる。

access-control で引ける範囲を絞れているかも、あわせて確認しておく。

1
2
unbound-checkconf -o num-threads
unbound-checkconf -o outgoing-range

この2つは前節のエントロピー計算に使う。

判定スクリプト

上の3点のうち、機械で決まる2つ(バージョンとso-reuseport)を判定し、エントロピーの減少ぶんまで出すスクリプトを置く。依存なしで、設定ファイルは読むだけで変更しない

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
#!/usr/bin/env node
"use strict";
/*
* unbound-cve-2026-50252-check.js
*
* Unbound の CVE-2026-50252 に該当するかを、バージョンと設定から判定する。
* 依存なし(Node 標準のみ)。unbound.conf を読むだけで、変更は一切しない。
*
* node unbound-cve-2026-50252-check.js --version 1.25.1 --conf /etc/unbound/unbound.conf
* node unbound-cve-2026-50252-check.js --version "$(unbound -V | head -1)"
*
* 判定の根拠:
* 影響範囲 … 1.4.22 以上 1.25.1 以下(両端を含む)/修正版 1.25.2
* 成立条件 … so-reuseport が有効(Unbound の既定は yes)
*/

const fs = require("fs");

const AFFECTED_FROM = "1.4.22";
const AFFECTED_THROUGH = "1.25.1";
const FIXED = "1.25.2";

/** "1.25.1" → [1,25,1]。数字以外は捨てる("unbound 1.25.1" も受ける)。 */
function parseVersion(s) {
const m = String(s).match(/(\d+)\.(\d+)(?:\.(\d+))?/);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
}

/** a<b → -1 / a==b → 0 / a>b → 1 */
function cmp(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}

function inAffectedRange(v) {
return (
cmp(v, parseVersion(AFFECTED_FROM)) >= 0 &&
cmp(v, parseVersion(AFFECTED_THROUGH)) <= 0
);
}

/* unbound.conf の素朴なパーサ。`key: value` 形式で、# 以降はコメント。
* include: は追わない(追うと環境差で結果が変わるため、見つけたら警告だけ出す)。 */
function parseConf(text) {
const out = { keys: {}, includes: [] };
for (const raw of String(text).split("\n")) {
const line = raw.replace(/#.*$/, "").trim();
if (!line) continue;
const m = line.match(/^([a-z0-9-]+)\s*:\s*(.*)$/i);
if (!m) continue;
const key = m[1].toLowerCase();
const val = m[2].trim();
if (key === "include" || key === "include-toplevel") {
out.includes.push(val);
continue;
}
out.keys[key] = val;
}
return out;
}

function yesNo(v, dflt) {
if (v === undefined) return dflt;
return /^(yes|true|1)$/i.test(v);
}

/* 送信元ポートがスレッドごとに分割されることで、1スレッドあたりの
* ランダムポート母数がどれだけ減るかを出す。
* 分割は「ほぼ等分の互いに素な部分集合」なので、母数は total/threads、
* エントロピーの減少は log2(threads) ビットになる。 */
function portEntropy(totalPorts, threads) {
const perThread = Math.floor(totalPorts / Math.max(1, threads));
const bitsAll = Math.log2(Math.max(1, totalPorts));
const bitsPerThread = Math.log2(Math.max(1, perThread));
return {
totalPorts,
threads,
perThread,
bitsAll: Number(bitsAll.toFixed(2)),
bitsPerThread: Number(bitsPerThread.toFixed(2)),
bitsLost: Number((bitsAll - bitsPerThread).toFixed(2)),
};
}

function parseArgs(argv) {
const a = {};
for (let i = 2; i < argv.length; i++) {
if (argv[i] === "--version") a.version = argv[++i];
else if (argv[i] === "--conf") a.conf = argv[++i];
else if (argv[i] === "--threads") a.threads = Number(argv[++i]);
else if (argv[i] === "--outgoing-range") a.range = Number(argv[++i]);
}
return a;
}

function main() {
const a = parseArgs(process.argv);
const notes = [];

if (!a.version) {
console.error(
"使い方: node unbound-cve-2026-50252-check.js --version <ver> [--conf /etc/unbound/unbound.conf]",
);
process.exit(2);
}
const v = parseVersion(a.version);
if (!v) {
console.error(`バージョンを読めませんでした: ${a.version}`);
process.exit(2);
}

let conf = { keys: {}, includes: [] };
if (a.conf) {
if (!fs.existsSync(a.conf)) {
console.error(`設定ファイルがありません: ${a.conf}`);
process.exit(2);
}
conf = parseConf(fs.readFileSync(a.conf, "utf8"));
if (conf.includes.length) {
notes.push(
`include が ${conf.includes.length} 件あります(この判定では追っていません): ${conf.includes.join(", ")}`,
);
}
} else {
notes.push("--conf 未指定のため、設定は Unbound の既定値で仮定しました。");
}

// Unbound の既定: so-reuseport: yes / num-threads: 1 / outgoing-range: 4096
const soReuseport = yesNo(conf.keys["so-reuseport"], true);
const threads = a.threads || Number(conf.keys["num-threads"] || 1);
const range = a.range || Number(conf.keys["outgoing-range"] || 4096);

const versionAffected = inAffectedRange(v);
const affected = versionAffected && soReuseport;

console.log(
JSON.stringify(
{
version: v.join("."),
versionAffected,
affectedRange: `${AFFECTED_FROM} - ${AFFECTED_THROUGH}`,
fixedVersion: FIXED,
soReuseport,
soReuseportSource: conf.keys["so-reuseport"] ? "conf" : "default(yes)",
verdict: affected
? "AFFECTED"
: versionAffected
? "NOT_AFFECTED(so-reuseport が無効)"
: "NOT_AFFECTED(バージョンが範囲外)",
entropy: portEntropy(range, threads),
notes,
},
null,
2,
),
);
process.exit(affected ? 1 : 0);
}

if (require.main === module) main();
module.exports = { parseVersion, cmp, inAffectedRange, parseConf, portEntropy };

8スレッド構成の設定ファイルに当てると、次のように出る。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ node unbound-cve-2026-50252-check.js --version 1.25.1 --conf /tmp/ub.conf
{
"version": "1.25.1",
"versionAffected": true,
"affectedRange": "1.4.22 - 1.25.1",
"fixedVersion": "1.25.2",
"soReuseport": true,
"soReuseportSource": "conf",
"verdict": "AFFECTED",
"entropy": {
"totalPorts": 8192,
"threads": 8,
"perThread": 1024,
"bitsAll": 13,
"bitsPerThread": 10,
"bitsLost": 3
},
"notes": [
"include が 1 件あります(この判定では追っていません): \"/etc/unbound/unbound.conf.d/*.conf\""
]
}

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

設計上、意図的にそうしている点が2つある。**include を追わないのは、追うと環境ごとに結果が変わって再現しなくなるためで、見つけたら notes に件数を出して黙って無視しない形にした。--conf を省略したときに既定値で仮定する**のも同様で、仮定したこと自体を notes に必ず出す。判定できていないのに判定できたふりをするのが、この種のスクリプトで一番まずい壊れ方だからだ。

so-reuseport の最終的な値は、本来 unbound-checkconf -o so-reuseport が正である。このスクリプトの結果と食い違ったら unbound-checkconf を信じてほしい。

対処

基本は 1.25.2 以上への更新である。ディストリのパッケージを使っているなら、そのディストリが 1.25.2 相当へ更新するか修正をバックポートするので、自分のディストリのセキュリティトラッカーで当該CVEの状態を確認してから更新する。上流のアドバイザリは 1.25.1 に手でパッチを当てる方法も案内している。

すぐに更新できない場合の緩和として、**so-reuseport: no にすると本欠陥の成立条件が崩れる。前節で見たとおり、この欠陥は「負荷分散の結果が送信元ポートに依存して露出すること」で成立しているので、その負荷分散をやめれば前提がなくなる。ただしこれは SO_REUSEPORT による性能上の利点を捨てる変更である。問い合わせ量の多いリゾルバでは性能低下を測ってから決める**こと。設定を変えれば済む、という軽い話ではない。

恒常的な防御としては、次の2つを押さえておきたい。

DNSSEC 検証を有効にしておく。 キャッシュポイズニングで差し込まれた偽の応答は、署名検証で落とせる。送信元ポートのエントロピーは「当てにくくする」防御だが、DNSSEC は「当てられても受け付けない」防御である。この種の欠陥に対して最も効く多層防御であり、しかも今回のように前提が崩れたときにも効き続ける。

access-control で引ける範囲を必要なセグメントだけに絞る。 AV:A の攻撃なので、隣接に入れる相手を減らすことがそのまま優先度を下げることになる。

まとめ

この脆弱性が面白いのは、壊れたコードが1行もないところだ。送信元ポートのランダム化は正しく実装されている。SO_REUSEPORT による負荷分散も正しく動いている。それぞれが正しいまま噛み合った結果、片方が守ろうとしていた秘密を、もう片方が観測可能にしていた。

こういう欠陥は、コードを読んでも見つけにくい。「この値は秘密として使われているか」「その秘密は他の仕組みから推測できないか」という、機能をまたいだ問いを立てないと出てこない。

実務としてやることは3つに絞られる。バージョンを確認して 1.25.2 以上へ上げる。上げるまでは so-reuseport: no を性能と引き換えに検討する。そして DNSSEC 検証と access-control を、この機会に見直しておく。

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


送信元ポートは秘密だったはずだ — Unbound CVE-2026-50252 をスレッド数から確かめる
https://blog.hashito.biz/2026/08/17/unbound-cve-2026-50252-so-reuseport-port-entropy/
著者
hashito
作成日
2026年8月17日
著作権