SharedArrayBufferがブラウザでデフォルトでは使えないのは、Spectre対策のためだということは知っていた。ただ、Spectreについては「メモリ周りの脆弱性」という漠然とした理解で止まっていた。

SharedArrayBufferも共有メモリを扱う低レイヤーなAPIだし、これを使うとメモリ周りで何か悪さができるのだろう。そのくらいで納得していて、Spectreの仕組みや、このAPIが攻撃にどう使われるのかまでは説明できなかった。

今更調べてみたら、思っていた方向と違うまずさだった。SharedArrayBufferから他人のメモリを直接読めるという話ではなく、共有メモリで精密な時計を作れるという。

でも、正確に時間を計測できると、何がダメなんだろう。処理にかかった時間が分かるなら便利そうだし、それだけで読めないはずのメモリが読めるようになるのか。この間が、自分にはつながっていなかった。

そこで分かってきたのが、CPUが先走って実行した処理の痕跡を、時間差から測れるという話だった。秘密の値が戻り値として出てこなくても、その値によって「どこを読むと速いか」が変われば、時計を使って推測できる。メモリのAPIの話を読んでいたはずなのに、時計の話になった。

そこから先も長かった。時計を粗くするだけでは足りず、秘密のデータを攻撃者と同じプロセスに入れない話になり、COOPとCOEPというHTTPヘッダーに行き着く。さらに、なぜウィンドウ間の関係を切る必要があるのかを追うと、browsing context groupやWindowProxyまで出てくる。

ここでは、その順番で理解したことを書いておく。攻撃を自分のマシンで再現した記録ではなく、論文、ブラウザ実装側の説明、Webの仕様を読んで整理したものだ。コードや図は、明記したものを除いて説明用で、攻撃の実行例や計測結果ではない。

長くなったので、途中から読むなら次のあたりから。

最初に、何が意外だったのか

調べる前は、何となくこう思っていた。

Spectreは、メモリ周りの脆弱性らしい
  → SharedArrayBufferは、共有メモリを扱う低レイヤーなAPI
  → メモリ周りの悪さを何か手助けするのだろう
  → だからSpectre対策でデフォルトでは使えない

しかし、今回理解したつながりはこうだった。

投機実行中に秘密の値が使われる
  → その値に応じたCPUキャッシュの痕跡が残る
  → メモリへのアクセス時間を測ると秘密を推測できる
  → 共有メモリで作った時計が、その測定に使える
  → 時計の制限と、秘密のデータの隔離が必要になる

SharedArrayBufferはこの流れの途中にある。秘密を置く場所や、秘密を直接読むためのAPIとして登場するとは限らない。攻撃者が自分のコード同士で共有するメモリでも、測定器として役に立ってしまう。

なお、デフォルトでは使えないことと、全面的に禁止されていることは別だ。一時的な無効化を経て、現在の通常のWebページではcross-origin isolationなどの条件を満たせば使える。Chrome側が説明する無効化と再導入の経緯に、この時計の問題が書かれている。

CPUは、条件の結果を待たずに先へ進む

まず投機実行から考える。

プログラムには、条件が確定するまで次に進めないように見える箇所がある。例えば、配列の添字が範囲内か調べてから、その要素を読む処理だ。ソースコードを上から読めば、チェックが先で、読み取りがあとになる。

一方、CPUが本当にその順で何もかも待つと、メモリから値が届くまで使われない実行資源が生まれる。そこで、条件がどちらに進みそうかを予測し、正しいか確定する前に先の命令を実行する。予測が当たれば、その分だけ待ち時間を隠せる。

外れた場合は、誤った経路で実行した命令の結果を、プログラムの確定した結果としては採用しない。Project Zeroの最初の報告では、この「実行」と、結果を確定するretireを区別して説明している。

ここで自分が引っかかったのは、「実行したのに、実行していないことにできるのか」という点だった。プログラムに見える結果については、そういう仕組みで動いている。誤予測した経路で計算した値を、そのまま正しい戻り値として返すわけではない。

ただし、「プログラムが正しい答えを返す」ことと、「途中で何をしたかを一切観測できない」ことは別だった。

確定した状態と、速くするための状態

この区別には、architectural stateとmicroarchitectural stateという言葉が出てくる。

前者は、命令セットの約束としてプログラムから見えるレジスタやメモリなどの状態。後者は、その命令セットを実際に速く動かすためにCPU内部で持つ状態で、キャッシュや分岐予測器などが関わる。

例えば計算を破棄して、レジスタに誤った答えを残さないようにしても、その計算の途中で読み込んだデータがキャッシュに載ることまでは、同じように取り消されるとは限らない。

原論文で重要なのは、この差を情報の出口として使ったことだ。秘密を戻り値に載せなくても、秘密によって変わった内部状態を、あとから観測できればよい。

「実行を取り消す」という説明を、自分は「時間も含めて完全に元通りにする」と受け取っていた。そこが違った。

