技術約11分で読めます

Node.js v24.22.1で直ったHIGH脆弱性4件を修正前と比べた

いけさん目次

TL;DR

影響 Node.js 26.x・24.x・22.xでHIGHが4件(22.xが対象なのは3件)。TLSのホスト名検証の回避2件、dns.resolve のNUL文字による名前の切り詰め、Brotli展開時の範囲外書き込み
対応 v26.12.1、v24.22.1、v22.23.4への更新。v25やv20などサポート終了済みのラインは修正版なし
おまけ eMudhraの危殆化した中間CAを経由する証明書チェーンも、同じリリースから CERT_REVOKED で拒否


2026年10月21日、Node.jsが26.x、24.x、22.xの各リリースライン(メジャーバージョンごとの系列)でセキュリティリリースを公開した(Node.js — Wednesday, October 21, 2026 Security Releases)。
修正は10件で、深刻度HIGHが4件、MEDIUMが3件、LOWが3件ある。
1週間前の10月14日に予告が出ていて、そのときは対象のラインと、最高深刻度がHIGHだということだけが公表されていた。

このブログを書いているM4 Mac mini(macOS 26.5.2)には、Node.js v25.3.0が入っていた。
v25は2026年6月1日にサポートが終わっている(EOL、End of Life)ので、今回の修正版は出ていない(Node.js Release schedule)。
そこで修正前のv24.22.0と修正版のv24.22.1をnodejs.orgから落としてきて、手元のv25.3.0と並べ、修正コミットに入っていたテストと同じ入力で挙動を比べてみた。

修正された10件と対象のリリースライン

表のSEA(Single Executable Applications)は、Node.jsのアプリを1つの実行ファイルにまとめる機能で、パーミッションモデルは --permission を付けて起動したときにファイルやネットワークへのアクセスを制限する仕組み。
パーミッションモデルを使っていなければ、CVE-2026-106071と106073の影響は受けない。
対象の列は、セキュリティアドバイザリ(脆弱性の告知)に書かれた影響を受けるラインと同じにしてある。

CVE内容深刻度対象
CVE-2026-105482dns.resolve がNUL文字を含む名前を切り詰めるHIGH26.x、24.x、22.x
CVE-2026-105483全角数字で書いたIPアドレスでTLSのホスト名検証を回避HIGH26.x、24.x
CVE-2026-106069正規化するとIPアドレスになるホスト名でTLSのホスト名検証を回避HIGH26.x、24.x、22.x
CVE-2026-111497Brotliの展開処理で範囲外書き込み、リモートコード実行の可能性HIGH26.x、24.x、22.x
CVE-2026-105481keep-alive(接続の使い回し)中のソケットが、リクエスト送信前に書き込まれたレスポンスを処理するMEDIUM26.x、24.x、22.x
CVE-2026-106072標準の書き方でないIPv4のホスト名に対し、証明書のホスト名の項目でIPアドレスの接続先を認証してしまうMEDIUM26.x、24.x、22.x
CVE-2026-106074crypto.randomUUID() と randomInt() の乱数キャッシュがスナップショットとSEAに保存され、毎回同じ値になるMEDIUM26.x、24.x、22.x
CVE-2026-106070ZIPのヘッダー検証に失敗したあと、攻撃者が用意した先頭バイトを返すLOW26.x
CVE-2026-106071パーミッションモデルで、ワイルドカード指定のファイルアクセス許可が必要以上に広く与えられるLOW26.x
CVE-2026-106073fs.realpathSync でパーミッションモデルを回避LOW26.x、24.x、22.x

Node.js本体に組み込まれている依存ライブラリも更新された(npmで入れる node_modules の中身とは別)。DNSの名前解決に使うc-aresは1.34.9になった。
fetch の実装であるundiciは、26.xで8.12.2、24.xで7.30.1、22.xで6.29.1になっている。
OpenSSLは24.xと22.xで3.5.9に、SQLiteは22.xで3.53.4に更新された(CHANGELOG_V24、CHANGELOG_V22、CHANGELOG_V26)。

