Skip to content

Instantly share code, notes, and snippets.

@terjanq
Last active July 2, 2025 21:35
Show Gist options
  • Save terjanq/e66c2843b5b73aa48405b72f4751d5f8 to your computer and use it in GitHub Desktop.
Save terjanq/e66c2843b5b73aa48405b72f4751d5f8 to your computer and use it in GitHub Desktop.
Postviewer v5 writeup - Google CTF 2025

Postviewer v5² Writeup by @terjanq

Google CTF 2025

Introduction

Postviewer challenges have become a highlight of the Web category of Google CTF, and this year featured yet another continuation of the series—Postviewer v5². There were two versions of the same challenge; the core challenge was for Chrome, and the other was for Firefox, called Postviewer v5² (FF).

This year, I intended the core challenge to be difficult, and this was indeed the case, given that only two teams managed to retrieve the flag: justCatTheFish and Friendly Maltese Citizens.

I was very excited about this year's edition because it featured a production version of SafeContentFrame, a library for rendering active content that I have been developing at Google bascially since I joined. A blog post about it is coming soon!

The library is developed to be difficult to misuse, and the challenge featured one of the ways this could happen with a tricky race condition.

Application TL;DR

The frontend of the Postviewer was almost the same as in previous years: a simple page that lets users preview files stored in the browser's IndexedDB. Each file is rendered inside the aforementioned SafeContentFrame, as shown in the diagram.

app-diagram

Unlike in previous years, players could share their own files through a simple postMessage-based feature.

SafeContentFrame TL;DR

Technically, SafeContentFrame was already featured in the Game Arcade challenge in Google CTF 2024. Here are the important aspects of how it works:

  1. A shim iframe is hosted on a secure origin in the form of https://<hash>-h748636364.scf.usercontent.goog/google-ctf/shim.html?origin=https://postviewer5.com.
  2. The <hash> is calculated as sha256("google-ctf" + "$@#|" + salt + "$@#|" + "https://postviewer5.com");, where salt is transmitted in the postMessage explained later.
  3. The shim iframe can be simplified to the snippet below:
<script>
  onmessage = evt => {
    if(originFromUrl === evt.origin
      && VERIFY_HASH(productFromUrl, originFromUrl, evt.data.salt)
    ){
      var blob = new Blob([evt.data.body], evt.data.mimeType);
      evt.ports[0].postMessage("Reloading iframe");
      location.replace(URL.createObjectURL(blob));
    }
  }
</script>

The threat model is that even if the secure origin is leaked to a malicious website, it won't be able to execute arbitrary JavaScript on that origin. This is because originFromUrl is part of the hash, and the shim only accepts messages from that origin.

The salt is used to isolate two different iframes from each other because otherwise, they would end up on the same origin, allowing a malicious document to steal other documents.

Challenge Functionality

The challenge had a couple of different functionalities, which are briefly described in this section.

Previewing Files

As in previous years, it was possible to display any file by adding #<N> to the URL.

Cached Mode

The main functionality of the challenge was rendering files inside SafeContentFrame in two modes:

  1. Cached mode: The salt, mentioned in the previous section, is generated from the hash of the file's contents or its filename if the filename is longer than the hash.
  2. Non-cached mode: The salt is derived from Math.random().

Sharing Files

The application listened for onmessage events, making it possible to transfer a file from any origin with the following customizable features:

  1. Filename
  2. Cached mode

Admin Bot

Players can supply an arbitrary https?:// URL, which causes an automated bot to perform the following steps:

  1. Visit the page URL on http://localhost:1338.
  2. Add a non-cached file with its content set to the plaintext flag.
  3. Visit the player's supplied URL.
  4. Close the browser after 5 minutes.

One Iframe