秘密を返さず、秘密に応じた場所へ触る

キャッシュは、メモリアクセスを速くするためにデータを近くへ置く仕組みだ。同じデータを読む場合でも、キャッシュにあるかどうかで、かかる時間が変わる。

ここで、秘密の1バイトが42だったとする。攻撃者は42そのものを通常の読み取り結果として受け取れない。しかし、投機実行の間だけその値を使い、観測用の領域の「42に対応する場所」を読む処理を走らせられたらどうなるか。

秘密が  7 → 観測用領域の  7 に対応する場所へアクセス
秘密が 42 → 観測用領域の 42 に対応する場所へアクセス
秘密が 99 → 観測用領域の 99 に対応する場所へアクセス

あとから攻撃者は、観測用領域の候補を読む。そのうち42に対応する場所だけが速ければ、秘密は42だったのではないかと推測できる。

この「対応する場所」は、単純に隣り合う1バイトずつとは考えないほうがよい。キャッシュはあるまとまりでデータを扱うので、候補が同じまとまりに入ると区別しにくい。説明上は、十分に離した場所を候補ごとに用意する、と考える。

つまり、秘密を「どの場所が速く読めるか」に変換している。観測用領域は、秘密の数値を時間差へ変換するためのものだ。

この段階を読むと、時計が必要な理由が分かる。攻撃者に欲しいのは、現在が何時かではなく、「この読み取りは、あの読み取りより少し速かったか」を判断する道具だった。

チェックがあるのに、どうやって秘密へ到達するのか

ここまでの説明には、「そもそも秘密を投機的に読めるのか」という前提が残っている。

Spectreには複数の型があるが、最初に理解しやすかったのはbounds check bypass、いわゆるvariant 1だった。境界内の入力を何度も与えて、分岐予測に「このチェックは通る」という履歴を作る。そのあとで範囲外の入力を与える。

条件が確定するまでの間に、CPUが「今回も通る」と予測すると、通常なら実行しない読み取りまで進んでしまう場合がある。その先で、読んだ値に応じて観測用領域へ触る。誤予測が判明して結果を破棄しても、その痕跡は残り得る。

説明用に、機械語側の処理を疑似コードにするとこうなる。

もし index が公開領域の長さより小さければ:
    value = 基準アドレスと index から求めた場所のバイトを読む
    value に対応する観測用領域へ触る

重要なのは、ソースコード上の条件を削除していないことだ。条件は存在し、確定した実行結果もその条件に従う。しかし、予測中に生じた観測可能な痕跡まで、その条件だけで防げるとは限らない。

実際の攻撃には、利用できる命令列、入力への影響、十分な投機実行の時間、キャッシュの準備、ノイズへの対処などが必要になる。適当な配列アクセスを書けば、どんな環境でも秘密を取り出せるという話ではない。ここでは成立条件を一つずつ再現せず、データが漏れる経路を追っている。

SpectreとMeltdownを一緒にすると分からなくなる

同じ時期に公表されたため、自分の中ではSpectreとMeltdownもかなり混ざっていた。2018年のProject Zeroの報告では、variant 1と2がSpectre、variant 3がMeltdownとして整理されている。

今回追っているのは、投機実行させる命令列とサイドチャネルによって情報を漏らすSpectre側の話だ。「ユーザー空間からカーネルのメモリを読める」という一文だけで全体を理解したつもりになると、JavaScriptや共有メモリとのつながりを見失う。

Spectreのすべてをvariant 1の配列チェックだけで説明するのも狭すぎる。間接分岐の予測先を狙うvariant 2などもある。ただ、まず一つの型で「秘密を読む」「痕跡へ変換する」「痕跡を測る」を分けたほうが、自分には理解しやすかった。

JavaScriptにポインタがなくても、CPU上で動いている

JavaScriptのコードには、普通、任意のアドレスを指すポインタが出てこない。TypedArrayにも範囲がある。そこで、自分はブラウザのJavaScriptからCPUの問題を突けることに、少し距離を感じていた。

しかし、JavaScriptエンジンも最終的にはCPU上で動く。JITが生成するコードも、インタプリタを実装したコードも、CPUにとっては命令列だ。言語として守っている境界と、CPUが投機実行中にどの情報へ影響を与えるかは、同じ層の話ではない。

V8がSpectre対応を振り返った記事は、言語のチェックだけにプロセス内の機密性を任せられない、という結論を説明している。

これは「JavaScriptの範囲外アクセスが、普通の戻り値として別のメモリを返すようになった」という意味ではない。プログラムの意味としては読めなくても、その実行時間から情報を推測する別の経路がある、ということだ。

また、JITを切ればこの種類の問題がすべてなくなる、と短絡するのも違う。CPUが動かすのは、JITの生成物だけではない。どの命令列を攻撃に使えるかは実装によるが、「JITという機能そのもののバグ」とだけ捉えると範囲を取り違える。

