Postviewer v5² Writeup by @terjanq
Google CTF 2025
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.
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.
Unlike in previous years, players could share their own files through a simple postMessage
-based feature.
Technically, SafeContentFrame was already featured in the Game Arcade challenge in Google CTF 2024. Here are the important aspects of how it works:
- 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
. - The
<hash>
is calculated assha256("google-ctf" + "$@#|" + salt + "$@#|" + "https://postviewer5.com");
, wheresalt
is transmitted in thepostMessage
explained later. - 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.
The challenge had a couple of different functionalities, which are briefly described in this section.
As in previous years, it was possible to display any file by adding #<N>
to the URL.
The main functionality of the challenge was rendering files inside SafeContentFrame in two modes:
- 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. - Non-cached mode: The
salt
is derived fromMath.random()
.
The application listened for onmessage
events, making it possible to transfer a file from any origin with the following customizable features:
- Filename
- Cached mode
Players can supply an arbitrary https?://
URL, which causes an automated bot to perform the following steps:
- Visit the page URL on
http://localhost:1338
. - Add a
non-cached
file with its content set to the plaintext flag. - Visit the player's supplied URL.
- Close the browser after 5 minutes.
Unlike in previous years, this challenge used only one iframe to render content. The content rendering process can be summarized as follows:
- Calculate the file's hash and construct a valid SafeContentFrame.
- Register an
onload
event. - 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 theReloading iframe
response that signals a successful load of the file. - Remove the
onload
event listener.
See the diagram:
The solution consists of the following steps:
- Share a
non-cached
file with the admin that leaksonmessage
events. - Using a race condition, leak the transmitted
salt
that is derived fromMath.random()
. - From the leaked salt, recover five random numbers that were encoded as
base36
. - Crack the pseudorandom number generator and predict future random numbers.
- Find a salt prediction where five concatenated random numbers are shorter than 51 characters.
- 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. - 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). - "Burn" random salts longer than 50 characters by making the app render a
non-cached
file until the prediction occurs. - 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.
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:
- Register
onload
listener. - The shim emits an
onload
event. onload
:postMessage({body: file, salt}, fileOrigin)
- The iframe is redirected before
Reloading iframe
is sent. - A new
onload
is scheduled, and the iframe goes back to the shim. - The shim schedules
Reloading iframe
and loads thefile
. - The scheduled
onload
event arrives. onload
:postMessage({body: file, salt}, fileOrigin)
- The application receives
Reloading iframe
and removes theonload
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:
onload
scheduled by the shim iframeonload
scheduled by the additional blob iframeReloading iframe
scheduled by the shim iframeonload
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:
shareFile(redirectFile, cached=true)
- Wait for the
redirectFile
to fully render. shareFile(saltFile, cached=false)
- Slow down the challenge's process via a chosen gadget.
- The
onload
from theredirectFile
is scheduled just before thesaltFile
is rendered. - The
saltFile
is fully rendered (which schedulesReloading iframe
). - The process becomes unblocked.
- The application receives the
onload
event and sends thesalt
.
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:
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()
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.
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! :)