Unlike in previous years, this challenge used only one iframe to render content. The content rendering process can be summarized as follows:

  1. Calculate the file's hash and construct a valid SafeContentFrame.
  2. Register an onload event.
  3. Upon receiving the onload event, send data to the SCF (this includes the file's contents, mime-type, and the aforementioned salt) and wait for the Reloading iframe response that signals a successful load of the file.
  4. Remove the onload event listener.

See the diagram:

scf-diagram

Solution

The solution consists of the following steps:

  1. Share a non-cached file with the admin that leaks onmessage events.
  2. Using a race condition, leak the transmitted salt that is derived from Math.random().
  3. From the leaked salt, recover five random numbers that were encoded as base36.
  4. Crack the pseudorandom number generator and predict future random numbers.
  5. Find a salt prediction where five concatenated random numbers are shorter than 51 characters.
  6. Share a cached XSS payload with the filename set to the predicted salt and with content such that its hash is shorter than the filename's length.
  7. From the XSS payload, transmit it to the calculated secure origin (e.g. https://4petu6f8l4vwqn1261qrzwlv9vap3l9mqsuspa11cy50s3ovqy-h748636364.scf.usercontent.goog) and store a reference to the iframe there (this was required because the iframe was hidden behind a shadowRoot).
  8. "Burn" random salts longer than 50 characters by making the app render a non-cached file until the prediction occurs.
  9. From the transmitted XSS payload, access the iframe containing the flag and read its contents (this is possible because the origins are the same).

The most difficult, and also the core idea of the challenge, was winning the seemingly impossible race condition.

Race Condition

The race condition to leak the salt is quite easy to win for cached files. Imagine the following file:

<script>onmessage=e=>leak(e.data.salt)</script>

In cached mode, its secure origin will always be the same. Imagine sharing this file 100 times in rapid succession via the postMessage functionality.

1. share(file, cached=true)
2. share(file, cached=true)
...
99. share(file, cached=true)
100. share(file, cached=true)

The application will schedule 100 distinct onload events, and for each one, it will send postMessage({body, mimeType, salt}, iframeOrigin) to the shared iframe. The application only removes the onload event listener after receiving the Reloading iframe event. In theory, if we change the iframe's destination, the Reloading message will never arrive, and the salt will be indefinitely transferred to the iframe upon any iframe reload, making it trivial to leak. However, if I recall correctly, this didn't happen in practice, potentially due to how processes are allocated for same-origin iframes.

But we don't need this behavior to leak the salt. Notice that the application has already scheduled 100 onloads, and after the first successful load of the file, it will simply receive the salt for the onload events scheduled for the consecutive files.

But for cached files, the salt isn't derived from Math.random, so this race condition is quite useless (although it could be used to leak the flag without realizing how to bypass the shadowRoot limitation).

Let's modify the approach slightly and analyze what would happen in the following scenario:

1. share(file, cached=false)
2. // some timeout
3. iframe.location = "blob: onload=()=>window.history.back()"

What we want to achieve on the application side is the following:

  1. Register onload listener.
  2. The shim emits an onload event.
  3. onload: postMessage({body: file, salt}, fileOrigin)
  4. The iframe is redirected before Reloading iframe is sent.
  5. A new onload is scheduled, and the iframe goes back to the shim.
  6. The shim schedules Reloading iframe and loads the file.
  7. The scheduled onload event arrives.
  8. onload: postMessage({body: file, salt}, fileOrigin)
  9. The application receives Reloading iframe and removes the onload listener.

In the scenario above, we've won the race if the scheduled onload event from the additional iframe load arrives before the shim sends Reloading iframe. This means the salt was transmitted a second time, allowing us to leak it.

In practice, however, the scheduled Reloading iframe message would arrive before the file had a chance to render and intercept the message. This makes perfect sense because the file is only rendered after the Reloading iframe message is sent. So how to win this "impossible" race?

The answer is slowing down the process!

I left two intentional and one unintentional gadget for doing just that. See the comments below:

window.onmessage = async function(e){
        // intentional, same as in Postviewer v1 players could
        // send a large chunk of data to cause the loose comparison to be slow
        if(e.data.type == 'share'){

          // intentional, gives the ability to execute an arbitrary number of
          // loop iterations, e.g. {file: {length: 1e8}}
          for(var i=0; i<e.data.files.length; i++){
            ...
          }
        }
        // unintentional, debug leftover which does essentially the same
        // as files.length gadget
        if(e.data.slow){
          for(i=e.data.slow;i--;);
        }
}

Slowing down a process is helpful in winning race conditions, thanks to process isolation. Even when the challenge application's main thread is blocked, cross-origin iframes can continue their calculations and event scheduling just fine. Let's once again review the events received by the challenge application:

  1. onload scheduled by the shim iframe
  2. onload scheduled by the additional blob iframe
  3. Reloading iframe scheduled by the shim iframe
  4. onload scheduled by the loaded file

What we need to achieve is for the salt-leaking file to be fully rendered before the onload scheduled by the additional blob iframe is received by the application. With some small timeouts, this could be achieved quite easily.

This race would be quite easy to win by embedding the challenge page inside an iframe because iframe.location could be directly invoked as in the example shown. However, the application was not frameable. In contrast to the embedding case, it is only possible to change an iframe's location if it is same-origin with the window that initiates the redirection.

This constraint makes winning the race more difficult, but not impossible!

My approach for winning the race in the popup case was slightly different. Instead of redirecting the iframe, I shared a file that indefinitely redirects itself. The file can be simplified to:

<script>
    setTimeout(()=>{
      location = URL.createObjectURL(new Blob([document.documentElement.innerHTML], {type: 'text/html'}))
    }, 150);
</script>

After the file above is shared, The salt-leaking file is shared with the admin:

<script>onmessage=e=>leak(e.data.salt)</script>

What needs to happen to win the race is:

  1. shareFile(redirectFile, cached=true)
  2. Wait for the redirectFile to fully render.
  3. shareFile(saltFile, cached=false)
  4. Slow down the challenge's process via a chosen gadget.
  5. The onload from the redirectFile is scheduled just before the saltFile is rendered.
  6. The saltFile is fully rendered (which schedules Reloading iframe).
  7. The process becomes unblocked.
  8. The application receives the onload event and sends the salt.

In principle, this looks reasonable and not too hard to implement. However, in practice, my exploit didn't work all the time, as I had tuned the timings suboptimally and taken the lazy path. I was pretty sure this could be coded better than my exploit, and the teams that solved the challenge proved this to be the case, leaking the salt very consistently.

You can read the full exploit in exploit-chrome.html.

See exploit in action:

demo

Race Condition in Firefox

Winning the race in Firefox was pretty easy. For some reason, if the challenge's process is slowed down, the Reloading iframe event is executed after the onload listener.

This snippet won the race with almost 100% accuracy.

    const buff = new Uint8Array(3e7);
    shareFile(blobSalt, 'blobsalt');
    // delay around 2.5s
    appWin.postMessage({ type: buff  }, '*', [buff.buffer])

    window.interval = setInterval(() => {
      const buff = new Uint8Array(2e7);
      appWin.postMessage({ type: buff  }, '*', [buff.buffer])
    }, 100);

    setTimeout(()=>{
      clearInterval(window.interval);
    }, 3_000)

You can read the full exploit in exploit-firefox.html, but it's almost the same as the Chrome one, with the difference being in this part.

Predicting Math.random()

Predicting Math.random() was quite straightforward in Firefox, as one could simply use a publicly available predictor.

In Chrome, it was a bit trickier because Chromium recently changed how it generates random numbers, and all publicly available predictors needed to be patched by the players.

An additional issue was that finding a salt shorter than 51 characters required generating many random numbers. Generating 5,000 random numbers gave a prediction success rate of over 90%. Some publicly available predictors (for example, this one) can only correctly generate a couple of random numbers—to be precise, fewer than 64. This is because Chromium has a RefillCache of 64 random numbers, after which it needs to regenerate them.

While applying the patches for the Chromium change, I based my predictor on the awesome research from Kalmarunionen, which predicts infinitely many random numbers. See the details of the research at https://github.com/kalmarunionenctf/kalmarctf/tree/main/2025/web/spukhafte/solution.

Closing Thoughts

Many players loved seeing another Postviewer challenge, but some also expressed disappointment, saying that the race condition part was quite painful and not fun. I feel that client-side race conditions are an under-researched topic and that even seemingly "impossible" races can be won. In my opinion, making the challenge artificially easier would not have made it a better challenge. Real-world exploitation does not compromise on the difficulty of finding the correct parameters that allow a race condition to be winnable.

This challenge featured a production version of SafeContentFrame, which is commonly used by Google products. It's designed to be difficult to misuse, and I was proud of finding a way to do just that! :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment