← 他の記事もよんでみる
Available languages:
日本語English

DOMDOMタイムス#19: iframeを新たに作りさえすれば新鮮とれたてプロトタイプが手に入るの?

目次

ひさしぶりのDOMDOMタイムスです!ブログ移行後は初めてです👶
過去のDOMDOMタイムスはZennの方にありますので、読んでみたい人は読んでみてください!

https://zenn.dev/canalun

今回は、以前にDOMDOMタイムスで紹介した内容についてアップデート的な感じをやりたいと思います。

新鮮とれたてプロトタイプってなんすか?iframe作るってどういうことすか?

問題意識や前提知識に関するすべての説明を下記の記事に託して、この部分はすっ飛ばさせてください!
本稿で話されていることが「なにいってだこいつ」状態だったら、下記の記事を読んでみてくださいネ。

https://zenn.dev/canalun/articles/domdomtimes_get_prototype_from_iframe

ちなみに、この記事を出したあとにプロトタイプ汚染周りのECMAScript提案について色々まとめる機会があったので、よければ読んでみてください。スライドでもよければ日本語版があります👶

https://medium.com/@i.am.kanaru.sato/override-or-be-overridden-or-what-javascript-client-side-prototype-override-11d7a56ce9a3

iframeを新たに作りさえすれば新鮮なプロトタイプが必ず手に入るのか?

本題に入りましょう。結論から言うと、こんな記事を書くくらいなのでNOです。
もうすこしちゃんと言うと、createElementcontentWindowの挙動が特定のやり方で変更されている状況においては、新鮮なプロトタイプの入手が不可能になります。

まずは素直に: createElementを汚染されるとダメ

iframeを作ってプロトタイプを取り出すコードは、たとえば下記のようなものです(さっき紹介した自分の記事から引用)。

const iframe = document.createElement('iframe')

// iframeはDOMツリーに接続して初めてDocumentを作ってくれるので接続する
iframe.style.display = 'none';
document.body.insertAdjacentElement('beforeend', iframe);

// iframeから取ってくる
const originalPromise = iframe.contentWindow.Promise

// iframeを消す
iframe.remove()

これは残念ながらcreateElementに爆弾を仕込まれるとダメです!
つまりcreateElement自体に「iframeを作ったら、そのiframeにPromiseを書き換えるようなloadイベントを仕込んでね〜」と教えておかれると、作ったばかりのiframeからでさえ新鮮なPromiseを取り出すことはできなくなってしまいます。具体的な爆弾製造装置として下記のようなスクリプトが挙げられます。

const originalCreateElement = Object.getOwnPropertyDescriptor(
  document.__proto__.__proto__,
  "createElement",
).value;
Object.defineProperty(document.__proto__.__proto__, "createElement", {
  ...Object.getOwnPropertyDescriptor(
    document.__proto__.__proto__,
    "createElement",
  ),
  value: function (tagName, options) {
    const element = originalCreateElement.call(this, tagName, options);
    if (tagName === "iframe") {
      element.addEventListener("load", () => {
        try {
          element.contentWindow.Promise = "I am polluted...!";
        } catch (e) {
          // iframe might be cross-origin
        }
      });
    }
    return element;
  },
});

実際、やってみるとこんな感じです👶キャー!(トムブラウン)
iframeからでさえも新鮮なPromiseが取り出せなくなっていますねえ。

先ほどのcreateElementに細工するスクリプトの実行後に、iframeを作ってプロトタイプを取り出すコードを実行したときのDevToolsの様子。nativeのプロトタイプは取り出せなかったことがわかる

ちなみに参考までに、何もされていないコンテキストで普通にうまくいくときの様子がこちらです。

iframeを作ってプロトタイプを取り出すコードだけを実行したときのDevToolsの様子。nativeのプロトタイプが取り出せていることがわかる

もう少しちゃんと: contentWindowを汚染されるとダメ

createElementを経由しないでiframeを作れば邪魔されずになんとかなるでしょうか?
innerHTMLを使ってiframeをつくり、そこから新鮮プロトタイプを取り出すことを考えます。

const div = document.createElement("div");
div.id = 'prototype_fountain_parent'

// iframeを追加して、そのままPromiseを取得する。終わったらdivごと消える。
// innerHTMLでscriptタグを差し込んでも実行されない(ブラウザ自体のXSS対策)ので回りくどいことをしています👶
const script = `
originalPromise = prototype_fountain.contentWindow.Promise;
prototype_fountain_parent.remove();
`
div.innerHTML = `
<iframe id="prototype_fountain" style="display:none;"></iframe>
<img src onerror="${script}">
`;