IPアドレスの別の書き方とTLSのホスト名検証(CVE-2026-105483、CVE-2026-106069)

HTTPSなどのTLS接続では、サーバーが出した証明書が接続先のものかを確かめるために、Node.jsは tls.checkServerIdentity() でホスト名と証明書を照合する。
照合に使うのは証明書のSAN(Subject Alternative Name、その証明書が有効なホスト名やIPアドレスの一覧)で、DNS:example.com のようなホスト名の項目と、IP Address:127.0.0.1 のようなIPアドレスの項目がある。
接続先がIPアドレスなら IP Address: の項目と、ホスト名なら DNS: の項目と比べる。

ところが、IPv4アドレスには 127.0.0.1 以外にも、127.1 のような省略形、0x7f.0.0.1 のような16進数、2130706433 のような1つの整数、0127.0.0.1 のような先頭に0を付けた形、全角数字の 127.0.0.1、句点(。)区切りの 127。0.0.1 といった書き方がある。
Node.jsの net.isIP() は、これらをどれもIPアドレスと判定しない。
一方で、URLの仕様に沿ってホスト名を正規化する url.domainToASCII() はこれらをIPアドレスに変換し、OSの名前解決でも 127.0.0.1 のようなアドレスになる。

修正前は、こうしたホスト名を正規化したあとの文字列で DNS: の項目と比べていた。
そのため、IPアドレスの形をしたホスト名 DNS:127.0.0.1 を入れた証明書があれば、127.1 などへの接続が証明書の検証を通っていた。
全角数字の件(CVE-2026-105483)は、修正コミットで退行(以前は正しく動いていた処理が、別の変更で壊れること)と説明されている。修正では、全角数字などを IP Address: の項目と比べるように変えた(tls: fix ip normalization regression)。
同じコミットメッセージには、悪用は簡単ではないとも書かれている。

CVE-2026-106069の修正では、正規化して初めてIPアドレスになるホスト名を、照合の前にすべてエラーにした(tls: reject non-canonical IP addresses)。
コミットメッセージによると、OSのリゾルバー(名前解決の処理)が、元のホスト名を別のアドレスとして解釈することがあるからだという。
このMacで確かめると、0127.0.0.1 は url.domainToASCII() では先頭の0を8進数とみなして 87.0.0.1 に変わり、dns.lookup() では 127.0.0.1 が返った。
修正前のNode.jsは 87.0.0.1 のほうで証明書と比べるので、DNS:87.0.0.1 を持つ証明書で、実際には 127.0.0.1 につながる接続を認証してしまう。

攻撃が成り立つには、IPアドレスの形をしたホスト名を DNS: に入れた証明書を、接続側が信頼する認証局(CA)から手に入れる必要がある。
修正コミットのテストでは、テスト用のCAで DNS:87.0.0.1 の証明書を発行し、rejectUnauthorized: true のまま接続させて ERR_TLS_CERT_ALTNAME_INVALID になることを確かめている。

実際のサーバーには接続せず、修正コミットのテストにあったホスト名と証明書のSANの組み合わせを、3つの版の tls.checkServerIdentity() に直接渡してみた。修正前のv24.22.0だけが、3件とも証明書を受け入れた。

ホスト名証明書のSANv24.22.0(修正前)v24.22.1(修正版)v25.3.0(EOL)
127.0.0.1(全角数字)DNS:127.0.0.1受け入れた拒否拒否
127.1DNS:127.0.0.1受け入れた拒否拒否
0127.0.0.1DNS:87.0.0.1受け入れた拒否拒否

v24.22.1のエラーメッセージは Host is a non-canonical IP address で、v25.3.0は is not in the cert's altnames だった。

修正版に上げるまでの間、外から受け取ったホスト名へ接続するコードでは、修正版と同じ確認をアプリ側で先に入れられる。
元のホスト名を net.isIP() に通すとIPアドレスにならないのに、url.domainToASCII() で正規化してから通すとIPアドレスになる場合に、接続せずにエラーにする。

