ブログに戻る

フォロー&ご登録

英語のみで利用可能

このページは現在英語でのみ閲覧可能です。ご不便をおかけして申し訳ございませんが、しばらくしてからこのページに戻ってください。

Compute で PNG をフィルタリングして Acropalypse 脆弱性を回避

Andrew Betts

Principal Developer Advocate, Fastly

「Acropalypse」は、先日 Simon Aarons 氏と David Buchanan 氏によって発見された脆弱性</u>で、Android の Markup 編集アプリを使用してクロップされた画像には、クロップ前の画像の多くの部分が隠れた状態で残っていることが多々あるというものです。クロップしたはずの部分が画像に残っている可能性があることをユーザーが知らないため、この脆弱性によってプライバシーの問題も発生します。例えば、スマートフォンでスクリーンショットを撮ってクロップされた部分には、個人情報やアカウント名、通知メッセージの内容など、プライバシーに関わるさまざまなデータが含まれている可能性があります。Buchanan 氏は、エッジにフィルターをデプロイし、CDN でこの脆弱性を透過的に対処する</u>ことで、この問題に対する責任からユーザーを解放し、インターネットをより良い世界にすることができると述べています。 

そこで、私たちはこの課題に挑戦することにしました。

Markup 編集アプリに存在したこの問題の根本的原因は修正済みですが、取り返しのつかない大きなダメージがすでに生じています。修正前にクロップされた画像は今でもインターネット上のさまざまなプラットフォームやサイトに残っており、Markup アプリが修正されても、デバイスを離れてすでに公開されている画像から、クロップされた部分を遡及的に削除することはできないためです。つまり、アプリ修正前に不適切にクロップされソーシャルプラットフォームにアップロードされた画像は、現在も公開され続けており、画像作成者はプライバシー侵害のリスクにされされています。

Fastly の Compute は、このような問題を素早く解決できます。Fastly のエッジ経由で配信されるアプリやサービスを通過するすべての画像からクロップされたデータを取り除くことで、この問題を解決できます。

手近な解決策

Fastly の画像最適化機能によって、通過するあらゆる画像から追跡目的のコンテンツを削除できることは、すでに確認済みです。すなわち、Fastly サービスでイメージオプティマイザー (IO) を使用している場合、画像をフィルタリングできるということであり、同様にクロップされたデータも削除できることを意味します。

しかし、すべてのお客様が Fastly サービスで IO を使用されているわけではありません。IO を使用されていないお客様はどうすべきでしょうか?

問題の本質を理解する

PNG ファイルには、「マジックバイト」と呼ばれる、このファイルタイプの特定を可能にするバイトシーケンスが使用されていることはよく知られています。PNG ファイルのマジックバイトは以下のとおりです。

89 50 4E 47

あらゆる PNG ファイルの hexdump 出力で、このバイトシーケンスを確認できます。

