2026/1 の DoS 脆弱性 ─ なぜ React/Next.js は無事で APM は被弾したか
2026 年 1 月 13 日、Node.js は珍しい形のセキュリティ告知を出した。Mitigating Denial-of-Service Vulnerability from Unrecoverable Stack Space Exhaustion for React, Next.js, and APM Users。タイトルからして異例 ── 特定のフレームワークと APM ツールを名指ししている。
そして本文を読むと、もっと異例の事実が出てくる。
Node 24+ で React と Next.js は影響を受けない。だが APM 各社(Datadog / New Relic / Dynatrace / Elastic / OpenTelemetry)は同じ Node 24+ で影響を受けた。
なぜ同じ Node のバージョンで、影響範囲がこんなに違うのか。答えは第 5 章にある。
脆弱性の機構
問題のシナリオは以下:
import { createHook } from 'node:async_hooks';
createHook({ init() {} }).enable();
function recursive() {
new Promise(() => {});
return recursive();
}
try {
recursive(); // ← これが try-catch で拾えずに即死する
} catch (err) {
console.log('Never runs'); // ← 来ない
}
何が起きるか:
recursive()が再帰的に深くなり、new Promise()を呼ぶ- V8 PromiseHook が同じコールスタックで発火する
- スタックが exhausted する瞬間、active frame は hook callback の中
- V8 は「hook 内で fatal エラーが起きた」と判定する(
TryCatchScope::kFatal) - プロセスが exit code 7 で即死。
try-catchを貫通する
sequenceDiagram
participant U as user code
participant V8 as V8
participant Hook as async_hooks callback
participant TC as TryCatchScope::kFatal
U->>U: recursive 呼び出し(深い)
U->>V8: new Promise()
V8->>Hook: PromiseHook 発火(同スタック)
Hook-->>V8: stack overflow
V8->>TC: kFatal で扱う
TC-->>U: ❌ try-catch 貫通
TC-->>U: process.exit(7)
Note over U: try-catch が機能しないため<br/>サービスが即死する
通常の JavaScript では RangeError: Maximum call stack size exceeded を try-catch で拾える。だがhook 内のスタックオーバーフローはその例外を発火する余地がない ── スタックがすでに尽きているからだ。V8 は kFatal として扱うしかない。
影響範囲
| プロダクト | 内部で createHook を使う? | 影響を受けたか |
|---|---|---|
| React Server Components | 使わない(AsyncLocalStorage のみ) | Node 24+ で影響なし |
| Next.js | 使わない(AsyncLocalStorage のみ) | Node 24+ で影響なし |
| Datadog APM | 直接使う(createHook 経由でトレース ID を伝播) | 影響あり |
| New Relic | 直接使う | 影響あり |
| Dynatrace | 直接使う | 影響あり |
| Elastic APM | 直接使う | 影響あり |
| OpenTelemetry | 直接使う | 影響あり |
5 章の伏線回収
なぜ React と Next.js は無事だったのか。第 5 章で見たとおり:
Node 24+ の AsyncLocalStorage は、もう
async_hooks.createHook()を使っていない。
React / Next.js は ALS だけを使っている。Node 24 で ALS の実装が AsyncContextFrame に置き換わった結果、createHook を経由しない ようになった。よって PromiseHook が発火しない。よって kFatal の罠を踏まない。
第 5 章で「createHook と決別した」と書いた決断が、2 年後に結果的にセキュリティ脆弱性を回避したことになる。Node コアチームの設計判断が、フレームワーク側に「動いただけで安全」という余録をもたらした。
一方 APM 各社は、トレース ID をリクエスト境界を越えて伝播するために createHook を直接使い続けた。「migrate away from this API」という第 2 章の公式忠告を踏み続けた結果、被弾した。
パッチとフラグの罠
修正パッチは 2026-01-13 にリリース:
| バージョン系 | 修正済み |
|---|---|
| 20.x LTS | 20.20.0+ |
| 22.x LTS | 22.22.0+ |
| 24.x LTS | 24.13.0+ |
| 25.x Current | 25.3.0+ |
| 8.x - 18.x(EOL) | パッチなし |
修正の中身は単純:TryCatchScope::kFatal でエラーを掴んだ際に StackOverflow 由来かを判定し、kFatal なら ReThrow に切り替える。これで try-catch が機能する。
// 概念コード
if (HasCaught() && mode_ == CatchMode::kFatal) {
Local<Value> exception = Exception();
if (IsStackOverflowError(env_->isolate(), exception)) {
ReThrow(); // ← 追加: ユーザーコードに戻す
Reset();
return;
}
FatalException(/* ... */);
}
注意すべきフラグの罠:第 5 章で紹介した --no-async-context-frame。これを使うと Node 24 でも ALS が旧実装に戻る ── つまり createHook を使う実装に。そうすると React/Next.js も再び脆弱性の影響圏に入る。互換性確保のためのフラグだが、本番運用で使う場合はパッチ済み Node を必ず確保しておく必要がある。
CVE は付かなかった
意外なことに、この件には CVE 番号が割り当てられなかった。Node コアチームの判断は:
- スタックオーバーフローからの recovery は ECMAScript の仕様上保証された挙動ではない
- V8 のセキュリティ保証にも含まれない
- 「仕様外動作への依存」が根本原因(CWE-758, CWE-674)
つまり「そもそも頼ってはいけない recovery に頼っていたエコシステム側の問題」と整理された。Node 側のパッチは「リスク緩和」として位置づけられている。
これは技術的には正しい判断だが、実運用上はパッチを当てなければサービスが落ちる実害がある。「言葉の整理」と「現場の実害」を分けて理解する必要がある。
この章の要点
- 2026/1/13 に公開された async_hooks 起因の DoS:recursive code の Promise 生成でスタック枯渇 → kFatal → try-catch 貫通 → exit code 7
- React Server Components / Next.js は Node 24+ で影響なし(ALS が createHook を使わない実装に進化していたため)
- APM 各社(Datadog / NR / Dynatrace / Elastic / OTel)は
createHook直叩きで被弾 - パッチは 20.20.0 / 22.22.0 / 24.13.0 / 25.3.0
--no-async-context-frameは旧実装に戻す ── パッチを当てない限り脆弱性圏に戻るフラグ- CVE 番号は割り当てられなかった(仕様外動作への依存と整理)
次章への問いかけ
ALS の進化は、結果として「
createHookから離れる」方向に収斂した。これは ALS だけの話で終わるのか、JavaScript 全体の話になるのか?最終章で TC39 AsyncContext proposal の動向と、ALS の今後を見る。