const net = require('node:net');
const url = require('node:url');

function isNonCanonicalIP(host) {
  return !net.isIP(host) && net.isIP(url.domainToASCII(host)) !== 0;
}

この関数は手元で 127.1、0127.0.0.1、0x7f.0.0.1、2130706433、全角数字、句点区切りの6つで true を返し、127.0.0.1、::1、example.com では false を返した。

dns.resolve とNUL文字(CVE-2026-105482)

dns.resolve4() などの dns.resolve* 系の関数は、c-aresというC言語のライブラリで名前解決をしている。
C言語の文字列はNUL文字(\0、値が0のバイト)を終わりの印として扱うので、JavaScriptの文字列の途中にNUL文字があると、c-aresにはそこまでしか渡らない。
修正コミットのテストにある例では、example.com\0.trusted.corp を解決させると、実際には example.com を問い合わせる(dns: validate resolve inputs for null bytes)。

アプリが「.trusted.corp で終わる名前だけ解決する」のような文字列のチェックをしていても、NUL文字を混ぜられると、チェックを通った名前とは別の名前が問い合わせられる。
修正版は、dns.resolve* と dns.reverse()、そのPromise版でNUL文字を含む引数を ERR_INVALID_ARG_VALUE で拒否する。
Resolver の setLocalAddress() に渡すアドレスも同じように確認するようになった。

同じ入力で3つの版を試すと、修正版だけが例外を投げ、v24.22.0とv25.3.0は example.com を解決した。

版dns.resolve4('example.com\0.trusted.corp')
v24.22.0(修正前)example.com のIPアドレスが2つ返った
v24.22.1(修正版)ERR_INVALID_ARG_VALUE で例外
v25.3.0(EOL)example.com のIPアドレスが2つ返った

修正版に上げられない場合は、外から受け取った名前を dns.resolve* に渡す前に name.includes('\0') を確かめれば、修正版と同じように弾ける。

Brotli展開時の範囲外書き込み(CVE-2026-111497)

Brotliは、HTTPのレスポンスによく使われる圧縮形式で、Node.jsは zlib モジュールにGoogleのBrotliライブラリを組み込んでいる。
今回の修正は、そのライブラリ側のコミットを取り込んだもの(deps: brotli: cherry-pick 0ed4268a4401abcb44720963)。
アドバイザリのタイトルは「範囲外書き込みでリモートコード実行の余地がある」で、詳細な説明は載っていない。

取り込まれたコミットでは、Brotliのデータが持つブロックの種類が1つしかないのにブロックの切り替えが現れた場合を、不正なデータとしてすぐエラーにするように変えている。
Node.js側にも、このエラーを表す定数 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH が追加された(src: expose BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH)。

zlib.brotliDecompress() を直接呼んでいなくても、fetch() を使っていればこの展開処理を通る。
Node.jsの fetch() は、レスポンスの Content-Encoding ヘッダーに br があると、zlib.createBrotliDecompress() で自動的に展開する。v24.22.1に入っているundici 7.30.1のソースで確かめると、展開するかどうかはレスポンス側のヘッダーだけで決まっていた。
リクエストで Accept-Encoding: identity を送っても、悪意のあるサーバーが Content-Encoding: br を付けて返せば展開処理に入る。

修正コミットのテストにあった不正なBrotliデータを zlib.brotliDecompressSync() に渡すと、3つの版ともエラーになったが、エラーの種類が違った。

版不正なBrotliデータの展開
v24.22.0(修正前)ERR__ERROR_FORMAT_BLOCK_LENGTH_1
v24.22.1(修正版)ERR__ERROR_FORMAT_BLOCK_SWITCH
v25.3.0(EOL)ERR__ERROR_FORMAT_BLOCK_LENGTH_1

このテスト用データでは修正前もエラーになったので、範囲外書き込みが起きるかどうかはこの試験からでは判別できない。

eMudhraの危殆化した中間CAを含むチェーンの遮断

