どういうわけか HTML5 の Canvas で描画された画像をデータとして保存したくなることがあるかもしれない。Canvas にはそのための便利なメソッドがいくつか用意されていて、例えば getImageData() を使うと Data URLs として出力できる。ところがこれらのメソッドは常に使えるわけでなく、特定の条件を満たさない場合には Canvas のセキュリティ保護機能によりエラーになってしまう。

セキュリティ保護は Web ブラウザのコードで実装されており、JavaScript 単体でこれを解除してやる方法は (たぶん) 無い。でも、何らかの理由でどうしても描画された画像データを取り出したい、そういう時にどうすればいいかと言うと、Firefox を改造してビルドする方法がある。

なお、悪用厳禁な気がするので本記事では詳細を語らず、Firefox のビルド方法だけ紹介することとする。

Canvas のセキュリティ保護機能

Canvas では秘密の情報が漏洩することを防ぐためセキュリティ保護の仕様が用意されている。仕様が設けられているのは HTMLCanvasElement のメソッド toDataURL()toBlob()、CanvasRenderingContext2D の getImageData() だ。これらのメソッドを使うと Canvas 要素のビットマップに描画された画像データをシリアライズして文字列で取り出すことができる。ビットマップには img タグでどこからでも持ってきた画像データを描くことができるので、機密情報だろうが、そうじゃなかろうか、何でも描画できるということだ。だから、例えば誰かが LAN 内の秘密の画像を表示する Web ページを img タグへ読み込ませてきた場合に、セキュリティ保護が無ければ、その画像情報を Canvas に読み込んでシリアライズして XHR で任意のサーバーへ送信することができてしまう。

ここで Canvas の当該メソッドでは、Canvas のビットマップが origin-clean でない時にはシリアライズできない、というルールを設けている。ビットマップの描画に使った ImageBitmapcross-origin なものが有った場合に origin-clean では無くなる。cross-origin な画像というのはつまり、JavaScript が実行されたページとは異なるドメインの URL から読み込んだ画像で、かつ CORS ヘッダにより許可されていないものだ。origin-clean でないビットマップを含む Canvas 要素に対し toDataURL()toBlob()getImageData() を呼び出すと SecurityError を発生させ失敗する 1。こうすれば先の例のように、インターネット上の Web ページを開いて LAN 内の秘密の画像を img タグに表示させられたとしても、Canvas を通じてシリアライズできないので情報の漏洩を防ぐことができる。

以下の HTML コードは、YouTube の画像を全然関係無いページで表示し、それを Canvas に読み込んで toDataURL() する例である。コード内で Canvas に描画する YouTube の画像は、普通 origin-clean では無いので toDataURL()SecurityError で失敗する。

実験してみると Chromium (Vivaldi) では次のエラーが発生した。

SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tained canvases may not be exported.

Firefox でも同様に SecurityError した。

SecurityError: The operation is insecure.

HTML ページ:

<!DOCTYPE html>
<html>
  <body>
    <p>Source:</p>
    <img id="source" width="200" height="200" src="https://www.gstatic.com/youtube/img/branding/favicon/favicon_144x144.png" alt="">
    <p>Canvas:</p>
    <canvas id="canvas" width="200" height="200" style="border:1px solid #d3d3d3;">
      Your browser does not support the HTML5 canvas tag.
    </canvas>
    <p>
      <button id="go">canvas.toDataURL()</button>
      <textarea id="data"></textarea>
    </p>
  <script>
window.addEventListener("load", () => {
  var canvas = document.getElementById("canvas");
  var img = document.getElementById("source");
  canvas.getContext("2d").drawImage(img, 0, 0);
  document.getElementById("go").addEventListener("click", () => {
    try {
      document.getElementById("data").value = c.toDataURL();
    } catch (e) {
      alert(e);
    }
  });
});
    </script>
  </body>
</html>

せっかくだから CodePen でも試せるようにしてみたCanvas.toDataURL() ボタンを押すと失敗してアラートダイアログが出現する。

さて Canvas のセキュリティ保護機能により期待通りエラーになることがわかったが、何らかの理由でどうしてもデータを取得したい場合もあるだろう。もしかしたらセキュリティホールも存在するかもしれないが、正攻法ではたぶん無理だ。しかし自分で一時的な用途に使うだけなら Web ブラウザのソースコードを書き換えてしまえば当然、回避できる。そこで以下では Firefox をビルドする方法を紹介する。なお冒頭に記述した通り、具体的な改造方法は書かない。

Firefox のビルド

Firefox はご存知の通りオープンソースで開発されており、ビルド方法も 公式サイト で丁寧に紹介されている。だから公式の手順を踏むだけで良い。

今回は依存ライブラリ等をインストールしてデスクトップ環境を汚したくないので、ビルド用の Docker イメージを作成し、その中で作業できるようにした。デスクトップ環境の各環境は以下の通り:

> cat /etc/fedora-release
Fedora release 33 (Thirty Three)
> uname -r
5.10.22-200.fc33.x86_64
> docker --version
Docker version 20.10.5, build 55c4c88

なお Fedora では Wayland が標準になっているが、うちでは X11 を使う。

> echo $XDG_SESSION_TYPE
x11

まずは Dockerfile を次のように書く。

# Dockerfile
FROM ubuntu:latest