○ head -c 100 Screenshot\ 2023-03-01\ at\ 10.35.01.png | hexdump -C
00000000  89 50 4e 47 0d 0a 1a 0a  00 00 00 0d 49 48 44 52  |.PNG........IHDR|
00000010  00 00 06 cc 00 00 04 e6  08 06 00 00 00 85 ef 84  |................|
00000020  f2 00 00 0a aa 69 43 43  50 49 43 43 20 50 72 6f  |.....iCCPICC Pro|
00000030  66 69 6c 65 00 00 48 89  95 97 07 50 53 e9 16 c7  |file..H....PS...|
00000040  bf 7b d3 43 42 49 42 28  52 42 6f 82 74 02 48 09  |.{.CBIB(RBo.t.H.|
00000050  a1 85 de 9b a8 84 24 40  28 21 26 04 05 bb b2 b8  |......$@(!&.....|
00000060  82 6b 41 44                                       |.kAD|
00000064

また、PNG ファイルの hexdump 出力は「IEND」と呼ばれる似たようなマーカーで終了し、バイトストリームで簡単に見つけられます。4つの null バイトのシーケンスと ASCII 文字「IEND」で構成されます。

○ tail -c 100 Screenshot\ 2023-03-01\ at\ 10.35.01.png | hexdump -C
00000000  98 cf ff 9d 2f 79 c0 7b  c3 ec de 7b ef 8d 1b 96  |..../y.{...{....|
00000010  2c 0b 1e f0 24 c3 a2 32  f9 4a fd 95 c6 3a 7c 13  |,...$..2.J...:|.|
00000020  a1 1a 78 6c 47 70 c6 fb  dc 41 dd f2 8d 4b 79 d1  |..xlGp...A...Ky.|
00000030  35 f2 6b 89 ef f0 d8 3e  e8 ed 23 70 c6 3f 5e 6f  |5.k....>..#p.?^o|
00000040  6f 98 75 80 4b 7c da 01  1e 1c 72 80 93 a8 ff 37  |o.u.K|....r....7|
00000050  c8 1a 61 82 9a 74 43 f6  00 00 00 00 49 45 4e 44  |..a..tC.....IEND|
00000060  ae 42 60 82                                       |.B`.|

問題は、Android の編集ツールを含む一部のツールでは、画像をクロップした際に、ファイルの終わりにある余ったスペースに元の画像データが含まれてしまうことにあります。

Aarons 氏はツイッターの投稿でこの問題を分かりやすく解説しています。

この脆弱性によるユーザーの個人情報の漏えいを防ぐために何ができるでしょうか?

Compute で解決

Fastly Fiddle を使ってエッジでコードを書いてみましょう。まず、リクエストをバックエンドに送信し、レスポンスをクライアントに返す、基本的なリクエストハンドラーを作成します。

このような感じです。

addEventListener("fetch", event => event.respondWith(handleRequest(event)));

async function handleRequest(event) {
  const response = await fetch(event.request, {backend: 'origin_0'});
  return acropalypseFilter(response);
}

function acropalypseFilter(resp) {
  return resp;
}

Fiddle では、このようになります。

http-me.glitch.me をバックエンドとして使用するように Fiddle を設定できます。これは Fastly が管理しているサーバーで、レスポンスを返すことを目的とし、このようなテストを行うのに便利です。HTTP-me が PNG 画像を返すよう、パスを /image-png に設定します。

以下のように acropalypseFilter 関数を作成しました。

function acropalypseFilter(response) {
  // Define the byte sequences for the PNG magic bytes and the IEND marker
  // that identifies the end of the image
  const pngMarker = new Uint8Array([0x89,0x50,0x4e,0x47]);
  const pngIEND = new Uint8Array([0x00,0x00,0x00,0x00,0x49,0x45,0x4e,0x44]);

  // Define an async function so we can use await when processing the stream
  async function processChunks(reader, writer) {
    let isPNG = false;
    let isIEND = false;
    while (true) {

      // Fetch a chunk from the input stream
      const { done, value: chunk } = await reader.read();
      if (done) break;

      // If we have not yet found a PNG marker, see if there's one
      // in this chunk.  If there is, we have a PNG that is potentially
      // vulnerable
      if (!isPNG && seqFind(chunk, pngMarker) !== -1) {
        console.log("It's a PNG");
        isPNG = true;
      }

      // If we know we're past the end of the PNG, any remaining data
      // in the file is the hidden data we want to remove.  Since we already
      // sent the Content-Length header, we'll pad the rest of the response
      // with zeroes.
      if (isIEND) { 
        writer.write(new Uint8Array(chunk.length));
        continue;

      // If it's a PNG but we're yet to get to the end of it...
      } else if (isPNG) {

        // See if this chunk contains the IEND marker
        // If so, output the data up to the marker and replace the rest of the
        // chunk with zeroes.
        const idx = seqFind(chunk, pngIEND);
        if (idx > 0) {
          console.log(`Found IEND at ${idx}`);
          isIEND = true;
          writer.write(chunk.slice(0, idx));
          writer.write(new Uint8Array(chunk.length-idx));
          continue;
        }
      }

      // Either we're not dealing with a PNG, or we're in a PNG but have not
      // reached the IEND marker yet. Either way, we can simply copy the
      // chunk directly to the output.
      writer.write(chunk);
    }

    // After the input stream ends, we should do cleanup.
    writer.close();
    reader.releaseLock();
  }

  if (response.body) {
    const {readable, writable} = new TransformStream();
    const writer = writable.getWriter();
    const reader = response.body.getReader();
    processChunks(reader, writer);
    return new Response(readable, response);
  }
  return response;
}

では順番に見ていきましょう。

  1. レスポンスが PNG であることを示すバイトシーケンスと、レスポンスの最後に達したことを示すバイトシーケンスを含む変数を設定します。

  2. 最後の部分を見てみると、if (response.body) ブロックによって、TransformStream が作成され、

    読み込み可能な部分を含む新しい Response を返します。ここで、バックエンドレスポンスからデータを読み取り、TransformStream の書き込み可能な場所にデータをプッシュする必要があります。

  3. acropalypseFilter 関数から Response を返したいので、acropalypseFilter 関数を非同期にすることはできません (async が宣言されると、Response ではなく Promise が返されます)。そこで、processChunks という子関数を定義しました。

    こちらは非同期でも構いません。ストリームが終わる前に Response を返しても問題ない (むしろその方が望ましい) ので、Promise を待たずにこれを呼び出すことも可能です。

  4. processChunks で読み込みループを作成し、バックエンドレスポンスからデータのチャンクを読み込みます。ファイルが PNG であることを示すマジックマーカーが存在するか確認し、見つかったら PNG の終わりを示すマーカーを確認します。それも見つかったら、undefined

    それ以降はすべて「0」で置き換えられます。これがデータ漏えいを防ぐ鍵となる部分です。

なぜそのポイントでレスポンスを終了させないのかと、疑問に思われる方もいらっしゃるかもしれません。理由は、先にレスポンスのヘッダーを送信した際に、Content-Length が含まれていた可能性が非常に高く、同じ量のデータを送信する必要があるためです。

もうひとつ注目すべき点は、reader.read() メソッドで読み取られる chunkArrayBuffer であり、前述の特定のバイトシーケンスを見つけるために、それを検索する必要があります。しかし、これを JavaScript でネイティブに行うのは不可能なので、配列の中から要素のシーケンスを検索する以下の関数を作成しました。

// Find a sequence of elements in an array
function seqFind(input, target, fromIndex = 0) {
  const index = input.indexOf(target[0], fromIndex);
  if (target.length === 1 || index === -1) return index;
  let i, j;
  for (i = index, j = 0; j < target.length && i < input.length; i++, j++) {
    if (input[i] !== target[j]) {
      return seqFind(input, target, index + 1);
    }
  }
  return (i === index + target.length) ? index : -1;
}

もちろん、これには改善の余地がありますが、エッジコンピューティングを使ってできる簡単な解決策としては、十分だと思います。

この記事で使用した Fiddle はこちらでご確認いただけます。もしよろしければ、クローンして独自のバージョンを作成してみてください。