eMudhraの中間CA秘密鍵は255通りで、公開証明書から復元できた では、秘密鍵を誰でも計算できる状態になった(危殆化した)中間CAのチェーンに、手元のNode.js v25.3.0の fetch がそのまま接続できた。
今回のリリースには、証明書チェーンに特定の公開鍵が含まれていたら拒否する仕組みが追加された(tls: reject chains containing blocked public keys)。

コミットメッセージによると、Node.jsはMozillaのルート証明書一覧を組み込んでいるものの、ChromeのCRLSetやFirefoxのOneCRLのような遮断リストも、標準のオンライン失効確認も持っていない。
ルート証明書ごと外すと同じルートにつながる正規の証明書まで使えなくなるので、遮断する公開鍵のSHA-256ハッシュをバイナリに組み込み、一致したら CERT_REVOKED として扱う形にした。
最初の登録は、eMudhra/emSignの中間CA 8枚で、GoとElectronも同じリストを使っていると書かれている。
組み込みのルート証明書一覧も、MozillaのNSS 3.131.1に合わせて更新された。

前回の記事で使ったテスト用サイトには、危殆化した中間CAで署名した証明書を、eMudhraのルートにつながるチェーンで返すホストと、Sectigoのルートにつながるチェーンで返すホストがある。
この2つへ fetch で接続してみると、修正版だけが両方を CERT_REVOKED で拒否した。2026年10月21日21時18分(JST)に試した結果。

版eMudhraルート経由Sectigoルート経由
v24.22.0(修正前)接続できた(200)UNABLE_TO_GET_ISSUER_CERT_LOCALLY
v24.22.1(修正版)CERT_REVOKEDCERT_REVOKED
v25.3.0(EOL)接続できた(200)接続できた(200)

tls.rootCertificates で組み込みのルート一覧を調べると、Sectigoのルート AAA Certificate Services は3つの版とも入っていなかった。v24.22.0がSectigo経由で失敗し、v25.3.0が接続できた理由は、この一覧からは分からない。
eMudhraのルート emSign ECC Root CA - G3 はv24.22.0とv25.3.0には入っていて、v24.22.1では一覧から外れていた。

修正版への更新方法

修正版はv26.12.1、v24.22.1、v22.23.4が出ている。
node -v で今の版を確かめて、v25、v23、v20などサポートが終わったラインなら修正は出ないので、サポート中のv26、v24、v22のどれかへ移る。
手元のv25.3.0もTLSの3件こそ拒否したが、DNSとeMudhraの件では修正前のv24.22.0と同じく通してしまった。
LTS(長期サポート版)の予定では、v24は2026年10月20日からメンテナンスLTSに入り、v26は10月28日にアクティブLTSになる。

入れ方更新の方法公開当日(10月21日)の状況
nodejs.org の配布物ダウンロードページ から入れ直す3つとも公開済み
nvm(Node.jsの版を切り替えるツール)nvm install 24 で24.xの最新を入れる公開済みの配布物を取る
DockerFROM node:24.22.1 のように版を指定し、イメージを作り直すnode:24.22.1、node:22.23.4、node:26.12.1 のタグが20時39分(JST)にDocker Hubへ公開されていた
Homebrewbrew upgrade node(node@24、node@22 も同様)21時台でもまだ node 26.12.0、node@24 24.22.0、node@22 22.23.3 のままだった

この記事の試験に使ったv24.22.1は、nodejs.orgの配布物を取ってきて、SHASUMS256.txt でハッシュを照合してから展開した。

curl -LO https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz
curl -L https://nodejs.org/dist/v24.22.1/SHASUMS256.txt | grep darwin-arm64.tar.gz | shasum -a 256 -c
tar xzf node-v24.22.1-darwin-arm64.tar.gz
./node-v24.22.1-darwin-arm64/bin/node -v

Dockerで node:24 のような版を固定しないタグを使っていても、キャッシュに残った古いイメージから作り直すと修正前の版のままになるので、docker pull で取り直してから作り直す。
Homebrewは21時台の時点でまだ前の版だったので、急ぐ場合はnodejs.orgの配布物かnvmで入れたほうが早い。