メモリ安全性の価値がなくなったわけでもない。通常の範囲外アクセスを防ぐことと、投機実行中の情報の漏れを防ぐことには、それぞれ考えるべき境界がある。

SharedArrayBufferは、もともと何を共有するのか

ようやくSharedArrayBufferへ戻る。

SharedArrayBufferは、複数の実行主体から同じバイト列を扱うための仕組みだ。よく出てくるのは、ページと、そのページが作ったDedicated Workerでメモリを共有する使い方になる。

例えば、ページ側でバッファを作り、Workerへ渡す。

const shared = new SharedArrayBuffer(4);
const counter = new Int32Array(shared);
const worker = new Worker("counter-worker.js");
worker.postMessage(shared);

この例は利用条件を満たす環境を前提にした断片で、ここでは実行していない。

普通のオブジェクトをpostMessage()で送るときのコピーや、ArrayBufferの所有を移す転送とは違い、SharedArrayBufferでは受け取る側も同じ共有データを扱う。JavaScriptオブジェクトとしての包みと、その背後のデータを区別すると分かりやすい。SharedArrayBufferの説明もこの点を区別している。

ここでいう共有は、ブラウザ全体のメモリを全部公開するという意味ではない。別オリジンのページが持つバッファを、勝手に列挙して読めるわけでもない。

ECMAScriptのSharedArrayBufferの定義で扱う共有データの仕組みと、OSプロセスのアドレス空間全体へのアクセスは別のものだ。自分の「低レイヤーでメモリ周りの悪さができるのだろう」という理解には、こういう区別もなかった。

なぜ、共有メモリが時計になるのか

共有バッファのある場所にカウンタを置き、Worker側で増やし続ける。観測する側は、調べたい処理の前後でその値を読む。

Worker側:
    共有カウンタを繰り返し増やす

観測する側:
    before = 共有カウンタを読む
    調べたいメモリアクセスをする
    after = 共有カウンタを読む
    after - before を経過時間の目安にする

共有するのは、攻撃者が自分で作ったバッファでよい。秘密を持つ側に「時計のために共有してください」と頼む必要はない。

カウンタの増加を明示的に扱うなら、Atomicsを使う形を考えられる。例えば、更新をAtomics.add(counter, 0, 1)、読み取りをAtomics.load(counter, 0)にする。Atomicsは共有メモリ上の操作を調整するための道具で、これ自体が時計のAPIというわけではない。

もちろん、1カウントが常に同じ秒数になるわけではない。CPUの速度、Workerが実行されるタイミング、他の処理との競合などに左右される。カウンタの周回もある。この説明用の構成で、どの機種でも特定の時間分解能が得られる、と主張しているわけではない。

それでも、何かが並行して変化し、その進み具合を観測できること自体が時間の基準になる。ブラウザが提供した時計のAPIを呼ばずに、時計に相当するものを作れてしまう。

共有メモリをメモリ周りの悪さに使う、と漠然と考えていたところへ、時計として使う話が出てきた。Workerが更新するカウンタを別のスレッドから読み、その差でキャッシュへのアクセス時間を測る。そんな使い方は思い付かなかった。

performance.now()を粗くするだけでは済まなかった

CPUキャッシュの小さな時間差を観測しにくくするため、ブラウザは時計の精度を落とした。Mozillaの2018年の告知は、performance.now()のような明示的な時計と、SharedArrayBufferのような暗黙の時計の両方を挙げている。

明示的な時計だけを粗くしても、共有メモリから別の時計を組み立てられる。それがSharedArrayBufferの無効化につながった。

さらに、精密な時計を隠せば終わりでもなかった。V8の振り返りでは、時間差を増幅する方法によって、粗い時計でも観測できる場合があると説明している。

例えば、1回の差が目盛りより小さくても、同じ性質の差を繰り返して全体を大きくできれば、観測できる可能性が出てくる。実際の攻撃は単純な足し算ほど素直ではないが、「時計の目盛りを粗くしたので、どんな時間差も測れない」という推論はできない。

したがって、SharedArrayBufferはSpectreの唯一の入口ではないし、必須部品でもない。使えると強力な観測手段になり得る、という位置付けだ。

ここまで来て、自分の中の「Spectre対策でデフォルトでは使えない」という理解で、具体的な仕組みをどれだけ省略していたかが分かった。

ブラウザは「読み込む」と「読ませる」を分けている

次はブラウザ側の事情を考える。

Webでは、別オリジンのリソースを読み込むこと自体は珍しくない。画像を表示したり、スクリプトやCSSを読み込んだり、iframeを置いたりする。

しかし、「表示できる」「処理に使える」と「ページのJavaScriptから中身を自由に読める」は同じではない。例えばクロスオリジンの画像は表示できても、CORSによる許可がなければ、その画像を描いたcanvasから画素を取り出すことは制限される。canvasでの画像利用の説明にも、この区別がある。

