マガジン

ランダムなセッション識別子は何を防いでいるか:偽のメッセージを拒否する仕組みを試作で確かめる

遊びを見つける

執筆にClaudeを使用しています。

分かれ道の前後に駒と橋を置き、サイコロの運とプレイヤーの選択を表したイラスト。
記事のテーマをもとに制作したAI生成イラスト。

ゲームの画面の中から「スコアは999です」というメッセージが届いたとします。受け取る側は、そのメッセージが本当に今遊ばれているゲームから来たのか、同じページに紛れ込んだ別のコードが書いたものなのかを、文面だけでは見分けられません。メッセージの中身はただの文字列やデータで、誰でも同じ形に書けるからです。 ここで役に立つのが、ランダムなセッション識別子です。セッションとは「いま始まった一連のやりとり」のことで、その単位ごとに推測できない長い文字列を一つ作り、正規の相手にだけ渡しておきます。以後のメッセージにはその文字列を添えることを約束にすれば、受け取る側は添えられていない、または違う文字列のメッセージを拒否できます。防いでいるのは、やりとりの途中に割り込む「なりすまし」です。 この記事では、この仕組みを一般的な考え方として説明し、単独で動く短いHTMLで「偽のメッセージが拒否される様子」を読者自身が確かめられる形にします。あわせて、アソバースの確認済みの設計から読み取れることと、作品を開いて自分で確かめる方法も整理します。なお、アソバースの内部でセッション識別子がどう使われているかは、確認済み事項に含まれていません。以下の仕組みの説明は、ブラウザ全般に通じる考え方として読んでください。

この記事の目次
  1. 何から何を守るのか:同じ画面にいる「別のコード」
  2. 合言葉が推測できないことの意味
  3. 試作で確かめる:偽のメッセージが拒否されるか
  4. 起こりやすい誤りと判断基準
  5. アソバースの設計から読み取れること
  6. 次の行動
  7. 参考資料

何から何を守るのか:同じ画面にいる「別のコード」

ブラウザの一つのページには、複数のコードが同居できます。ホストのページが、別のページを埋め込む iframe という枠を持つ場面を考えてみます。枠の中のゲームは、ホストへ postMessage という標準の方法でメッセージを送れます。

問題は、枠が一つとは限らないことです。広告、別の作品、あるいは悪意のある部品が同じページにいれば、それらも同じ postMessage でホストに話しかけられます。ホストが「メッセージが届いたから信じる」としていると、誰でもゲームのふりができます。