RUN apt update && \
  apt install -y git python3 python3-distutils python3-pip \
  mercurial \
  fonts-takao-gothic fonts-takao-mincho

docker コマンドで Docker イメージをビルドする。

$ docker build -t firefox-build .

イメージが用意できたらコンテナを起動して、Bash で入る。このとき少し工夫して、ホスト側の DISPLAY 変数と X11 のソケットファイル .X11-unix と認証情報 Xauthority を渡してあげると、コンテナ内で Firefox を起動したときに、ホスト側画面にウィンドウが表示できるようになった。

$ docker run --rm -it -v $PWD:/usr/src -v /tmp/.X11-unix:/tmp/.X11-unix -v /run/user/1000/gdm/Xauthority:/root/.Xauthority -w /usr/src -e DISPLAY=$DISPLAY firefox-build bash

なお Xauthority のパスは xauth で調べることができる。

$ xauth info
Authority file:       /run/user/1000/gdm/Xauthority
File new:             no
File locked:          no
Number of entries:    2
Changes honored:      yes
Changes made:         no
Current input:        (argv):1

コンテナに入れたら Firefox のソースコードを Mercurial でダウンロードする。今回はなんとなくコンテナ上で取得したが、好みでホスト側にて先に取得しておいても良い。

$ hg clone https://hg.mozilla.org/mozilla-central/

ソースコードを適当に編集する。Canvas の挙動は mozilla-central/dom/html/HTMLCanvasElement.cpp に実装されている。

満足に書き換えられたらビルドする。開発環境の構築 (bootstrap) からビルド (build)、実行 (run) まで全て mach で行うことができる 2mach bootstrap を実行すると対話的に環境構築が行なわれるが、この時に Artifact Mode にするか選べる。Artifact Mode では C++ と Rust のコードをビルドしない (事前に生成したバイナリをダウンロードする) ため、Canvas の挙動を変更するならば、無効にする必要がある。また、Mercurial の設定に入ると面倒臭いのでコミットする予定が無いならスキップしてしまってよい。

$ ./mach bootstrap # 環境構築
$ ./mach build     # ビルド

ビルドできたら Firefox を起動して遊ぶ。

$ ./mach run

謎のスクリプト

async function digest(alg, message) {
  const bytes = new TextEncoder().encode(message);
  const buffer = await crypto.subtle.digest(alg, bytes);
  const hash = Array.from(new Uint8Array(buffer));
  return hash.map(b => b.toString(16).padStart(2, '0')).join('');
}

const downloadCanvas = (canvas, ext) => {
  const a = document.createElement('a');
  document.body.appendChild(a);
  a.href = canvas.toDataURL();
  a.download = `image${ext}`;
  a.target = '_blank';
  a.click();
  a.remove();
  console.log('Downoading', canvas);
}

const downloadF = (ext) => {
  Array.prototype.slice.call(document.querySelectorAll('canvas'), 0)
    .reverse()
    .forEach(c => downloadCanvas(c, ext));
}

const downloadD = (ext) => {
  Array.prototype.slice.call(document.querySelectorAll('canvas'), 0)
    .filter(e => e.className != 'dummy' && e.style.width)
    .reverse()
    .forEach(c => downloadCanvas(c, ext));
}

const downloaderH = (ext) => {
  const done = new Set();
  const download = async () => {
    const canvas = document.querySelector('canvas');
    const hash = await digest('SHA-1', canvas.toDataURL());
    if (!done.has(hash)) {
      downloadCanvas(canvas, ext);
      done.add(hash);
    }
  };
  let obj = {
    timer: -1,
    start: () => {
      obj.timer = setInterval(download, 100);
    },
    stop: () => {
      clearInterval(obj.timer);
      obj.timer = -1;
    }
  };
  return obj;
}

謎のスクリプト2

少し手を加えた。

const digest = async (alg, message) => {
  const bytes = new TextEncoder().encode(message);
  const buffer = await crypto.subtle.digest(alg, bytes);
  const hash = Array.from(new Uint8Array(buffer));
  return hash.map(b => b.toString(16).padStart(2, '0')).join('');
};

const downloadCanvas = (canvas, ext) => {
  const a = document.createElement('a');
  document.body.appendChild(a);
  a.href = canvas.toDataURL();
  a.download = `image${ext}`;
  a.target = '_blank';
  a.click();
  a.remove();
  console.log('Downoading', canvas);
}

class DDownloader {
  constructor(ext) {
    this.ext = ext;
    this.done = new Set();
  }
  async download() {
    Promise.all(
      Array.prototype.slice.call(document.querySelectorAll('canvas'), 0)
        .filter(e => e.className != 'dummy' && e.style.width)
        .reverse()
        .map(async c => {
          const hash = await digest('SHA-1', c.toDataURL());
          if (!this.done.has(hash)) {
            downloadCanvas(c, this.ext);
            this.done.add(hash);
          }
        })
    );
  }
}

class Timer {
  constructor(f) {
    this.f = f;
    this.timer = -1;
  }
  start() {
    this.timer = setInterval(() => this.f(), 100);
  }
  stop() {
    clearInterval(this.timer);
    this.timer = -1;
  }
}

let d = new DDownloader('.jpg');
let t = new Timer(() => d.download());
t.start();

参考資料