ブラウザ内部には、表示のためのデータがある
                ↓
ページのJavaScriptに、そのデータを返すかは別途判定する

この区別によって、外部のコンテンツを使う便利さと、オリジン間の機密性を両立させてきた。

ところが、SpectreのようにAPIの戻り値以外からデータを推測されると、内部へ読み込んでから「JavaScriptには返さない」と判断するだけでは足りなくなる。web.devのcross-origin isolationの説明でも、クロスオリジンの画像の画素を例に、この問題を説明している。

守りたいのは、攻撃者の悪意ある画像を自分のページに入れないことだけではない。攻撃者のページが、他人の秘密を含むデータを読み込み、強い観測手段にさらすことも問題になる。

例えば、認証されたユーザー向けに返す画像があったとする。攻撃者のページがそれを画像として読み込める状況なら、通常のJavaScriptの読み取り制限が残っていても、読み込んだデータの扱いを考える必要がある。実際にCookieが送られるかは、SameSite属性やブラウザの第三者Cookieの扱いなどにも左右されるので、「別サイトの埋め込みは必ずログイン済みになる」とはしない。

自分はCOEPという名前を見たとき、「危ない外部コンテンツを排除する設定」だと思いがちだった。しかし、攻撃者もそのヘッダーを付けられる。付けた攻撃者が何を諦めるのか、という向きで考えるほうが腑に落ちた。

origin、site、processを同じものにしない

ここで言葉を整理しておかないと、隔離という単語が全部同じ意味に見えてしまう。

通常のHTTP URLでオリジンを比べるときは、スキーム、ホスト、実効的なポートが関わる。https://app.example.comとhttps://api.example.comは別オリジンだ。同じホストでもHTTPとHTTPSなら別になる。

一方、siteは登録可能なドメインなどを使った、オリジンより大きな単位だ。通常、同じHTTPSのapp.example.comとapi.example.comはsame-siteだが、same-originではない。HTMLのoriginとsiteの定義は、この二つを区別している。

そしてOSプロセスは、ブラウザがそれらの文書を実際にどこで動かすかという実装上の単位になる。

言葉何についての単位か
originDOMなどへの権限を考えるときの基本的な境界
siteoriginより広い、サイトのまとまりを扱う単位
OSプロセスアドレス空間や実行資源を隔離する実装上の単位
browsing context groupウィンドウやフレームの参照関係を扱う仕様上のまとまり
agent cluster共有メモリを共有できる範囲などを定める仕様上のまとまり
Realm組み込みオブジェクトとグローバル環境を持つJavaScriptの実行環境

同じタブだから同じプロセス、とも限らないし、同じオリジンだからブラウザ内のどこでも同じ共有メモリを使える、とも限らない。

特にbrowsing context groupを「プロセスの別名」と思うと、その先の説明が崩れる。仕様上の参照関係と、OSに割り当てるプロセスは対応を考える必要こそあるが、同じものではない。

共有メモリにも共有できる範囲がある

HTMLのagent clusterの説明では、ページとそのDedicated Workerは共有メモリを共有できる組み合わせとして挙げられている。一方、ページとそのService Workerは同じagent clusterではない。

同じオリジンの別タブでも、openerや祖先の関係がない独立したページ同士は、同じagent clusterになるとは限らない。このため、同じオリジンという一点だけで、任意の二つの実行環境へSharedArrayBufferを配れると考えてはいけない。

自分には「共有メモリがあるならプロセスを共有しているのだろう」という先入観もあった。しかし、Webの仕様はブラウザごとのプロセス構成に依存しない共有の境界を定めている。OSの構造を、そのままJavaScriptのAPIとして見せているわけではなかった。

Site Isolationは、秘密の置き場所を変える

時計を粗くしても観測を完全には防げないなら、攻撃者のコードが動くアドレス空間から、秘密を離すことが重要になる。

ChromiumのSite Isolationの説明では、異なるサイトのページを別のレンダラープロセスへ隔離する仕組みと、その動機が説明されている。別サイトのiframeを別プロセスで動かすことも、この話に含まれる。

画面としては一つのタブ

  サイトAの文書       → レンダラープロセスA
  埋め込んだサイトB   → レンダラープロセスB

画面の中で一緒に見えることと、同じアドレス空間へデータを置くことは分けられる。

ただし、文書を別プロセスへ置くだけで、すべてのリソース読み込みを説明できるわけでもない。画像やスクリプトなどは、読み込む側のページで使うためのデータになる。そのため、どんなレスポンスをレンダラープロセスへ渡してよいかという保護も必要になる。

ChromiumのCORBの説明は、クロスオリジンのHTMLやJSONなどを、読み込んだページのプロセスへ不用意に渡さないための対策を説明している。これは歴史的な対策の説明として読んでいて、現在のすべてのブラウザのレスポンス判定をCORBだけで表しているわけではない。

「プロセス境界を越えて読める」だけでは雑すぎた