最初に思いつく対策は、送り主のオリジン(https://aso-verse.com のような、プロトコル・ドメイン・ポートの組)を見て判断する方法です。これは有効ですが、限界もあります。

  • 権限を絞った sandbox 付きの枠は、設定によってはオリジンが "null" という文字列で届くことがあります。この場合、複数の枠が同じ "null" に見えて区別できません。
  • 同じオリジンの別コードからは、オリジンの文字列だけでは区別できません。

そこで、オリジンに頼らずに「このやりとりの正規の相手だけが知っている秘密」を使います。それがセッション識別子の役割です。

合言葉が推測できないことの意味

識別子の価値は、ほぼ「当てられないこと」にあります。16バイトの乱数を使えば、取りうる値は2の128乗通り、およそ3.4×10の38乗通りです。偽物が当てずっぽうで送っても、実用上は当たりません。

ここで大事なのは、ランダム性の出どころです。Math.random() は暗号用途を想定していないため、識別子には向きません。ブラウザの crypto.getRandomValues()(Web Crypto API)のように、セキュリティ用途の乱数を使います。日時や連番から作った識別子は、規則から推測される恐れがあります。

また、識別子は「セッションごとに新しく作る」ことで効果が高まります。ページを開き直すたびに別の値になれば、前回どこかに漏れた値は、次回には役に立ちません。

ただし、識別子は万能ではありません。次の二つは別の問題として残ります。

  • 識別子が外に漏れると、持っている者は誰でも正規の相手のふりができます。送る先を限定する、画面に出さない、ログに残さないといった扱いが必要です。
  • 識別子はメッセージの発信元を保証しますが、メッセージの中身が正しいかどうかまでは保証しません。中身の検証は別に行います。

試作で確かめる:偽のメッセージが拒否されるか

次のHTMLを一つのファイルに保存してブラウザで開くと、ホストのページが二つの枠を持ちます。一つは識別子を受け取る「ゲーム」、もう一つは識別子を知らない「偽物」です。どちらもホストにスコアを送り、ホストは二つの条件で受理か拒否かを判定して画面に書き出します。

html
<!doctype html>
<meta charset="utf-8">
<pre id="log"></pre>
<script>
const log = t => (document.getElementById('log').textContent += t + '\n');
const sid = Array.from(crypto.getRandomValues(new Uint8Array(16)),
  b => b.toString(16).padStart(2, '0')).join('');
log('sid=' + sid);
const make = (html, onload) => {
  const f = document.createElement('iframe');
  f.setAttribute('sandbox', 'allow-scripts');
  f.onload = onload;
  f.srcdoc = html;
  document.body.append(f);
  return f;
};
const game = make('<script>addEventListener("message", e => {' +
  'parent.postMessage({ sid: e.data.sid, value: 100 }, "*"); });<\/script>',
  () => game.contentWindow.postMessage({ type: 'init', sid }, '*'));
make('<script>parent.postMessage({ sid: "0000", value: 999 }, "*");<\/script>');
addEventListener('message', e => {
  const d = e.data || {};
  const fromGame = e.source === game.contentWindow;
  const sidOk = d.sid === sid;
  log('origin=' + e.origin + ' / ゲームから=' + fromGame + ' / ID一致=' + sidOk);
  log(fromGame && sidOk ? '  受理: ' + d.value : '  拒否');
});
</script>

仕組みを順に追うと、次のようになります。

  1. ホストが乱数で sid を作ります。
  2. ゲーム枠の読み込みが終わると、ホストはその枠の contentWindow にだけ sid を渡します。
  3. ゲームは受け取った sid を添えて、スコアを送り返します。
  4. 偽物の枠は sid を知らないので、当てずっぽうの "0000" を添えて送ります。
  5. ホストは、送り主がゲーム枠であること(e.source)と sid の一致の両方を確かめ、満たしたものだけを受理します。

記録と結果の読み方

画面に出る行を、次の表のように記録してみてください。ページを開き直して3回ほど繰り返すと、sid が毎回変わることも確かめられます。

  • そのまま実行、確かめること:ゲームの行と偽物の行がどう判定されるか、受理されたら何がわかるか:偽物が受理されるなら、判定のコードに誤りがあります
  • fromGame && を外す、確かめること:sid の一致だけで偽物が拒否されるか、受理されたら何がわかるか:拒否されるなら、識別子単独でも効いています
  • && sidOk を外す、確かめること:送り主の枠だけの判定で十分か、受理されたら何がわかるか:偽物が別の枠から来る限り拒否されますが、同じ枠に偽物が入り込む場面は守れません
  • 偽物の "0000" を、画面に出た sid と同じ値に書き換える、確かめること:識別子が漏れた場合、受理されたら何がわかるか:fromGame を残していれば拒否されます。残さなければ受理される可能性があります

最後の行は、実際の漏えいを再現するものではなく、「識別子だけに頼らず、複数の条件を重ねる」理由を体験するための操作です。

起こりやすい誤りと判断基準

自分で作るときに間違えやすい点を挙げます。

  • 識別子の生成に Math.random() を使う。 推測されやすくなります。必ず crypto.getRandomValues() を使ってください。
  • **送信先を '*' のままにして、秘密を誰にでも渡してしまう。** 試作では枠が sandbox 付きでオリジンが固定されないため '*' を使っていますが、送る先は自分が保持している contentWindow に限っています。実際の作りでは、可能ならオリジンも指定してください。
  • 受信側で e.data の形を確かめない。 e.data が null や文字列のときに例外を出すと、画面が止まります。試作の e.data || {} のような防御は最低限です。
  • 識別子を画面やログに出し続ける。 試作ではわかりやすさのために表示していますが、実際に守りたい値は表示しません。
  • 中身の検証を省く。 識別子が合っていても、スコアが常識外の値かもしれません。受け取った値の範囲や型は別に確かめます。

判断の目安は単純です。「そのメッセージを偽られたとき、何が起きるか」を考え、困るものほど、識別子とあわせて送り主の確認と中身の確認を重ねます。

アソバースの設計から読み取れること

確認済みの事実のうち、この話題に近いものを整理します。

  • ゲームは sandbox 付きの iframe で実行され、アップロードされたコードはメインサイトと別のオリジンから配信されます。ここから読み取れるのは、ゲームのコードが、サイト本体の権限や情報にそのまま触れない前提で作られていることです。
  • アップロードしたゲームにはSDKが自動で追加され、URL投稿のゲームは作者がSDKを読み込むと、作品専用のプレイヤーIDとクラウドセーブ(作品ごとにJSONで128KiBまで)が使えます。ここから読み取れるのは、ゲームに渡されるのが「作品ごとのID」であって、アカウントそのものではないということです。
  • ゲームにはFirebaseのUID、認証トークン、メールアドレスを渡さないと案内されています。守るべき秘密を、ゲーム側に渡さないという線引きです。
  • オンライン対戦は「潮目のオセロ」と「灯りのチェス」だけで、手番・合法手・勝敗はサーバーで確認されます。クライアントが「自分の番だ」「この手は合法だ」と主張しても、判断はサーバー側にあるという設計です。

これらは、メッセージの偽装を防ぐ層が複数あることを示しています。ただし、ホストとゲームの間の通信でランダムな識別子が実際に使われているかどうかは、確認済み事項には含まれていません。この部分は「現在の案内で確認する」か、後述の方法で自分の目で確かめてください。

作品を開いて確かめる方法

ブラウザの開発者ツール(多くのブラウザでF12キー)で、次の点を見られます。

  • 要素の一覧から、ゲームを表示している iframe を探し、sandbox 属性にどんな値が並んでいるかを読みます。
  • その iframe の src のドメインが、メインサイトのドメインと異なるかを比べます。
  • ネットワークの一覧で、ゲームの起動時にどんな通信が発生するかを眺めます。13本の公式作品は外部通信なしで動くと案内されているので、公式作品なら通信が少ないはずです。

ここで見えるのはあくまで外から観察できる事実だけです。仕組みの意図までは断定せず、「設計から読み取れること」と「実際に見えたこと」を分けてメモしておくと、後で見返しやすくなります。

次の行動

まず上のHTMLを保存し、表の4行を順に試してください。どの条件を外すと偽のメッセージが受理されるかが、画面の行で確かめられます。そのあと、気になる作品を開いて、開発者ツールで iframe の sandbox 属性と src のドメインを読んでみてください。

参考資料

  • MDN Web Docs「Window: postMessage() メソッド」
  • MDN Web Docs「Crypto: getRandomValues() メソッド」
  • MDN Web Docs「iframe 要素の sandbox 属性」

出典・参考資料