4.4 KiB
Where is the Scope?
This year we are launching our new GlacierTV website allowing you to play any video from youtube. You can also take some notes while watching them and also restrict the access to those with a 2FA token. Hope you enjoy it.
Challenge Notes
XSS via Report Function
Theory: Administrator can be XSS'ed via the "Report" function. To see if our hypothesis is correct, let's examine the relevant parts of the code.
A user clicking on the "Report" button is handled by the following code:
function onReportClick() {
document.getElementById("reportBtn").addEventListener("click", _ => {
alert('Thank you for reporting this uri. A moderator will review it soon.')
fetch("/report", {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: JSON.stringify({
path: window.location.search
})
});
})
}
The server handles post requests to the /report
endpoint in server.js
on lines 96 through 120:
app.post("/report", async (req, res) => {
try {
const path = req.body.path;
if(typeof path !== "string") return res.status(400).send("No path provided");
const uri = `http://localhost:8080/${path}`
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-setuid-sandbox"],
});
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
await page.goto('http://localhost:8080/');
await page.waitForNavigation({
waitUntil: 'networkidle0',
});
await page.evaluate(async message => {
await fetch("/setup_2fa", {method: "POST"});
await fetch("/secret_note", {
method: "POST",
body: JSON.stringify({message}),
headers: {
"Content-Type": "application/json"
}
});
}, FLAG)
await page.goto(uri);
await sleep(5000);
await browser.close();
res.status(200).send("Thank you for your report. We will check it soon")
} catch(err) {
console.log(err)
res.status(400).send("Something went wrong! If you think this is an error on our site, contact an admin.")
}
})
Walking through the /report
callback:
- The server checks the JSON body for a field called "path"
- URI is created from the value of "path":
http://localhost:8080/${path}
- A browser is launched with Puppeteer and it loads the main app
- The browser session POSTs to
/setup_2fa
to get a token - Next, the browser places the flag into a Note on the UI
- The browser then will browse to the URI created with our payload
If we can XSS the admin and steal their cookie, we can get the flag from the saved note.
The uri
parameter is vulnerable to XSS. When the site is loaded, the loadFromQuery
method is called, which pulls
out the uri
parameter and passes it to parseUI
. The URI returned by parseUI
is used as the src
attribute
of the page's iframe:
function loadFromQuery() {
const query = new URLSearchParams(window.location.search);
const source = query.get("source") || "youtube";
const uri = query.get("uri");
document.getElementById("searchInput").value = uri || "https://www.youtube.com/embed/dQw4w9WgXcQ?&autoplay=1";
if(!uri) return false;
updateSource(uri, source);
var ifconfig = {
pathname: `<iframe frameborder="0" width=950 height=570 src="${parseURI(uri)}"></iframe>`
}
document.getElementById("viewer").srcdoc = ifconfig.pathname;
return true;
}
The parseURI
method tries to create a URL
object from the passed in uri
variable. Then the origin
of the URL
object is checked to see if it matches "https://www.youtube.com", and if it does, it is returned to loadFromQuery
and
used as the src
attribute for the iframe:
function parseURI(uri) {
const uriParts = new URL(uri);
if(uriParts.origin === "https://www.youtube.com")
return uri;
// If user does not provide a youtube uri, we take the default one.
return "https://www.youtube.com/embed/dQw4w9WgXcQ?&autoplay=1";
}
A uri
such as https://www.youtube.com/%22></iframe>
will break us out of the iframe and allow us to render
any HTML content that we would like.