ここまで読むと、「Spectreはプロセス境界を越えて読める」という一文は、自分の理解にはむしろ邪魔だった。

Spectreの攻撃群には、被害者側の実行を誘導するものもあり、別のセキュリティ境界をまたぐ攻撃も研究されている。しかし、ここで追っているブラウザの防御を理解するには、攻撃者が動くプロセスに、JavaScriptの権限では読めないデータがあるという問題をまず押さえる必要がある。

V8の埋め込み側向けの文書も、信用できないコードと機密データを別プロセスへ置く対策を挙げている。

「別プロセスでも関係なく何でも読める」と思ってしまうと、なぜブラウザがプロセスを分ける方向へ進んだのか分からない。攻撃の種類、どのコードをどこで走らせるか、どこに秘密があるかを省略しすぎていた。

COOPは、ウィンドウ同士の関係に制約を入れる

ここから、SharedArrayBufferを使うためによく見る二つのヘッダーのうち、COOPを考える。

Cross-Origin-Opener-Policy: same-origin

COOPはCross-Origin-Opener-Policyの略だ。画像やCSSの読み込みを許可するヘッダーではなく、トップレベル文書と、それを開いた側・開かれた側との関係に関わる。

例えば、ページAがwindow.open()でページBを開く。通常、開いた側には戻り値として参照が残り、開かれた側にはwindow.openerがある。オリジンが違ってDOMを自由に読めなくても、ウィンドウへの参照が完全になくなるわけではない。

const popup = window.open("https://other.example/", "_blank");

実際にはポップアップがブロックされればnullになるなどの条件があるが、ここでは開くことに成功した場合を考える。

別オリジンでも、すべての接点が禁止されるわけではない

別オリジンのWindowに対して、documentを読もうとすれば制限に当たる。一方で、関係が保たれていればpostMessage()などの限定された接点がある。この「DOMは読めないが参照は持つ」という状態を、まず分けて考える必要がある。

COOPのsame-originは、ポリシーとオリジンの条件が合わない文書との間で、その関係を分離する。COOPの説明では、どちらがどちらを開くか、それぞれがどのポリシーを持つかで、同じbrowsing context groupに残るかを区別している。

groupが分かれた場合、openerの関係が切れ、開いた側から保持していた参照は、相手が閉じたように見えることがある。例えばclosedがtrueになる。画面上のタブが実際に閉じることとは違う。 ページは開いたままでも、以前の参照関係は継続しない。

逆に、同じオリジンでポリシーも適合するページまで、COOPを付けたという理由だけで無条件に全部切るわけではない。

ここでpostMessage()そのものがSpectreの攻撃だから禁止する、と理解するのも違う。postMessage()はもともと別プロセス間でも実現できる通信だ。COOPは、文書が維持する参照関係や同じgroupへ受け入れる相手を制限し、ブラウザがより強い隔離を組み立てられるようにする方針として考える。同じgroupにいることと、必ず同じプロセスにいることは、やはり同義ではない。

groupを分けるのは、名前を付け替えるだけではない

browsing context groupの仕様で扱うのは、関連するウィンドウやフレームをどうまとめるかという関係だ。

ここを分けたのに、古い参照から新しい文書へそのまま操作を届けられたら、分けた意味が薄れる。一方、同じgroupに残る正当な関係まで、雑に壊してよいわけでもない。

つまり「COOPという文字列をHTTPヘッダーから読んだ」だけでは、ブラウザとしての実装は終わらない。文書が遷移したときに何の関係を引き継ぐか、保持済みの参照から何が見えるかまでつながっている。

このあたりで初めて、HTTPヘッダーの説明のあとにWindowProxyの話が出てくる理由が分かった。

COEPは、他オリジンのデータの持ち込み方に制約を入れる

もう一つがCOEP、Cross-Origin-Embedder-Policyだ。代表的な値はrequire-corpになる。

Cross-Origin-Embedder-Policy: require-corp

COEPのこのモードでは、クロスオリジンのリソースを読み込むときに、CORSやCORPによる許可が必要になる。ただし「何を読んでも必ずCORPヘッダーだけで判定する」という意味ではない。リクエストのモードなどで経路が変わる。

COEPの説明を読むときは、ページ側の方針と、リソースを提供する側の許可を分けると分かりやすい。

ヘッダー主に誰が何を宣言するか
Cross-Origin-Embedder-Policy埋め込むページが、どういうリソースの持ち込み方を許すか
Cross-Origin-Resource-Policyリソースの提供者が、どこからのno-CORS読み込みを許すか
Access-Control-Allow-Originリソースの提供者が、どのオリジンにCORSでの利用を許すか

CORPで許可する経路

例えば、別オリジンの画像を普通のimgで読み込む、no-CORSの経路を考える。

<img src="https://assets.example/photo.png" alt="説明用の画像">

画像を返す側が次のように応答すれば、CORPの条件としてはクロスオリジンからの読み込みを許可する。