document.body.insertAdjacentElement('beforeend', div);

ひとまず、これはこれでちゃんとワークすることが確認できます :)
(もちろんWebページのCSP設定次第で動かないことがあります!)

上のスクリプトが成功している様子

では、contentWindowが汚れていたらどうなるでしょうか。下記を先に実行しておきます。

const originalContentWindow = Object.getOwnPropertyDescriptor(
  HTMLIFrameElement.prototype,
  "contentWindow",
).get;
Object.defineProperty(HTMLIFrameElement.prototype, "contentWindow", {
  ...Object.getOwnPropertyDescriptor(
    HTMLIFrameElement.prototype,
    "contentWindow",
  ),
  get() {
    const contentWindow = originalContentWindow.call(this);
    if (!contentWindow) {
      return contentWindow;
    }
    contentWindow.Promise = "i am polluted, again...!";
    return contentWindow;
  },
});

はい、残念ですがこのスクリプトによりinnerHTML作戦も失敗するようになります。うむうむ。

上記のスクリプトによってinnerHTMLを使った先ほどのスクリプトも失敗していることがわかるDevToolsの様子

なんなら、さっきのcreateElement作戦もcontentWindowを経由しているので、このスクリプトで失敗させられます。

上記のスクリプトによってcreateElementを使った最初のスクリプトも失敗することがわかるDevToolsの様子

たぶんですが、ここから先はイタチごっこです。
なんかもっと作戦あるかもですが、たぶん色々邪魔されて新鮮なプロトタイプの買い付けは失敗に終わると思います😂
でももし面白いパターンがあったら教えてください!:)

iframeからの新鮮プロトタイプ入手をセキュリティ対策としてやっている人は注意してね

iframeからの新鮮プロトタイプ入手をセキュリティ対策としてやっている方は注意してください!
つまり、例えば「この機密情報をフロントエンドでサクッと扱いたいんだけど、もしもプロトタイプ汚染があったら簡単に盗まれるよな。うんうん、一旦iframeを作って、新鮮なプロトタイプで処理しよう。そうすれば安全だろう」という発想です。

以下、立場を分けてもう少し具体的に考えてみましょう。

1. あなたがwebページ開発者なら

webページで使われているライブラリは容易にiframeの「内部」を汚染できます。これはサプライチェーンアタックの文脈で特に現実的な話です(膨大な依存関係の中にいるライブラリがある日、プロトタイプを汚染してくる可能性があるというわけ)。
もしiframeからの新鮮プロトタイプ入手をそれに対するセキュリティ対策としてやっていたら、それはセキュリティ対策として成立しないので注意してください👶サプライチェーンアタックに対する対策を徹底することが必要だと思います。

2. あなたが拡張開発者なら

ちなみに拡張の開発者の方も注意してください!
「いやうちのスクリプトはdocument_startで誰よりも早く動くから……」と思っていませんか?

少なくともChromiumでは、新しく作られたiframeについて、その中でdocument_startのcontent scriptが動くよりも先に親コンテクストがiframeコンテクストのプロトタイプを操作できることがわかっています
つまり、ページでiframeが新たに作られたときにそのiframeの中のmain worldであなたの拡張が何か機密を扱うスクリプト処理を行うようになっている場合、まずいということです。

このChromiumのバグは直された方がいいんじゃないかなと思いますが、ずっと放置されているので多分このままです😂<-笑っている場合ではない

誰かに新鮮なプロトタイプを使ってほしくないと思っている人へ(攻撃者に限らず、たまにいる)

この記事のテクニックを使ってみてください!

ということで

今日のDOMDOMタイムスはここまでです👶
ちょっとマニアックな話でしたが久々に書けてよかったです!

ちなみにAgoricのSESは、このプロトタイプ汚染によるセキュリティ問題を強く意識したプロジェクトです。興味がある人は見てみてください。

https://github.com/tc39/proposal-ses?tab=readme-ov-file#summary

また、再掲になりますがSES周辺のES提案については下記(スライドなら日本語版があります)を読んでみてください!

https://medium.com/@i.am.kanaru.sato/override-or-be-overridden-or-what-javascript-client-side-prototype-override-11d7a56ce9a3

なお、、、

← 他の記事もよんでみる