MessagePack 3.1.9 と 2.5.303、32 KiB のペイロードが 120 MiB を確保する問題を修正
CVE-2026-92707: ネストした MessagePack の配列ヘッダーが同じ残りバイト数を使い回して長さチェックを通過できたため、object へのデシリアライズがペイロードサイズの数千倍のアロケーションを引き起こしていました。この修正の内容、修正前後の検証、そして .NET 8 から 11 RC 1 までの SignalR アプリで直接のパッケージ固定が必要な理由を解説します。
MessagePack-CSharp は 2026-09-17 に 3.1.9 と 2.5.303 をリリースし、GHSA-qhrr-8q5h-9q3h(CVE-2026-92707、深刻度 medium)を修正しました。信頼できない MessagePack を object にデシリアライズしている場合、数キロバイトのペイロードだけでリクエストあたり 100 メガバイト超のアロケーションを強制できてしまいます。
深度制限が助けにならなかった理由
Deserialize<object> の背後にあるフォーマッター PrimitiveObjectFormatter は、配列ヘッダーを読み取ると即座に new object[count] を確保します。唯一のガードは MessagePackReader.ReadArrayHeader 内の健全性チェックで、宣言された要素数が未読のバイト数を超えてはならないというものでした。これは各要素が最低 1 バイトを占めるという前提に基づいています。
問題は、ネストした各ヘッダーが 同じ 残りバイト数に対してチェックされていた点です。32,000 要素を宣言する array32 ヘッダーの後に 31,995 要素を宣言する別のヘッダーが続き、さらに別のヘッダーが続いても、すべて検査を通過してしまいます。どのヘッダーも、親がすでに確保した分を考慮していないからです。MessagePackSecurity.UntrustedData はグラフの深さを 500 に制限していますが、例外が発生するのは 500 個の配列がすでに確保され生存した後です。
実測する
ペイロードを直接組み立てました。600 個の array32 ヘッダーがそれぞれ残りバイト数と同じ要素数を宣言し、nil で 32 KiB までパディングしています。同じファイルベースのアプリを SDK 10.0.302 上で各バージョンに対して実行しました。
#:package MessagePack@3.1.8
#:property PublishAot=false
using MessagePack;
using System.Buffers.Binary;
const int headers = 600, total = 32 * 1024;
var payload = new byte[total];
for (int i = 0; i < headers; i++)
{
payload[i * 5] = 0xdd; // array32
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(i * 5 + 1), (uint)(total - (i + 1) * 5));
}
payload.AsSpan(headers * 5).Fill(0xc0); // nil
var options = MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData);
long before = GC.GetTotalAllocatedBytes(precise: true);
try { MessagePackSerializer.Deserialize<object>(payload, options); }
catch (MessagePackSerializationException ex) { Console.WriteLine((ex.InnerException ?? ex).Message); }
Console.WriteLine($"{(GC.GetTotalAllocatedBytes(true) - before) / 1024.0 / 1024.0:F1} MiB");
| バージョン | 例外 | 確保量 |
|---|---|---|
| 2.5.302 | exceeds the maximum depth allowed of 500 | 120.5 MiB |
| 2.5.303 | Attempted to read past the end of the stream | 0.3 MiB |
| 3.1.8 | exceeds the maximum depth allowed of 500 | 120.5 MiB |
| 3.1.9 | Attempted to read past the end of the stream | 0.3 MiB |
修正後のリーダーは、501 回目のアロケーションではなく 2 番目のヘッダーの時点で拒否します。
修正が変更する内容
コミット 7fd78bb は、ヘッダーごとのチェックを ReserveMinimumChildBytes に置き換えました。リーダーは、それ以前の配列やマップのヘッダーが子要素のためにどれだけのバイト数をコミットしたかを追跡し、バイトが消費されるにつれてそのコミットを解消し、新しいヘッダーはそれらのコミットを差し引いた残りに要素数が収まる場合にのみ受け入れます。このチェックは MessagePackReader にあるため、object だけでなくすべてのフォーマッターが恩恵を受けます。
このリリースでは、名前付き型のデフォルトの拒否リストに、既知のデシリアライズガジェットもさらに追加されています(#2263、#2269)。
SignalR アプリには直接のパッケージ固定が必要
アドバイザリでは、AddMessagePackProtocol() を使う ASP.NET Core SignalR と、object を受け取るハブメソッドがリモート攻撃ベクトルとして挙げられています。Microsoft.AspNetCore.SignalR.Protocols.MessagePack の 8.0.31、9.0.20、10.0.12、11.0.0-rc.1 はいずれも MessagePack 2.5.302 に依存したままです。サービシングリリースがこれを引き上げるまでは、推移的な依存バージョンを自分で上書きしてください。
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="10.0.12" />
<PackageReference Include="MessagePack" Version="2.5.303" />
プロトコルパッケージは 2.x の API に対してコンパイルされているため、SignalR では 2.x 系列にとどめてください。このアドバイザリが NuGet の脆弱性データに反映されれば、dotnet list package --vulnerable --include-transitive が 2.5.302 を解決したままのプロジェクトを検出するようになります。
長期的には、ハブメソッドの object パラメーターや DTO の object メンバーを具象型に置き換えてください。これはアドバイザリが最初に挙げていた回避策であり、PrimitiveObjectFormatter を攻撃対象領域から完全に排除します。BinaryFormatter を廃止した後にバイナリシリアライザーを選定するなら、BinaryFormatter 移行ガイドで MessagePack がどこに当てはまるかを扱っています。
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.