Cross-Origin-Resource-Policy: cross-origin

CORPにはsame-originやsame-siteもある。どの値なら通るかは、読み込む側との関係によって変わる。Fetch StandardのCORPの定義に、それぞれの判定がある。

ここでCORPが許可するのは、あくまでその読み込みの経路だ。CORPのcross-originを付けたからといって、canvasからの画素読み取りまでCORSと同じように許可するわけではない。

CORSで許可する経路

画像のリクエストをCORSモードにするなら、次のような形になる。

<img src="https://assets.example/photo.png" crossorigin="anonymous" alt="説明用の画像">

提供側が適切なAccess-Control-Allow-Originを返し、CORSのチェックを通る必要がある。ページのオリジンがhttps://app.exampleなら、例えば次の応答だ。

Access-Control-Allow-Origin: https://app.example

この経路では、単に画像を表示するだけの場合とは、利用できる範囲も変わる。COEPを付けたこと自体が、no-CORSの要求を自動でCORSの要求へ変換するわけではない。COOP・COEPの導入ガイドでも、CORSを使う画像にはcrossorigin属性を付ける手順が出てくる。

自分には、似た名前のヘッダーが三つ並んでいることよりも、「ページの約束」「リソース側の埋め込み許可」「CORSでの利用許可」が別々だと分かったことが大きかった。

credentiallessという別の選択肢

COEPにはcredentiallessもある。これを無視して「COEPにはrequire-corpしかない」と覚えると、また途中で話が合わなくなる。

credentiallessでは、クロスオリジンのno-CORSリクエストから資格情報を外すことで、明示的なCORPのないリソースも読み込める経路を作る。Chromeの説明が、このモードを導入する理由を説明している。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless

ここでの対象は、クロスオリジンのno-CORSリクエストだ。ページからの通信が全部匿名になるわけでも、CORSのチェックが全部不要になるわけでもない。また、資格情報を外せば、認証済みユーザー向けに変わるはずの画像などは、同じ内容では取得できなくなる。

許可を取って持ち込む方法と、資格情報を付けずに持ち込む方法。この違いを意識すると、COEPは単なる「外部リソース禁止スイッチ」より、ずっと具体的な約束に見える。

なぜCOOPとCOEPを組み合わせるのか

ここまで来ると、二つのヘッダーは違う面を担当していることが分かる。

COOPは、関連するトップレベル文書との参照関係に制約を入れる。COEPは、他オリジンのリソースをどう持ち込むかに制約を入れる。片方だけでは、もう片方の制約を宣言したことにはならない。

だから、SharedArrayBufferを使うための代表的な設定として、次の組み合わせが出てくる。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

この隔離を要求する理由の説明では、強いAPIを使う代わりに、許可のない他オリジンのコンテンツの持ち込みを制限する、という考え方が示されている。

攻撃者が自分のページにヘッダーを付けてSharedArrayBufferを使おうとしても、その代わりに、許可なく他オリジンのデータを取り込む自由を手放す。ブラウザがその制限を強制することに意味がある。

crossOriginIsolatedは、プロセス一覧の検査ではない

ページ側では、例えばこう確認する。

console.log(self.crossOriginIsolated);
console.log(typeof SharedArrayBuffer);

crossOriginIsolatedは、必要な隔離の能力がその環境で有効かを見るための値だ。プロパティの説明には、COOP・COEPに加えてsecure contextやPermissions Policyの条件もある。

HTTPSなどの信頼できる環境が前提になり、iframeなら親側の条件や権限委譲も関わる。Report-Onlyのヘッダーは、問題の報告を得るためのもので、強制するヘッダーの代わりに隔離を成立させるものではない。

そして、trueになったことは「CPUの投機実行が停止した」という意味ではない。「このタブにはOSプロセスが必ず一つだけ割り当てられている」という意味でも、「別オリジンとの通信が全部禁止された」という意味でもない。

ブラウザが仕様に従って関係と読み込みを制約し、その環境に強力な機能を提供できると判断する。その結果をJavaScriptから確認している。

SharedArrayBufferとAtomicsを一括りに禁止だと思わない

最初の理解では、SharedArrayBufferもAtomicsも高解像度タイマーも、全部ひとまとめに「危ないので無効」になっていた。

しかし、APIごとの条件や、何に使えるかは分ける必要がある。例えばAtomics.wait()は、そもそもブラウザのメインスレッドで待機する使い方ができない。これは、cross-origin isolationだけで説明できる制約ではない。Atomicsの説明も、操作ごとの性質を分けている。

今回の主題は「強い共有メモリの観測能力を、どんな環境へ提供するか」であって、メモリに関係する名前のAPIを全部同じ理由で止めるという話ではなかった。

WindowProxyは、遷移しても窓を指し続けるための仕組み

COOPの説明で「保持済みの参照」と書いたが、JavaScriptから扱っている相手を、単に文書そのものだと考えると困る。

例えばiframeのcontentWindowを変数に保存する。そのあとiframeの中のページが別の文書へ遷移したら、保存していた変数はどうなるのか。

毎回すべての参照が無効になるのでは、ウィンドウを参照するAPIはかなり使いにくい。一方で、遷移前の文書の権限をそのまま遷移後にも持ち越せたら危険だ。

この間にいるのがWindowProxyだ。WindowProxyの説明では、Windowを包み、browsing contextの遷移に応じて包む対象が変わるオブジェクトとして説明されている。

保存してある参照
      │
      ▼
  WindowProxy ──→ 遷移前のWindow
      │
      └────────→ 遷移後のWindow

これは、同じbrowsing context内での遷移を考えるための図だ。COOPでbrowsing context groupを切り替える場合まで、同じ矢印で新しい相手へ接続し続けるという意味ではない。

また、名前にProxyと付いていても、JavaScriptのnew Proxy()をページ側で作っているわけではない。Webの仕様が定める特殊なオブジェクトだ。

参照が同じでも、アクセスしてよい相手とは限らない

例えば、最初は同じオリジンだったiframeが、別オリジンへ遷移したとする。参照を持っているという理由だけで、次のアクセスまで許可してよいわけではない。

const frameWindow = frame.contentWindow;
// このあと、iframeが別オリジンの文書へ遷移したとする。
// frameWindowを保持していることは、DOMへのアクセス許可を意味しない。

アクセス時の相手と、呼び出す側の環境に従って、許される操作を判定する必要がある。保持している参照の同一性と、文書への権限は別の話だ。

そしてCOOPでgroupが分かれる場面では、「新しい文書に対するDOMアクセスを拒否する」だけでなく、そもそも以前のウィンドウ関係を続けないという制約が入る。

ここが、単純なsame-originチェックだけでは説明しきれないところだった。「違うオリジンだから一部のプロパティを拒否する」と「関係を分けたので古い接点を続けない」は、同じ操作ではない。

MessageEvent.sourceの同一性も、見た目だけの問題ではない

ウィンドウ同士の通信では、postMessage()を使うことがある。受信側はevent.originで送信元のオリジンを確認し、event.sourceで送信元ウィンドウへの参照を得る。

例えば、開いたポップアップからのメッセージだけを扱うなら、概念的には次のような確認になる。

window.addEventListener("message", (event) => {
  if (event.origin !== "https://partner.example") return;
  if (event.source !== popup) return;

  // ここで、期待する形式のデータかを検証して扱う。
});

この断片は、popupに開いた相手の参照を保存してあり、COOPなどで関係が切れていない場合を想定している。これだけであらゆるメッセージ処理の安全性が保証されるわけではない。

ここで、ブラウザがメッセージを届けるたびに「同じ相手を表す別のラッパー」を適当に作ったら、event.source !== popupの判定が壊れる。

HTMLのメッセージ送信の手順では、送信元のWindowProxyをsourceとして設定する。値やメソッドが似ているだけでなく、どのオブジェクトを指すかも仕様の一部になる。

ただし、sourceの同一性だけを確認すれば、オリジンの確認が不要になるわけでもない。ウィンドウは遷移できる。event.originはメッセージ送信時のオリジンであって、そのウィンドウの現在のURLを永久に保証するものではない。postMessageの説明でも、送信先のtargetOriginと、受信側の送信元検証を扱っている。

同じ窓から来たか          → sourceで確認する対象
期待したオリジンからか    → originで確認する対象
データが期待した形式か    → アプリケーションで確認する対象

この三つを一つの「信頼できるメッセージ」にまとめてしまうと、どこで何を保証しているのかが見えなくなる。

Realmは「どのJavaScriptの世界か」という別の軸

Realmについては、以前のOmoikaneのiframe実装から見るJavaScriptのRealmで詳しく書いた。今回の話では、オリジンやプロセスとの違いを押さえておく。

Realmは、グローバル環境やArray、Objectなどの組み込みオブジェクトを含む、JavaScriptの実行環境を表す。ECMAScriptのRealmの定義に、その構成要素がある。

同じオリジンのiframeを使った、分かりやすい例を考える。親ページのArrayとiframe側のArrayは、それぞれの環境に属する。

const frameArray = new frame.contentWindow.Array(1, 2, 3);

Array.isArray(frameArray); // true
frameArray instanceof Array; // 通常はfalse

同じ配列として扱えることと、親のArray.prototypeをプロトタイプチェーンに持つことは違う。instanceofと複数のRealmの説明にも、この区別が出てくる。

つまり、Realmが違うことは、オリジンが違うことと同義ではない。同じオリジンでも別のRealmはある。別のRealmでも、条件が合えばオブジェクトへアクセスできる。この区別は、内部ホスト関数のオリジン境界を直した記事でも扱った。

なぜ例外や関数を作る場所まで問題になるのか

別の文書へのアクセスで例外を投げるとして、その例外をどの環境のコンストラクタやプロトタイプに従って作るかは、JavaScriptから観測できる。

クロスオリジンの操作として見せる関数も同じだ。許可されている操作を呼べるようにする際に、どのRealmの関数として作るか、同じ参照を取り直したときに同じオブジェクトを返すかが問題になる。

HTMLのCrossOriginGetOwnPropertyHelperでは、呼び出す側と対象側の環境、プロパティ名を使うキャッシュや、current Realmで関数を作る手順が定義されている。

ただし、ここから「クロスオリジンのものは、例外もLocationも関数も、全部一律に呼び出し元で作る」と一般化してはいけない。何がどの環境に属するかは、操作やオブジェクトごとの手順を読む必要がある。例外を投げるときのRealmも、Web IDLの例外の手順などとつながっている。

実装を想像すると、「現在表示しているページのオリジンだけを見ればよい」では足りない。今どのコードを実行しているか、その関数がどこで作られたか、アクセス先のオブジェクトがどの環境に属するかが別々にある。

ここまで来ると、最初の「SharedArrayBufferはなぜ危ないのか」から随分遠く見える。しかし、隔離を宣言した文書の関係をブラウザが実際に守るためには、こういうJavaScriptから見える細部までつながっている。

ヘッダーを読めたら、隔離を実装したことになるわけではない

以前、OmoikaneのDOMや例外通知について書いたときも、APIの追加は一つのメソッドを生やして終わりではなかった。今回の仕様を読むと、隔離という機能でも同じことを考える。

以下は特定のPRがすべて対応しているという報告ではなく、ここまで読んだ仕様から、ブラウザを実装するなら確認したいと思った点だ。

例えば、同じオリジンでポリシーも適合するポップアップとの関係は残るのに、不適合な文書へ遷移したときは関係が切れる。その二つを区別せず、ポップアップを一律に無効化しても、参照を一律に残しても困る。

同様に、ウィンドウ参照の同一性を維持する必要がある場面と、groupを切り替えた結果として以前の接点を引き継がない場面を区別する。postMessage()なら、送信元の参照、送信時のオリジン、配送時の条件を混ぜない。クロスオリジンのプロパティなら、アクセスする環境に従った判定や、関数オブジェクトの同一性も見る。

COEPなら、画像だから全部許可するわけでも、別オリジンだから全部拒否するわけでもない。no-CORSなのか、CORSを通ったのか、CORPが何を許可したのか、資格情報を付ける経路なのかを分ける。

crossOriginIsolatedという真偽値だけを先に正しく見せても、その下の関係や読み込みが制限されていなければ、宣言と実際の挙動が食い違う。

この節の内容は、CPUに対するSpectre対策をJavaScriptのラッパーだけで完成できるという意味でもない。プロセス構成やレスポンスの扱いを含む実装の対策と、Webから見える仕様上の挙動の両方を考える必要がある。

調べていて、自分が混同していたこと

最後に、途中で何度も引っかかった点を短く並べておく。

最初の雑な理解調べてから区別したこと
Spectre対策で使えないのは、低レイヤーAPIがメモリ周りの悪さを手助けするからだろう攻撃者自身の共有メモリで作った時計が、キャッシュの痕跡を測るために使える
チェックに失敗したので何も漏れない確定した結果に値が出なくても、時間差に情報が残り得る
SpectreにはSharedArrayBufferが必要観測を助ける道具の一つで、攻撃全体の必須条件ではない
JavaScriptの配列アクセスが普通に範囲外を返す言語の結果とは別の、投機実行とサイドチャネルの問題
Spectreは別プロセスのメモリも無条件に読めるどこで何を実行させるかが重要で、プロセス分離には意味がある
COEPは攻撃者のリソースを追い出す他オリジンのデータを許可なく持ち込むことにも制約を入れる
COOPを付けるとタブが閉じる切れた参照から閉じたように見えることと、UI上の終了は別
group、Realm、origin、processは似たような隔離単位権限、参照、実行環境、アドレス空間という別の問題を扱う

自分にとっての一番の変化は、最初の行だった。Spectre対策という理由は知っていても、メモリ周りの脆弱性と低レイヤーAPIという言葉を、何となく結び付けていただけだった。実際には、その共有メモリを時計として使うことで、別の場所で生まれた痕跡を測れることが問題につながっていた。

そこから、CPUの処理を取り消しても消えない痕跡、JavaScriptから見えないはずのデータ、データをプロセスへ持ち込む経路、ウィンドウ間の参照へと話がつながった。

SharedArrayBufferの制限がSpectre対策だと知っていることと、その仕組みやHTTPヘッダーの設定が必要な理由を説明できることの間には、思ったより長い距離があった。今まではその間を「低レイヤーだから何かまずい」で埋めていた。時計になるところから順に追って、ようやく自分の中でつながった。