xssmap 2.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xssmap-2.8.0/PKG-INFO +850 -0
- xssmap-2.8.0/README.md +833 -0
- xssmap-2.8.0/pyproject.toml +73 -0
- xssmap-2.8.0/setup.cfg +4 -0
- xssmap-2.8.0/tests/test_bench.py +112 -0
- xssmap-2.8.0/tests/test_brute.py +262 -0
- xssmap-2.8.0/tests/test_budget.py +228 -0
- xssmap-2.8.0/tests/test_charset.py +171 -0
- xssmap-2.8.0/tests/test_csp.py +338 -0
- xssmap-2.8.0/tests/test_domscan.py +76 -0
- xssmap-2.8.0/tests/test_domstate.py +376 -0
- xssmap-2.8.0/tests/test_efficiency.py +159 -0
- xssmap-2.8.0/tests/test_exploit.py +371 -0
- xssmap-2.8.0/tests/test_fuzz.py +162 -0
- xssmap-2.8.0/tests/test_honesty.py +178 -0
- xssmap-2.8.0/tests/test_inline.py +146 -0
- xssmap-2.8.0/tests/test_jslib.py +247 -0
- xssmap-2.8.0/tests/test_leads.py +180 -0
- xssmap-2.8.0/tests/test_mxss.py +95 -0
- xssmap-2.8.0/tests/test_ops.py +197 -0
- xssmap-2.8.0/tests/test_params.py +232 -0
- xssmap-2.8.0/tests/test_recon_report.py +206 -0
- xssmap-2.8.0/tests/test_session.py +292 -0
- xssmap-2.8.0/tests/test_subdomains.py +440 -0
- xssmap-2.8.0/tests/test_units.py +185 -0
- xssmap-2.8.0/tests/test_waf.py +218 -0
- xssmap-2.8.0/xssmap/__init__.py +10 -0
- xssmap-2.8.0/xssmap/__main__.py +6 -0
- xssmap-2.8.0/xssmap/browser.py +900 -0
- xssmap-2.8.0/xssmap/brute.py +563 -0
- xssmap-2.8.0/xssmap/callback.py +161 -0
- xssmap-2.8.0/xssmap/charset.py +117 -0
- xssmap-2.8.0/xssmap/cli.py +1375 -0
- xssmap-2.8.0/xssmap/contexts.py +131 -0
- xssmap-2.8.0/xssmap/crawler.py +462 -0
- xssmap-2.8.0/xssmap/csp.py +674 -0
- xssmap-2.8.0/xssmap/domscan.py +1098 -0
- xssmap-2.8.0/xssmap/domstate.py +620 -0
- xssmap-2.8.0/xssmap/efficiency.py +196 -0
- xssmap-2.8.0/xssmap/exploit.py +619 -0
- xssmap-2.8.0/xssmap/filters.py +329 -0
- xssmap-2.8.0/xssmap/fuzz.py +236 -0
- xssmap-2.8.0/xssmap/htmlparse.py +437 -0
- xssmap-2.8.0/xssmap/jslib.py +436 -0
- xssmap-2.8.0/xssmap/mxss.py +250 -0
- xssmap-2.8.0/xssmap/net.py +636 -0
- xssmap-2.8.0/xssmap/params.py +419 -0
- xssmap-2.8.0/xssmap/payloads.py +585 -0
- xssmap-2.8.0/xssmap/report.py +863 -0
- xssmap-2.8.0/xssmap/repro.py +259 -0
- xssmap-2.8.0/xssmap/sarif.py +260 -0
- xssmap-2.8.0/xssmap/scanner.py +1663 -0
- xssmap-2.8.0/xssmap/session.py +472 -0
- xssmap-2.8.0/xssmap/state.py +133 -0
- xssmap-2.8.0/xssmap/stored.py +407 -0
- xssmap-2.8.0/xssmap/subdomains.py +469 -0
- xssmap-2.8.0/xssmap/verify.py +113 -0
- xssmap-2.8.0/xssmap/waf.py +604 -0
- xssmap-2.8.0/xssmap.egg-info/PKG-INFO +850 -0
- xssmap-2.8.0/xssmap.egg-info/SOURCES.txt +62 -0
- xssmap-2.8.0/xssmap.egg-info/dependency_links.txt +1 -0
- xssmap-2.8.0/xssmap.egg-info/entry_points.txt +2 -0
- xssmap-2.8.0/xssmap.egg-info/requires.txt +4 -0
- xssmap-2.8.0/xssmap.egg-info/top_level.txt +1 -0
xssmap-2.8.0/PKG-INFO
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xssmap
|
|
3
|
+
Version: 2.8.0
|
|
4
|
+
Summary: Context-aware XSS scanner: crawler, browser verification, mXSS, charset and stored-XSS stages
|
|
5
|
+
Author: xssmap
|
|
6
|
+
Keywords: security,xss,scanner,pentest,sarif
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Information Technology
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
16
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# xssmap
|
|
19
|
+
|
|
20
|
+
A context-aware cross-site scripting scanner with a crawler, built in the spirit of
|
|
21
|
+
sqlmap: point it at a URL, it crawls, enumerates every injection point, works out the
|
|
22
|
+
exact reflection context, fires payloads aimed at that context, and proves which ones
|
|
23
|
+
actually execute in a real browser.
|
|
24
|
+
|
|
25
|
+
- **Zero dependencies.** Pure Python 3.9+ standard library (no requests, no BeautifulSoup,
|
|
26
|
+
no Playwright, no Node). Drop the folder anywhere and run it.
|
|
27
|
+
- **Crawler included.** Bounded BFS, robots.txt and sitemap seeding, forms, inline and
|
|
28
|
+
external JavaScript endpoint extraction, JSON/AJAX hints, scope and exclusion rules.
|
|
29
|
+
- **Context-aware payloads.** HTML text, double/single/unquoted attributes, URL
|
|
30
|
+
attributes, JS string (double/single/template), JS code, `<script>` data blocks,
|
|
31
|
+
comments, `<textarea>`/`<title>`/`<style>` RCDATA, tag space.
|
|
32
|
+
- **Filter fingerprinting.** Each point is probed to learn which characters and keywords
|
|
33
|
+
survive (`<`, `>`, quotes, space, `onerror`, `script`, `javascript:`), so payloads that
|
|
34
|
+
cannot possibly work are pruned and the ones that can are preferred.
|
|
35
|
+
- **Mutation engine.** When a filter blocks the obvious payload, candidates are mutated
|
|
36
|
+
(case, entity, nested keyword, unclosed tag, null byte, double encoding, comment
|
|
37
|
+
padding, event swap) and the survivors are verified like any other payload.
|
|
38
|
+
- **WAF fingerprinting and evasion.** Identifies the vendor in front of each host from
|
|
39
|
+
headers, block pages and probe behaviour, then routes payloads through that vendor's
|
|
40
|
+
bypass vocabulary - with a bounded number of extra attempts per point. What the target
|
|
41
|
+
actually lets through reorders that vocabulary for the rest of the scan: proven shapes
|
|
42
|
+
first, filtered ones last.
|
|
43
|
+
- **Filter brute force.** For a point that reflects but mangles everything, learns which
|
|
44
|
+
characters, keyword spellings and multi-character sequences survive, finds where the
|
|
45
|
+
value gets truncated, and synthesises a payload from the survivors instead of guessing.
|
|
46
|
+
- **Hidden parameters and fuzzing.** Probes undocumented query, form and JSON-body
|
|
47
|
+
parameters, confirms a hit in the same direction before adding it, and classifies what
|
|
48
|
+
each one does to the response (raw, encoded, template-evaluated, stripped) against its
|
|
49
|
+
own baseline, so exploration is evidence rather than noise.
|
|
50
|
+
- **Client-side CVE hints.** Fingerprints jQuery, Bootstrap, AngularJS, DOMPurify and
|
|
51
|
+
friends from body or filename, compares versions against each advisory's affected range
|
|
52
|
+
(so 2.3.2 is not blamed for a 3.4.1 fix) and can merge fresh advisories from osv.dev
|
|
53
|
+
with `--osv`.
|
|
54
|
+
- **Leads, not silence.** A parameter that reflects but swallows every payload is recorded
|
|
55
|
+
as an unconfirmed lead with the measured filter and how much of the probe survived
|
|
56
|
+
(verbatim, recased, escaped, truncated), in its own report section - never presented as a
|
|
57
|
+
finding and never left looking clean. Detection and measurement are separate: a sink that
|
|
58
|
+
mangles the value is still reported as a sink.
|
|
59
|
+
- **mXSS round trip.** Parser-differential payloads are proven by serialising the DOM,
|
|
60
|
+
re-parsing it and re-running it, so the proof comes from the page rather than from the
|
|
61
|
+
scanner's belief about the page.
|
|
62
|
+
- **Multibyte charset escapes.** GBK/Shift_JIS/BIG5 trail-byte payloads are generated per
|
|
63
|
+
charset and confirmed the same way, with a UTF-8 control route to catch false positives.
|
|
64
|
+
- **DOM XSS analysis.** Static source-to-sink taint tracing over crawled scripts
|
|
65
|
+
(`location.search/hash/name`, `document.referrer`, `postMessage` into `innerHTML`,
|
|
66
|
+
`eval`, `document.write`, `location`), then real browser confirmation of each flow.
|
|
67
|
+
Two-step flows are followed across resources: a value written to
|
|
68
|
+
`localStorage`/`sessionStorage` on one page and rendered as HTML on another, and a
|
|
69
|
+
server-rendered state blob (`__INITIAL_STATE__`, `__NEXT_DATA__`, `data-*`) that the
|
|
70
|
+
bundle re-reads after hydration, are replayed step by step in the browser instead of
|
|
71
|
+
being left as unprovable guesses.
|
|
72
|
+
- **Stored and blind XSS.** Plants a uniquely marked payload in every writable form,
|
|
73
|
+
finds where it reappears, and proves execution - in the page itself, a viewer URL you
|
|
74
|
+
supply, or out-of-band through a built-in callback listener.
|
|
75
|
+
- **Browser proof.** Drives a local Chrome/Chromium/Edge over the DevTools protocol
|
|
76
|
+
(stdlib WebSocket) and asks the live DOM whether the injected node was created,
|
|
77
|
+
which turns "reflected" into "confirmed".
|
|
78
|
+
- **Exploit stage (`--verify`).** Replays the proven vector with a weaponised script and
|
|
79
|
+
measures what it reaches: cookie bytes and names, anti-CSRF token readability, a
|
|
80
|
+
same-origin read, or an out-of-band callback. Proven capabilities raise severity and
|
|
81
|
+
CVSS; an inconclusive replay stays `unproven` with the browser's own reason.
|
|
82
|
+
- **Honest by construction.** Every payload ends in one of three states, and a
|
|
83
|
+
verification that could not run is never reported as a clean result. CSP that blocks
|
|
84
|
+
execution is detected and downgraded instead of being reported as a high-severity hit,
|
|
85
|
+
and a `strict-dynamic` page is annotated with the script-creating gadgets and allowlisted
|
|
86
|
+
JSONP endpoints a manual bypass review should start from.
|
|
87
|
+
- **Reports.** JSON, Markdown, self-contained HTML, SARIF 2.1.0 for CI, one evidence
|
|
88
|
+
file per finding, CVSS 3.1 vector and a ready-to-paste `curl` reproduction.
|
|
89
|
+
|
|
90
|
+
## Install
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git clone <this folder> xssmap && cd xssmap # or just copy the directory
|
|
94
|
+
python run.py --help
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Optional, for a `xssmap` command on PATH:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
python -m pip install .
|
|
101
|
+
xssmap --version
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Windows users can call `xssmap.cmd`, POSIX users `./xssmap.sh`. Both are thin wrappers
|
|
105
|
+
around `python run.py` that resolve their own directory, so they work from anywhere.
|
|
106
|
+
The `.cmd` wrapper skips the Microsoft Store `python` stub and propagates the real exit
|
|
107
|
+
code.
|
|
108
|
+
|
|
109
|
+
## Quick start
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# single URL, crawl by default (depth 2, 150 pages)
|
|
113
|
+
python run.py -u "https://target.tld/search?q=test"
|
|
114
|
+
|
|
115
|
+
# crawl deeper, more threads, write reports (--depth is an alias)
|
|
116
|
+
python run.py -u https://target.tld --depth 3 --max-pages 200 --threads 8
|
|
117
|
+
|
|
118
|
+
# hand it a bare domain and let it find and test everything
|
|
119
|
+
python run.py --domain example.com --autonomous
|
|
120
|
+
|
|
121
|
+
# no crawling, only the URL you gave
|
|
122
|
+
python run.py -u "https://target.tld/page?name=x" --no-crawl
|
|
123
|
+
|
|
124
|
+
# a list of targets
|
|
125
|
+
python run.py -m urls.txt --threads 10
|
|
126
|
+
|
|
127
|
+
# POST body / JSON body
|
|
128
|
+
python run.py -u https://target.tld/api/login --data 'user=a&pass=b'
|
|
129
|
+
python run.py -u https://target.tld/api/login --json-body '{"user":"a","pass":"b"}'
|
|
130
|
+
|
|
131
|
+
# DOM XSS + stored XSS with an out-of-band listener, SARIF for CI
|
|
132
|
+
python run.py -u https://target.tld --crawl --dom --stored --callback \
|
|
133
|
+
--sarif out/xssmap.sarif --evidence out/evidence
|
|
134
|
+
|
|
135
|
+
# authenticated scan, header/cookie injection points, browser proof, CI gate
|
|
136
|
+
python run.py -u https://target.tld \
|
|
137
|
+
--cookie "sid=abc" --header "X-Api-Key: ..." \
|
|
138
|
+
--headers-points --browser --fail-on-findings
|
|
139
|
+
|
|
140
|
+
# log in first, keep the session, and resume an interrupted run later
|
|
141
|
+
python run.py -u https://target.tld --login-url https://target.tld/login \
|
|
142
|
+
--login-user bob --login-pass 's3cret' --login-check https://target.tld/account \
|
|
143
|
+
--state out/scan.state --save-cookies out/cookies.txt
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Whole-domain scans
|
|
147
|
+
|
|
148
|
+
`--domain` takes a hostname instead of a URL: it picks a working scheme (https, falling
|
|
149
|
+
back to http), discovers subdomains, then crawls and tests everything it found.
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# discover + crawl + scan everything under example.com
|
|
153
|
+
python run.py --domain example.com --autonomous
|
|
154
|
+
|
|
155
|
+
# the same sweep with explicit budgets
|
|
156
|
+
python run.py --domain example.com --subdomains --max-hosts 30 --depth 3 --max-pages 500
|
|
157
|
+
|
|
158
|
+
# passive discovery only: no DNS wordlist sweep
|
|
159
|
+
python run.py --domain example.com --no-subdomain-brute
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Discovery uses two independent sources:
|
|
163
|
+
|
|
164
|
+
1. **Certificate transparency** via crt.sh. Passive: the target sees no traffic from it.
|
|
165
|
+
2. **A DNS wordlist** of roughly 80 high-signal labels (`admin`, `api`, `staging`, `dev`,
|
|
166
|
+
`sso`, ...), resolved concurrently and bounded by `--max-hosts` and a wall-clock
|
|
167
|
+
timeout. `--subdomain-wordlist FILE` adds your own labels.
|
|
168
|
+
|
|
169
|
+
A random name is resolved first to detect wildcard DNS. Against a wildcard every candidate
|
|
170
|
+
looks live, so the wordlist sweep is skipped and reported instead of inventing a hundred
|
|
171
|
+
phantom hosts. Discovered names that do not resolve right now are dropped, and each
|
|
172
|
+
survivor gets an HTTPS probe with an HTTP fallback. The page budget is shared across all
|
|
173
|
+
hosts, so `--max-pages` remains a real limit rather than a per-host one.
|
|
174
|
+
|
|
175
|
+
When discovery finds other hosts, scope widens from `host` to `subdomain` automatically and
|
|
176
|
+
says so. Pass `--scope host` explicitly to keep the crawl on one host; the discovered hosts
|
|
177
|
+
are then left out and the log tells you why.
|
|
178
|
+
|
|
179
|
+
### Autonomous mode
|
|
180
|
+
|
|
181
|
+
`--autonomous` is the one-flag sweep: subdomain discovery, depth-3 crawl, POST forms,
|
|
182
|
+
stored planting, DOM analysis, browser confirmation, header and cookie points, common
|
|
183
|
+
paths, screenshots, and a 30-minute soft budget. Every flag you pass yourself wins,
|
|
184
|
+
including the off switches:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
python run.py --domain example.com --autonomous # full sweep
|
|
188
|
+
python run.py --domain example.com --autonomous --no-stored # read-only
|
|
189
|
+
python run.py --domain example.com --autonomous --max-pages 80 --depth 2
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
It prints what it enabled and what it kept from your command line. Because it plants
|
|
193
|
+
stored payloads and submits forms by default, it warns about that once at startup; use
|
|
194
|
+
`--no-stored` and/or `--no-forms` on anything you must not modify.
|
|
195
|
+
|
|
196
|
+
`--max-minutes N` is a wall-clock budget enforced between phases *and* inside them: the
|
|
197
|
+
scanner checks the deadline before every injection point, and the stored stage checks it
|
|
198
|
+
before every plant and every proof. Once it is reached, queued work is dropped, the points
|
|
199
|
+
already in flight are allowed to finish, and the log reports how many points were actually
|
|
200
|
+
tested ("time budget reached mid-scan; 13 point(s) tested"). The report then counts only
|
|
201
|
+
those points under **Points tested**, lists the run as `"partial"` in JSON, and points
|
|
202
|
+
skipped by the budget are deliberately kept out of `--state` so the next run re-tests them.
|
|
203
|
+
The remaining overshoot is bounded by a single in-flight point, not by a whole phase.
|
|
204
|
+
|
|
205
|
+
## How detection works
|
|
206
|
+
|
|
207
|
+
For every injection point the engine runs a fixed pipeline:
|
|
208
|
+
|
|
209
|
+
1. **Baseline.** Request the point with a benign value, record the response.
|
|
210
|
+
2. **Marker.** Send a unique alphanumeric token (`xs` + 8 hex chars) and locate every
|
|
211
|
+
occurrence with the HTML parser, which tracks byte offsets, enclosing tag,
|
|
212
|
+
attribute, quote style and script/comment ranges.
|
|
213
|
+
3. **Classify.** Each reflection gets a context and a snippet, so the payload can be
|
|
214
|
+
built for that exact position rather than sprayed blindly.
|
|
215
|
+
4. **Fingerprint.** Probe once more to learn the filter: which break-out characters
|
|
216
|
+
arrive raw and which keywords are stripped. The answer decides which payloads are
|
|
217
|
+
even feasible, and it is stored on the finding as evidence of *why* it worked.
|
|
218
|
+
5. **Fire.** Context-matched payloads are rendered with the marker and JS variant, then
|
|
219
|
+
filtered by `--level` and `--risk`. Blocked ones are retried as mutations.
|
|
220
|
+
6. **Verify the reflection.** The payload must appear verbatim and the break-out
|
|
221
|
+
characters (`"`, `'`, space, `<`, `-->`, backtick) must survive raw. Quotes preceded
|
|
222
|
+
by an odd run of backslashes are rejected as escaped, not broken out. Payloads built
|
|
223
|
+
for a non-UTF-8 charset are re-encoded in that charset before this check.
|
|
224
|
+
7. **Stability.** The same request is re-sent; flaky or one-off reflections are dropped.
|
|
225
|
+
8. **Execution proof (optional).** A DOM-writing payload variant is loaded in a headless
|
|
226
|
+
browser and the engine checks `document.getElementById(...)` in the live page, with
|
|
227
|
+
console, network and CSP-violation observers attached. If the node exists, the payload
|
|
228
|
+
really ran.
|
|
229
|
+
|
|
230
|
+
A payload that is reflected but never executes is reported as *reflected*, not as a
|
|
231
|
+
confirmed vulnerability, and the note says why.
|
|
232
|
+
|
|
233
|
+
### What the sink did to the value (`efficiency`)
|
|
234
|
+
|
|
235
|
+
Every reflecting point produces a verdict, not just the ones that happen to match a payload.
|
|
236
|
+
The last stage measures how much of the probe came back and scores it (the same idea as
|
|
237
|
+
XSStrike's `fuzz.partial_ratio`, made explicit instead of hidden in a 90-point special case):
|
|
238
|
+
|
|
239
|
+
| Kind | Score | Meaning |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| `verbatim` | 100 | the value arrived exactly as sent; the payload ladder is worth running |
|
|
242
|
+
| `case` | 99 | the sink changed the case (`XS…`); executable, but payloads must be rebuilt |
|
|
243
|
+
| `escaped` | 90 | one character was rewritten or escaped (`\'`); a break-out may still exist |
|
|
244
|
+
| `partial` | 50 | only the head survived ("only the first 5 characters survive") |
|
|
245
|
+
| `absent` | 0 | the value is not in the body |
|
|
246
|
+
|
|
247
|
+
A point whose value is mangled is not silently dropped: it becomes a lead carrying the
|
|
248
|
+
measurement (`filter_profile.efficiency`, `lead_kind` in the JSON), so "filtered" and "never
|
|
249
|
+
reached the sink" stay distinguishable. Two details matter in practice:
|
|
250
|
+
|
|
251
|
+
- **Recased sinks are detected without an extra request.** Markers are lowercase, so the
|
|
252
|
+
comparison ignores the body's case; a sink that uppercases the value used to look unreflected.
|
|
253
|
+
- **Digit-stripping or truncating sinks get a second, shorter probe.** When the first marker
|
|
254
|
+
does not come back intact (absent, escaped or partial) and `--level 2` or higher is active,
|
|
255
|
+
one letter-only marker is sent. A sink that loses exactly one character is the case that used
|
|
256
|
+
to slip through: 9 of 10 characters still scores 90%, which reads as `escaped` and looks like
|
|
257
|
+
a value that survived. If the short probe reflects, the point is measured and attacked with a
|
|
258
|
+
marker the sink can carry instead of being written off.
|
|
259
|
+
|
|
260
|
+
`lead_kind` names why the point stayed a lead: `unproven` (reflected, nothing executed),
|
|
261
|
+
`partial-reflection` (the probe itself was mangled) or `non-html` (the sink returns JSON or
|
|
262
|
+
text, so a browser cannot execute it).
|
|
263
|
+
|
|
264
|
+
### Verification is tri-state, and "unknown" is not "clean"
|
|
265
|
+
|
|
266
|
+
This is the rule the scanner is built around. Every payload that reaches the proof stage
|
|
267
|
+
carries one of:
|
|
268
|
+
|
|
269
|
+
| State | Meaning |
|
|
270
|
+
| --- | --- |
|
|
271
|
+
| `confirmed` | The page itself produced the effect: artifact node present, console message seen, callback received, or CSP violation reported. |
|
|
272
|
+
| `not_observed` | The check genuinely ran, the page loaded and executed, and the payload produced nothing. A real negative. |
|
|
273
|
+
| `not_tested` | The check could not run: no browser, navigation failure, DevTools error, timeout, blocked download. **Unknown, not clean.** |
|
|
274
|
+
|
|
275
|
+
Anything that fails to observe records *why* (`verify_reason`), and `not_tested` findings
|
|
276
|
+
are never counted as false-positive-free evidence. A run with no browser reports
|
|
277
|
+
`execution reported not_tested` rather than implying the site is safe. Conflating those
|
|
278
|
+
two states was the single biggest honesty defect in earlier versions: a browser crash and
|
|
279
|
+
a blocked payload both used to look like a quiet negative, and both silently dropped a
|
|
280
|
+
finding from high to medium confidence.
|
|
281
|
+
|
|
282
|
+
`--fail-on-findings` exits 1 on findings. If you want a CI gate that also fails when
|
|
283
|
+
verification was impossible, gate on the `verify_state` counts in the JSON report.
|
|
284
|
+
|
|
285
|
+
### Mutation XSS
|
|
286
|
+
|
|
287
|
+
The mXSS stage takes a payload that a browser accepted as inert markup, serialises the
|
|
288
|
+
resulting DOM, re-parses that serialisation, and checks whether the second parse now
|
|
289
|
+
executes. The payload is only reported when the round trip itself produces execution,
|
|
290
|
+
so a browser that normalises differently from the server cannot manufacture a finding.
|
|
291
|
+
A control route that does not round-trip is asserted to stay silent.
|
|
292
|
+
|
|
293
|
+
### Multibyte charsets
|
|
294
|
+
|
|
295
|
+
Where the response declares or implies a multibyte charset in the GBK family
|
|
296
|
+
(`gbk`, `gb2312`, `gb18030`, `cp936`), Big5 (`big5`, `cp950`), or Shift_JIS family
|
|
297
|
+
(`shift_jis`, `sjis`, `cp932`, `windows-31j`), the generator produces the classic
|
|
298
|
+
trail-byte form: a lead byte followed by a byte that the server's encoder consumes out
|
|
299
|
+
of the quote or angle bracket, so `\` or `"` disappears from the byte stream and the
|
|
300
|
+
attribute breaks out. Payloads are encoded in the target charset rather than sent as
|
|
301
|
+
UTF-8, and a UTF-8 route with the same payload is used as a control: if the control
|
|
302
|
+
fires, the finding is a plain encoding issue rather than a charset escape.
|
|
303
|
+
Charsets outside those three families are not modelled and are reported as ordinary
|
|
304
|
+
reflection, not as an escape.
|
|
305
|
+
|
|
306
|
+
### CSP
|
|
307
|
+
|
|
308
|
+
The `Content-Security-Policy` header is parsed into directives, sources and nonces.
|
|
309
|
+
A payload that the policy would block is reported with `csp_blocked` and a downgraded
|
|
310
|
+
severity, and the report names the directive that stopped it plus any observed
|
|
311
|
+
weakening (`unsafe-inline`, `*`, `data:`, a missing `object-src`, report-only mode).
|
|
312
|
+
`Content-Security-Policy-Report-Only` is treated as *not enforced* - it changes nothing
|
|
313
|
+
about exploitability, so it must not silence a real finding.
|
|
314
|
+
|
|
315
|
+
### DOM XSS
|
|
316
|
+
|
|
317
|
+
Crawled scripts are parsed for sources (`location.search`, `location.hash`,
|
|
318
|
+
`location.pathname`, `document.referrer`, `window.name`, `postMessage`, storage,
|
|
319
|
+
framework router hooks and server-rendered state blobs) and sinks (`innerHTML`,
|
|
320
|
+
`outerHTML`, `insertAdjacentHTML`, `document.write`, `eval`, `Function`,
|
|
321
|
+
`setTimeout(string)`, `location`). A flow is reported only when the tainted value
|
|
322
|
+
reaches the sink along an assignment chain, and confirmed only when the browser shows
|
|
323
|
+
the payload's own artifact node or the payload calls home. Flows that need the fragment
|
|
324
|
+
are delivered through `location.hash` with an exact reload, not by assuming a
|
|
325
|
+
navigation happened.
|
|
326
|
+
|
|
327
|
+
The flow does not have to live in one script or travel in one request:
|
|
328
|
+
|
|
329
|
+
- **Storage joins.** `localStorage.setItem('saved-search', term)` on one page and
|
|
330
|
+
`el.innerHTML = localStorage.getItem('saved-search')` on another are joined into a
|
|
331
|
+
single flow. Verification drives the real sequence - open the write page with the
|
|
332
|
+
payload, confirm the value actually landed in storage, then load the read page - so a
|
|
333
|
+
write that never happened is reported as "flow not exercised", not as "clean". Keys
|
|
334
|
+
are origin-scoped: a write from another origin is dropped rather than replayed.
|
|
335
|
+
- **Hydration sources.** A state blob is not JavaScript, so it is read through the id
|
|
336
|
+
of its `<script>` element (`__INITIAL_STATE__`, `__PRELOADED_STATE__`, `__NEXT_DATA__`,
|
|
337
|
+
`__NUXT__`, `__APOLLO_STATE__`, `__REDUX_STATE__`) or through `el.dataset.*` /
|
|
338
|
+
`getAttribute('data-…')`. The blob itself is excluded from the taint pass, and the
|
|
339
|
+
parameter that feeds it is taken from the page's own query names before any guess.
|
|
340
|
+
- **Every inline block counts.** All executable `<script>` blocks of a page are merged
|
|
341
|
+
into one virtual resource before analysis, so a variable assigned in one block and
|
|
342
|
+
sunk in the next is still a flow - and a non-executable block can no longer shadow the
|
|
343
|
+
script that reads it.
|
|
344
|
+
|
|
345
|
+
### Stored and blind XSS
|
|
346
|
+
|
|
347
|
+
`--stored` plants one uniquely marked payload per form field, re-fetches the crawled
|
|
348
|
+
pages to find where the marker reappears, then opens those pages and waits for the
|
|
349
|
+
payload's handler to create a node. The same handler also beacons the callback listener
|
|
350
|
+
when `--callback` is running, which is what makes blind XSS (payload executed by an
|
|
351
|
+
admin view you cannot see) provable instead of guessed.
|
|
352
|
+
|
|
353
|
+
Three refinements matter in practice:
|
|
354
|
+
|
|
355
|
+
- **`--viewer-url`** names the page that renders submitted content (a moderation queue,
|
|
356
|
+
a profile page, an admin inbox). Blind stored XSS is exactly the case where the payload
|
|
357
|
+
renders for someone else, so without a viewer URL it can only be proven by callback.
|
|
358
|
+
- **`--stored-json URL [--stored-json-field NAME]`** plants into JSON APIs, which is how
|
|
359
|
+
most modern forms actually submit. Form-encoded planting misses them entirely.
|
|
360
|
+
- **`--capture-cookies`** makes the callback beacon include `document.cookie`. It is
|
|
361
|
+
**off by default**: harvesting a session cookie is an exfiltration step, not a
|
|
362
|
+
detection step, and it should be a deliberate choice on an authorised test.
|
|
363
|
+
|
|
364
|
+
### Authenticated sessions
|
|
365
|
+
|
|
366
|
+
`--login-url` submits the site's own login form (auto-detecting the username, password
|
|
367
|
+
and any CSRF token fields, overridable with `--login-user-field`, `--login-pass-field`
|
|
368
|
+
and `--login-data K=V`), keeps the resulting cookie jar for every later request, and
|
|
369
|
+
`--login-check URL` confirms the session really took by requiring that URL to stop
|
|
370
|
+
redirecting to the login page. If the check fails the scan says so instead of silently
|
|
371
|
+
crawling a login page.
|
|
372
|
+
|
|
373
|
+
Anti-CSRF tokens are re-read before each submission, because a token captured once is
|
|
374
|
+
usually stale by the third POST (`--no-csrf-refresh` disables this). `--cookie-jar`
|
|
375
|
+
preloads a Netscape `cookies.txt`, and `--save-cookies` writes the final jar back out;
|
|
376
|
+
that file holds live session secrets, and the scanner says so when it writes it.
|
|
377
|
+
|
|
378
|
+
## WAF, parameters and library intelligence
|
|
379
|
+
|
|
380
|
+
Five stages run alongside detection. They are observation-only: they decide what gets
|
|
381
|
+
tested and what the report explains, never what counts as a finding.
|
|
382
|
+
|
|
383
|
+
### WAF fingerprinting and evasion (`--waf`, `--evade`)
|
|
384
|
+
|
|
385
|
+
`--waf` asks the target a small fixed set of questions - four requests: a benign baseline
|
|
386
|
+
and three probes shaped like a script tag, a SQL injection and a traversal - then reads the
|
|
387
|
+
vendor from headers, cookies, block pages and probe behaviour. Evidence from every response
|
|
388
|
+
is kept and merged, so confidence rises only when the answers agree; one odd 403 is a
|
|
389
|
+
low-confidence hint, not a WAF. Detection is per host and cached for the run.
|
|
390
|
+
|
|
391
|
+
When a blocking vendor is identified, `--evade` routes payloads through that vendor's
|
|
392
|
+
vocabulary of variants (case, whitespace, entity and comment tricks). Every reflection gets
|
|
393
|
+
its own share - two to six variants, depending on the detection's confidence and whether
|
|
394
|
+
the vendor was seen blocking - and `--evasion-budget` (default 240) caps the total added
|
|
395
|
+
across the whole scan, so one hostile point cannot starve the rest. `--no-waf` and
|
|
396
|
+
`--no-evade` switch the stages off.
|
|
397
|
+
|
|
398
|
+
Two deliberate limits: a variant that only appears to bypass because the scanner decoded it
|
|
399
|
+
twice is not counted - every variant still has to pass the same reflection and execution
|
|
400
|
+
gates as a normal payload - and the budget is a cap, not a promise.
|
|
401
|
+
|
|
402
|
+
### Character-level brute force (`--brute`)
|
|
403
|
+
|
|
404
|
+
When a point reflects but every payload is mangled, `--brute` measures the filter instead of
|
|
405
|
+
guessing. Four probe families run in sequence, all marker-wrapped so a rejected value cannot
|
|
406
|
+
hide which part caused it:
|
|
407
|
+
|
|
408
|
+
- one probe per break-out character, to learn what survives raw, is encoded, or is stripped;
|
|
409
|
+
- keyword probes in every casing (`alert`, `ALERT`, `AlErT`, `script`, `svg`), so a filter
|
|
410
|
+
that only blocks the lowercase spelling is measured as passable - HTML does not care about
|
|
411
|
+
case even when the filter does; handlers are probed too (`onload`, `onerror` before the rest,
|
|
412
|
+
because a synthesised vector needs a handler the filter actually allows);
|
|
413
|
+
- multi-character sequences (`<script`, `</script`, `<svg`, `onerror=`, `onload=`,
|
|
414
|
+
`javascript:`, `<>`, `"><`, `=(`, `(){}`), because `<` surviving on its own says nothing
|
|
415
|
+
about `<script` being blocked as a token;
|
|
416
|
+
- a truncation search that finds the length where the target starts cutting the value, so a
|
|
417
|
+
payload silently sliced in half is not mistaken for one whose characters were removed.
|
|
418
|
+
|
|
419
|
+
The character map then drives synthesis: vectors are built only from characters that
|
|
420
|
+
survived, using the spelling of each keyword that came back verbatim and skipping banned
|
|
421
|
+
sequences, and they are tested like any other payload. A word blocked in every casing is
|
|
422
|
+
never emitted: if no callable or handler survives, the vector is dropped (a `<script>` block
|
|
423
|
+
needs no handler and is still tried). `--brute-budget` caps the probes for the whole scan
|
|
424
|
+
(default 40 - raise it when a scan has several hostile points); findings produced this way
|
|
425
|
+
carry the character map, the synthesised vector, the blocked sequences and the truncation
|
|
426
|
+
limit as evidence.
|
|
427
|
+
|
|
428
|
+
Brute force also runs when the reflection could not be classified at all (`inferred`): the
|
|
429
|
+
payload ladder is skipped, the measured survivors are synthesised, and a hit is reported as a
|
|
430
|
+
finding because the marker came back inside the synthesised vector - the filter was measured,
|
|
431
|
+
not assumed.
|
|
432
|
+
|
|
433
|
+
### Undocumented parameters (`--discover-params`)
|
|
434
|
+
|
|
435
|
+
Parameters are probed in batches with a unique alphanumeric marker. A name is reported when
|
|
436
|
+
its marker is reflected, or when the response contains a validation-style message naming it;
|
|
437
|
+
promising names are re-probed with `name=1` and their response shape compared against the
|
|
438
|
+
baseline so a length change alone is not mistaken for a parameter, and confirmed again in
|
|
439
|
+
the same direction before a name is added (a shape that flips between requests is drift, not
|
|
440
|
+
a parameter). JSON and form bodies are searched too: every candidate key is injected into the
|
|
441
|
+
body and the response is compared against a body-only baseline, so a property that is only
|
|
442
|
+
read by the server - and never appears in the URL - is still found, while keys the request
|
|
443
|
+
already documents are not reported as hidden. `--param` still filters which names may be
|
|
444
|
+
added to the scan. A user-supplied wordlist is tested before the built-in candidates, so a
|
|
445
|
+
long list cannot be silently truncated by the built-in budget.
|
|
446
|
+
|
|
447
|
+
### Fuzz mode (`--fuzz`)
|
|
448
|
+
|
|
449
|
+
Exploration rather than detection: each parameter is probed with a fixed matrix (quotes,
|
|
450
|
+
angle brackets, comment and script closers, backticks, parentheses, `javascript:` URLs,
|
|
451
|
+
template syntax) and every response is classified as raw, encoded, evaluated or stripped -
|
|
452
|
+
always against that parameter's own baseline, so punctuation that already exists in the page
|
|
453
|
+
boilerplate is not reported as a reflection. It produces no findings; it produces the table
|
|
454
|
+
that tells you which payload family a parameter will accept, and `--fuzz-budget` bounds the
|
|
455
|
+
requests (default 200).
|
|
456
|
+
|
|
457
|
+
### Client-side library CVEs (`--js-libs`)
|
|
458
|
+
|
|
459
|
+
Script sources and inline bodies are matched against a signature table (jQuery, jQuery UI,
|
|
460
|
+
Bootstrap, AngularJS, Lodash, Moment.js, DOMPurify, Handlebars, Underscore, Axios, React,
|
|
461
|
+
Vue, marked). A version is used only when it was read from the resource itself - the body
|
|
462
|
+
first, then the filename - and compared numerically against each advisory's affected range
|
|
463
|
+
(introduced version up to, but not including, the version that fixed it). Ranges matter:
|
|
464
|
+
Bootstrap 3.0.0-3.4.0 and 4.0.0-4.3.0 are affected by the tooltip/popover issues fixed in
|
|
465
|
+
3.4.1 and 4.3.1, while 2.3.2 and 3.3.7 are not, and a single "version 3.4.1" string would
|
|
466
|
+
get both cases wrong. `--osv` additionally asks osv.dev about every identified version and
|
|
467
|
+
merges the advisories it returns (lookups are capped and a failure is ignored rather than
|
|
468
|
+
changing a verdict). Libraries whose version could not be determined are not queried at all:
|
|
469
|
+
a versionless OSV query returns every advisory for the package, which would read as "this
|
|
470
|
+
build is vulnerable to all of them". Long advisory lists stay complete in the JSON and are
|
|
471
|
+
summarised in the console line (`+N more`). The output is an indicator: "CVE-2020-11022
|
|
472
|
+
applies to this version" is not a claim that this page is exploitable.
|
|
473
|
+
|
|
474
|
+
OSV enrichment is verified against the live service, not only against fixtures. A bounded run
|
|
475
|
+
(`max_lookups=3`, 0.64 s) merged advisories for jQuery 1.8.3 (5 CVEs), Bootstrap 3.3.7 (7)
|
|
476
|
+
and Lodash 4.17.4 (9) into the local verdicts, then stopped: the fourth library was never
|
|
477
|
+
queried and stayed unchanged, so the cap is visibly enforced.
|
|
478
|
+
|
|
479
|
+
### Browser cost
|
|
480
|
+
|
|
481
|
+
`--browser` reuses one browser and one page per run. The report's `browser_verification`
|
|
482
|
+
line states how many checks ran, how many launches were needed and the average milliseconds
|
|
483
|
+
per check, so the price of execution proof is visible rather than implied.
|
|
484
|
+
|
|
485
|
+
## Flags
|
|
486
|
+
|
|
487
|
+
| Flag | Meaning |
|
|
488
|
+
| --- | --- |
|
|
489
|
+
| `-u URL`, `--domain DOMAIN`, `-m FILE` | single URL, a bare domain to enumerate, or a file of URLs |
|
|
490
|
+
| `--autonomous` | one-flag sweep; explicit flags always win |
|
|
491
|
+
| `--max-minutes N` | wall-clock budget, enforced between and inside phases (0 = unlimited) |
|
|
492
|
+
| `--crawl` / `--no-crawl` | force crawling on or off (crawl is on by default) |
|
|
493
|
+
| `--crawl-depth N`, `--depth N` | BFS depth (default 2) |
|
|
494
|
+
| `--max-pages N` | page cap for the crawl, shared across hosts (default 150) |
|
|
495
|
+
| `--scope {host,subdomain,any}` | how far the crawler may wander |
|
|
496
|
+
| `--subdomains` | discover subdomains via CT + DNS and crawl them too |
|
|
497
|
+
| `--no-subdomain-brute` | passive CT only, no DNS wordlist sweep |
|
|
498
|
+
| `--subdomain-wordlist FILE` | extra DNS labels, one per line |
|
|
499
|
+
| `--subdomain-base {auto,host,registrable}` | how the enumeration base is derived (default `auto`) |
|
|
500
|
+
| `--public-suffix-list FILE` | real PSL file to replace the built-in suffix table |
|
|
501
|
+
| `--max-hosts N` | cap on discovered hosts crawled per target (default 25) |
|
|
502
|
+
| `--exclude REGEX` / `--include REGEX` | limit what gets crawled (repeatable) |
|
|
503
|
+
| `--no-js`, `--robots`, `--common-paths` | JS endpoint mining off, honour robots.txt, probe conventional paths |
|
|
504
|
+
| `--forms` / `--no-forms` | also submit POST forms as reflected points (can create records) |
|
|
505
|
+
| `--stored` / `--no-stored` | plant payloads in forms and prove stored XSS (writes data) |
|
|
506
|
+
| `--stored-max-forms N` | cap on forms used for stored planting (default 20) |
|
|
507
|
+
| `--stored-json URL`, `--stored-json-field NAME` | plant stored payloads through a JSON endpoint |
|
|
508
|
+
| `--viewer-url URL` | page that renders stored content (moderation queue, profile) |
|
|
509
|
+
| `--callback [PORT]` | loopback listener for blind XSS proof (random port by default) |
|
|
510
|
+
| `--callback-bind ADDR` | interface the listener binds to (default `127.0.0.1`) |
|
|
511
|
+
| `--callback-url URL` | public URL the target should reach, when the listener is behind NAT |
|
|
512
|
+
| `--capture-cookies` | let the callback beacon send `document.cookie` (off by default) |
|
|
513
|
+
| `--method`, `--data`, `--json-body` | test a specific request body |
|
|
514
|
+
| `-p`, `--param`, `--param-value` | only these parameters / custom injection placeholder |
|
|
515
|
+
| `--headers-points` | add Referer, User-Agent, X-Forwarded-Host, Origin |
|
|
516
|
+
| `--cookie`, `-H`, `-A`, `--random-agent` | request identity |
|
|
517
|
+
| `--login-url`, `--login-user`, `--login-pass` | sign in through the site's login form first |
|
|
518
|
+
| `--login-user-field`, `--login-pass-field`, `--login-data K=V` | override login field names / add fields |
|
|
519
|
+
| `--login-check URL` | URL that must stop redirecting to login |
|
|
520
|
+
| `--cookie-jar FILE`, `--save-cookies FILE` | load / save a Netscape cookies.txt (secrets) |
|
|
521
|
+
| `--no-csrf-refresh` | do not re-read anti-CSRF tokens before each submission |
|
|
522
|
+
| `--level 1..3` | payload breadth: 1 classic, 2 JS/RCDATA/forms, 3 polyglots/headers/cookies |
|
|
523
|
+
| `--risk 1..3` | 1 non-interactive, 2 adds autofocus/onfocus, 3 adds interaction payloads |
|
|
524
|
+
| `--no-profile`, `--mutations N` | disable filter fingerprinting / cap mutations per point |
|
|
525
|
+
| `--waf` / `--no-waf` | fingerprint the WAF/CDN in front of each host (default on) |
|
|
526
|
+
| `--evade` / `--no-evade` | route payloads through the detected vendor's bypass vocabulary (default on) |
|
|
527
|
+
| `--evasion-budget N` | cap on evasion variants added across the whole scan (default 240) |
|
|
528
|
+
| `--brute`, `--brute-budget N` | character/keyword brute force for hostile points; probe budget for the whole scan (default 40) |
|
|
529
|
+
| `--discover-params` | probe for undocumented query and body parameters and add the responsive ones to the scan |
|
|
530
|
+
| `--fuzz`, `--fuzz-budget N` | classify what each parameter does to the response; request budget (default 200) |
|
|
531
|
+
| `--js-libs` / `--no-js-libs` | fingerprint client-side library versions and list applicable CVEs (default on) |
|
|
532
|
+
| `--osv` | merge advisories from osv.dev into the library results (capped lookups, network) |
|
|
533
|
+
| `--browser` / `--no-browser` | headless execution proof (auto-detects Chrome/Edge) |
|
|
534
|
+
| `--verify` / `--no-verify` | exploit stage: replay each finding with a weaponised payload and measure impact |
|
|
535
|
+
| `--verify-max N` | cap on findings replayed by `--verify` (default 10) |
|
|
536
|
+
| `--dom` / `--no-dom`, `--dom-flows N` | DOM analysis and its flow cap (default 15) |
|
|
537
|
+
| `--headless new\|old` | headless mode passed to the browser |
|
|
538
|
+
| `--screenshots` | capture a PNG for verified findings |
|
|
539
|
+
| `--threads N`, `--delay S`, `--timeout S`, `--retries N` | pacing and reliability |
|
|
540
|
+
| `--proxy URL`, `--insecure` | `http://`/`socks5://` proxy for scanner and browser; skip TLS verification |
|
|
541
|
+
| `--max-points N` | stop after N injection points |
|
|
542
|
+
| `--state FILE` | resume file: skip points already completed, carry findings over |
|
|
543
|
+
| `-o FILE`, `--html FILE`, `--markdown FILE`, `--output-dir DIR` | report destinations |
|
|
544
|
+
| `--sarif FILE` | SARIF 2.1.0 report for CI/code scanning integrations |
|
|
545
|
+
| `--evidence DIR` | one evidence file per finding |
|
|
546
|
+
| `-q`, `-v`, `--no-color` | verbosity |
|
|
547
|
+
| `--fail-on-findings` | exit code 1 when something is found (CI gate) |
|
|
548
|
+
| `--list-payloads` | print the payload catalogue and exit |
|
|
549
|
+
| `--version` | print the version and exit |
|
|
550
|
+
|
|
551
|
+
## Exit codes
|
|
552
|
+
|
|
553
|
+
| Code | Meaning |
|
|
554
|
+
| --- | --- |
|
|
555
|
+
| `0` | run completed, nothing found (or findings without `--fail-on-findings`) |
|
|
556
|
+
| `1` | `--fail-on-findings` was set and something was found |
|
|
557
|
+
| `2` | usage error, bad arguments, or no usable Python interpreter (launcher) |
|
|
558
|
+
| `130` | interrupted (Ctrl-C) - **reports and `--state` are still written first** |
|
|
559
|
+
|
|
560
|
+
Ctrl-C used to abort mid-run and lose everything that had already been proven. It now
|
|
561
|
+
stops the workers, flushes reports, saves resume state and exits 130, so an interrupted
|
|
562
|
+
long scan is still worth something.
|
|
563
|
+
|
|
564
|
+
## Reports
|
|
565
|
+
|
|
566
|
+
Each run writes the configured files (timestamped in `--output-dir` by default):
|
|
567
|
+
|
|
568
|
+
- `*.json` - machine readable: metadata, crawl stats, statistics, filter profiles and
|
|
569
|
+
every finding with payload, context, severity, confidence, `browser_verified`,
|
|
570
|
+
`browser_checked`, `verify_state`, `verify_reason`, `csp_blocked`, `cvss`,
|
|
571
|
+
`reproduction` and `severity_change_conditions`. Findings also carry `template`,
|
|
572
|
+
`payload_name`, the recorded `request`, and an `exploit` block when `--verify` ran;
|
|
573
|
+
the run's overall exploit counts sit in the top-level `verification` key.
|
|
574
|
+
- `*.md` - summary table plus per-finding sections for tickets and write-ups, including
|
|
575
|
+
a "Exploit (proof of impact)" block with the measured capabilities. A final
|
|
576
|
+
"Reconnaissance" section lists the WAF verdict and evidence, client-side libraries and
|
|
577
|
+
their CVE hints, discovered parameters, the fuzzing matrix and brute-force character maps.
|
|
578
|
+
Points that reflect but swallow every payload get their own "Leads (unconfirmed)" table:
|
|
579
|
+
parameter, context, confidence and the measured filter, kept separate from findings so a
|
|
580
|
+
hostile-but-unproven point is never read as a vulnerability or as a clean result.
|
|
581
|
+
- `*.html` - self-contained report with severity counters (including an "Exploit-proven"
|
|
582
|
+
tally), collapsible evidence and the same Reconnaissance section as tables.
|
|
583
|
+
- `*.sarif` - SARIF 2.1.0, one rule per context, with parameter, payload, confidence,
|
|
584
|
+
verification state and the `exploit` verdict in `properties`. The same reconnaissance
|
|
585
|
+
data rides in `runs[0].properties.reconnaissance` for pipelines that read more than
|
|
586
|
+
`results`. Unconfirmed leads ride along in the run's `leads` metadata (capped at 200).
|
|
587
|
+
|
|
588
|
+
`--evidence DIR` additionally writes `NNN-context-parameter.txt` per finding: target,
|
|
589
|
+
parameter, payload, filter profile, the request that triggered it, the response
|
|
590
|
+
observation that confirmed it, a CVSS 3.1 vector, and a `curl` line that reproduces it.
|
|
591
|
+
The captured request is reproduced with `Cookie` and `Authorization` values replaced by
|
|
592
|
+
`<redacted>`, and the `curl` line omits those headers entirely with a comment telling you
|
|
593
|
+
where to add your own session - a report is a document, not a credential spill.
|
|
594
|
+
|
|
595
|
+
## Exploit stage (`--verify`)
|
|
596
|
+
|
|
597
|
+
Detection answers "did this input reach an execution context?". `--verify` answers the
|
|
598
|
+
next question a report has to answer: **what could that script reach on this origin?**
|
|
599
|
+
|
|
600
|
+
It re-renders the vector that already worked -- same template, same parameter, same
|
|
601
|
+
request body -- with the inert DOM artifact swapped for a weaponised script, replays that
|
|
602
|
+
request in the same browser, and asks the live page what the script observed:
|
|
603
|
+
|
|
604
|
+
| Measured capability | What it establishes |
|
|
605
|
+
| --- | --- |
|
|
606
|
+
| `dom_control` | the injected script ran and owns the document |
|
|
607
|
+
| `cookie_bytes` / `cookie_names` | how much `document.cookie` the origin exposes, and which cookies (**names only, never values**) |
|
|
608
|
+
| `csrf_token_readable` / `csrf_token_length` | the script can read the page's anti-CSRF token, so it can script state-changing requests |
|
|
609
|
+
| `same_origin_status` / `same_origin_bytes` | a same-origin GET issued as the victim returned a readable response |
|
|
610
|
+
| callback hit | the payload reached an out-of-band listener (`--callback`) |
|
|
611
|
+
|
|
612
|
+
```bash
|
|
613
|
+
python -m xssmap -u "https://target.tld/search?q=1" --verify --browser
|
|
614
|
+
python -m xssmap -u https://target.tld --verify --verify-max 5 --callback 9000
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Against the loopback lab used for the regression tests:
|
|
618
|
+
|
|
619
|
+
```
|
|
620
|
+
[*] exploit stage: replaying up to 1 of 1 finding(s) with a weaponised payload
|
|
621
|
+
[+] exploit proven on q (html_text): same-vector - dom_control, csrf_token_readable
|
|
622
|
+
[+] exploit: 1 finding(s) proven to reach cookies, tokens or same-origin data
|
|
623
|
+
exploit PROVEN (same-vector): Arbitrary script execution in the target origin ...
|
|
624
|
+
The script read 28 bytes of cookie data (names: sid) ...
|
|
625
|
+
The script read the page's anti-CSRF token (14 chars) ...
|
|
626
|
+
A same-origin GET issued as the victim returned HTTP 200 (161 bytes readable) ...
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
The stage refuses to inflate a finding: a replayed payload that produced no observable
|
|
630
|
+
effect stays `unproven`, with the browser's own reason quoted (CSP refusal, console
|
|
631
|
+
error, or "rendered but no effect"), and the finding keeps its detection severity. Only
|
|
632
|
+
measured capabilities upgrade severity and CVSS.
|
|
633
|
+
|
|
634
|
+
Two implementation details matter more than they look:
|
|
635
|
+
|
|
636
|
+
- The weaponised script contains **no whitespace and no angle brackets**. Most vectors
|
|
637
|
+
put `{JS}` in an unquoted attribute (`<svg onload={JS}>`), where the first space or
|
|
638
|
+
`>` ends the attribute value; a normal-looking script would be truncated mid-expression
|
|
639
|
+
and never run. `var/**/x` keeps the parser's token separator without emitting a space,
|
|
640
|
+
and the token hunt uses `indexOf(...)+1` instead of `... > -1`. As a fallback, an
|
|
641
|
+
unquoted slot is quoted when a script still needs either character.
|
|
642
|
+
- It records cookie **names and byte counts**, never values, so the report demonstrates
|
|
643
|
+
exposure without becoming a credential dump.
|
|
644
|
+
|
|
645
|
+
When the target sends CSP, `--verify` makes one bounded attempt at nonce reuse: if the
|
|
646
|
+
same nonce appears in two responses, an injected `<script nonce=...>` is tried. That is a
|
|
647
|
+
fixed, single candidate -- there is no policy brute force. Everything else about CSP is
|
|
648
|
+
reported as an observed block, not as an exploit.
|
|
649
|
+
|
|
650
|
+
## Verification
|
|
651
|
+
|
|
652
|
+
`tests/selftest.py` starts a purpose-built vulnerable lab (`testapp/vuln_app.py`) on a
|
|
653
|
+
random port, scans it over real HTTP and scores the output against the app's manifest.
|
|
654
|
+
It exits non-zero if any expectation fails, so it is usable as a gate.
|
|
655
|
+
|
|
656
|
+
```bash
|
|
657
|
+
python tests/selftest.py # crawl + direct scan
|
|
658
|
+
python tests/selftest.py --forms --stored # no browser required
|
|
659
|
+
python tests/selftest.py --browser --forms --stored # full, needs Chrome/Edge
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
Eight phases: crawl and scan, direct per-endpoint scan, POST form with CSRF token,
|
|
663
|
+
machine-readable outputs, mXSS round trip, multibyte charset escaping, authenticated
|
|
664
|
+
session with JSON planting, and resume state with viewer pages.
|
|
665
|
+
|
|
666
|
+
Latest full run (`--browser --forms --stored`):
|
|
667
|
+
|
|
668
|
+
```
|
|
669
|
+
phase 1 - crawl and scan (default level 2)
|
|
670
|
+
TP /attr?name attr_double browser-confirmed
|
|
671
|
+
TP /attr-single?name attr_single browser-confirmed
|
|
672
|
+
TP /attr-unquoted?q attr_unquoted browser-confirmed
|
|
673
|
+
TP /charset?q charset browser-confirmed
|
|
674
|
+
TP /comment?x comment browser-confirmed
|
|
675
|
+
TP /csp?q html_text csp-blocked-execution
|
|
676
|
+
TP /csp-report-only?q html_text browser-confirmed
|
|
677
|
+
TP /dom?q dom_xss browser-confirmed
|
|
678
|
+
TP /dom-hash?q dom_xss browser-confirmed
|
|
679
|
+
TP /dom-hydrate?q dom_xss browser-confirmed
|
|
680
|
+
TP /dom-state-read?q dom_xss browser-confirmed
|
|
681
|
+
TP /feedback?message stored_xss browser-confirmed
|
|
682
|
+
TP /filter?q html_text browser-confirmed
|
|
683
|
+
TP /guestbook?comment stored_xss browser-confirmed
|
|
684
|
+
TP /js?name js_string_double browser-confirmed
|
|
685
|
+
TP /js-single?name js_string_single browser-confirmed
|
|
686
|
+
TP /misconfig?q html_text browser-confirmed
|
|
687
|
+
TP /search?q html_text browser-confirmed
|
|
688
|
+
TP /textarea?msg textarea browser-confirmed
|
|
689
|
+
TP /title?q title browser-confirmed
|
|
690
|
+
TN /charset-safe?q - utf-8 decoding keeps the escape
|
|
691
|
+
TN /encoded?q - html_escape
|
|
692
|
+
TN /stripped?q - character stripping
|
|
693
|
+
TN /safe?q - json script escaping
|
|
694
|
+
TN /json?q - json content-type + nosniff
|
|
695
|
+
-> 20/20 detected (100% recall), 0 false positive(s) (100% precision)
|
|
696
|
+
crawl: 43 pages, 27 parameterised URLs, 12 forms; 413 requests
|
|
697
|
+
|
|
698
|
+
phase 2 - direct per-endpoint scan (reflected only)
|
|
699
|
+
-> 14/14 detected (100% recall), 0 false positive(s) (100% precision)
|
|
700
|
+
|
|
701
|
+
phase 3 - POST form with CSRF token token handled
|
|
702
|
+
phase 4 - machine-readable outputs SARIF 2.1.0 valid, evidence written
|
|
703
|
+
phase 5 - mutation XSS round trip round trip re-executed, high/confirmed
|
|
704
|
+
phase 6 - multibyte charset escaping trail byte proven; no-browser run not_tested
|
|
705
|
+
phase 7 - authenticated session, JSON planting tokens refreshed: 2, stored proven: 4
|
|
706
|
+
phase 8 - resume state and viewer pages point skipped, finding carried over
|
|
707
|
+
all checks passed
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
The lab deliberately includes traps: HTML-escaped output, a character-stripping filter,
|
|
711
|
+
JSON inside a non-executable `<script type="application/json">` block, JSON served with
|
|
712
|
+
`nosniff`, a UTF-8 route sharing the charset payload, and a
|
|
713
|
+
`Content-Security-Policy-Report-Only` header that must *not* be treated as enforcement.
|
|
714
|
+
All of them stay clean.
|
|
715
|
+
|
|
716
|
+
### Second, independent lab
|
|
717
|
+
|
|
718
|
+
`bench/` scores the scanner against `bench/lab2/app.py`, a second vulnerable application
|
|
719
|
+
written separately from `testapp/`, with its own ground truth (`bench/verify_lab.py`)
|
|
720
|
+
and a scorer that reports precision/recall rather than a pass/fail count:
|
|
721
|
+
|
|
722
|
+
```bash
|
|
723
|
+
python bench/run_bench.py # no browser
|
|
724
|
+
python bench/run_bench.py --browser # with execution proof
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
Latest browser run: 9 true positives, 0 false positives, 0 false negatives, 4 true
|
|
728
|
+
negatives (precision 1.00, recall 1.00, F1 1.00) over 13 labelled routes. Having two
|
|
729
|
+
labs written independently of each other is the point: a payload tuned to one app's
|
|
730
|
+
response shape shows up as a miss on the other.
|
|
731
|
+
|
|
732
|
+
## Tests and CI
|
|
733
|
+
|
|
734
|
+
```bash
|
|
735
|
+
python -m unittest discover -s tests -p "test_*.py" -v # 445 tests, no network
|
|
736
|
+
python -m ruff check . # lint
|
|
737
|
+
python -m mypy xssmap # types on shipped package
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
Units cover the parts that are hard to eyeball: the tri-state verifier and its
|
|
741
|
+
"exception is not a negative" rule, the CSP parser (including report-only), the mXSS
|
|
742
|
+
round-trip gate, charset trail-byte generation and the UTF-8 control, throttle and
|
|
743
|
+
`Retry-After` handling, resume state, report/SARIF rendering, subdomain parsing, suffix
|
|
744
|
+
derivation and scope matching, the autonomous preset's precedence rules, DOM source and
|
|
745
|
+
delivery inference (`location.hash`/`location.search` outranking the source table),
|
|
746
|
+
storage write/read joins including the cross-origin drop, hydration and `dataset`
|
|
747
|
+
sources, parameter guessing for flows that name none, inline-script merging across
|
|
748
|
+
`<script>` blocks, payload surgery and request replay for the exploit stage (including
|
|
749
|
+
the no-whitespace/no-angle-bracket rule), session re-login and captured-cookie
|
|
750
|
+
precedence, `strict-dynamic` gadget and JSONP notes, adaptive evasion ordering, the
|
|
751
|
+
transport deadline clip, and the bench ground truth.
|
|
752
|
+
|
|
753
|
+
`ruff` and `mypy` settings live in `pyproject.toml`; the ignores are documented inline
|
|
754
|
+
next to the rule they silence rather than in a bare list. mypy is scoped to `xssmap/`
|
|
755
|
+
(the code users run) and does not cover `tests/` or `bench/` fixtures.
|
|
756
|
+
|
|
757
|
+
`.github/workflows/ci.yml` runs five jobs: lint, types, units on a
|
|
758
|
+
3.9/3.12/Windows matrix, the browser-free selftest on Linux and Windows, and the full
|
|
759
|
+
browser harness on Linux (advisory, since headless timing on shared runners varies).
|
|
760
|
+
|
|
761
|
+
## Limitations
|
|
762
|
+
|
|
763
|
+
- DOM analysis is static taint tracing plus runtime confirmation. It recognises common
|
|
764
|
+
framework sources and sinks (`useSearchParams`/`useLocation`, `dangerouslySetInnerHTML`,
|
|
765
|
+
`v-html`, `bypassSecurityTrustHtml`, `createContextualFragment`, jQuery `.parseHTML`,
|
|
766
|
+
`innerHTML`, `eval`, `document.write`, `location`) and follows a value across resources
|
|
767
|
+
through web storage, server-rendered state blobs, `data-*` attributes, component state
|
|
768
|
+
(`setState({...})`, `this.state.x`, `useState`), reducer dispatch (`dispatch({type:...})`
|
|
769
|
+
joined to the matching `case` handler, including spread state updates) and a pub/sub bus
|
|
770
|
+
(`emit`/`publish` joined to `on`/`subscribe` handlers). Values carried in an object
|
|
771
|
+
property or an array (`holder.value`, `parts.push`, `arr.join`) are tracked to the sink
|
|
772
|
+
as well. It is still not a general data-flow analysis: a flow that is rendered by a
|
|
773
|
+
server template the crawler never fetches, or that is assembled by string operations the
|
|
774
|
+
taint model does not name, stays a miss. A storage join also needs both pages inside the
|
|
775
|
+
crawl budget (`--max-pages`, `--crawl-depth`), and a blob needs the script that reads it.
|
|
776
|
+
A component-state, reducer or event-bus join is only replayed when the write and the read
|
|
777
|
+
happen on the same page, because an in-memory store is reset by a second navigation; a
|
|
778
|
+
cross-page join is reported as a lead with that reason. Only flows proven in the browser
|
|
779
|
+
are reported, so a miss stays a gap, never a false positive.
|
|
780
|
+
- A lead means the parameter reflects and every payload the scanner tried was filtered,
|
|
781
|
+
encoded or cut. It is recorded with the measured filter (surviving characters, blocked
|
|
782
|
+
keywords and sequences, truncation limit, efficiency score) and low confidence; it is not a
|
|
783
|
+
finding, and it is not proof that a bypass exists - only a reason to look by hand. The
|
|
784
|
+
measurement explains *where* the value landed and how much of it survived; it cannot
|
|
785
|
+
explain why an application-level filter chose to rewrite it.
|
|
786
|
+
- Stored scanning writes data through forms. It is opt-in (`--stored`) and should only be
|
|
787
|
+
used on systems you are authorised to modify.
|
|
788
|
+
- Browser proof and `--verify` cover classic inline execution. CSP handling is bounded on
|
|
789
|
+
purpose: one nonce-reuse attempt when the header really repeats a nonce, then the
|
|
790
|
+
observed block is reported. `strict-dynamic` is analysed rather than assumed: the finding
|
|
791
|
+
notes count the page's own script-creating gadgets (`createElement('script')`,
|
|
792
|
+
`innerHTML`/`document.write` with `<script>`, `insertAdjacentHTML`), because that trust
|
|
793
|
+
model still executes a script a trusted gadget builds from attacker data, and they list
|
|
794
|
+
allowlisted JSONP endpoints that take a callback parameter (skipped under
|
|
795
|
+
`strict-dynamic`, where a host allowlist does not apply). Those are leads for a manual
|
|
796
|
+
gadget check, not an execution claim: the scanner does not weaponise a gadget on its own,
|
|
797
|
+
so a target behind a well-built CSP will usually stay `unproven` - which is a statement
|
|
798
|
+
about the measurement, not about safety.
|
|
799
|
+
- `--verify` replays the recorded request with the run's session. When a login was
|
|
800
|
+
performed (`--login-url`/`--login-user`/`--login-pass`), the credentials are kept in
|
|
801
|
+
memory only for the run: if a replay lands on a login wall (401/403 or a login path),
|
|
802
|
+
the scanner logs in again once, re-syncs the browser cookies and retries, and the
|
|
803
|
+
finding records `reauth`. A captured `Cookie` header from the original request is
|
|
804
|
+
dropped while a session exists so the fresh cookie is the one that is sent. A replay
|
|
805
|
+
still fails as `unproven` if the second login is refused, and the reason says so.
|
|
806
|
+
- The exploit stage proves what the script could read *at that moment*: cookie byte
|
|
807
|
+
counts and names, token readability and one same-origin GET. It does not attempt
|
|
808
|
+
state-changing requests, so it demonstrates capability rather than performing writes.
|
|
809
|
+
- `--callback` binds to loopback by default. Use `--callback-bind` for another interface,
|
|
810
|
+
or `--callback-url` when the listener sits behind NAT and the target must reach a
|
|
811
|
+
public address; the listener is unauthenticated by design and should be short-lived.
|
|
812
|
+
- mXSS and charset stages prove what the *scanner's* browser does with a payload. A
|
|
813
|
+
different browser or version may round-trip differently, so treat a positive as
|
|
814
|
+
"this payload executed here", not "every client is affected".
|
|
815
|
+
- `--save-cookies` writes live session secrets to disk in Netscape format. The file is
|
|
816
|
+
created `0600` on POSIX; Windows does not enforce mode bits, so rely on NTFS ACLs there.
|
|
817
|
+
Treat the file like a password either way.
|
|
818
|
+
- Subdomain discovery is best-effort reconnaissance, not a complete asset inventory. The
|
|
819
|
+
enumeration base is public-suffix aware for the common `co.uk`/`com.au`-style suffixes
|
|
820
|
+
built in, and `--public-suffix-list FILE` loads the real PSL (`*.` and `!` rules
|
|
821
|
+
supported) when you need full coverage; without it, an unusual multi-label suffix falls
|
|
822
|
+
back to the simple rule. Certificate transparency is queried through three independent
|
|
823
|
+
providers in order (crt.sh, certspotter, hackertarget): a failing or rate-limited
|
|
824
|
+
provider is recorded and skipped, the next one is asked, and the per-source yield is
|
|
825
|
+
reported, so one bad day at crt.sh no longer empties the result. Coverage is still
|
|
826
|
+
best-effort, and the DNS step uses a short built-in wordlist - feed your own recon in
|
|
827
|
+
through `--subdomain-wordlist` for a full inventory.
|
|
828
|
+
- `--max-minutes` is enforced inside phases as well as between them (crawl, scan pool,
|
|
829
|
+
DOM, stored and exploit stages all check a deadline), and the transport clips each
|
|
830
|
+
request's socket timeout to the budget that is left, with retry sleeps and throttle
|
|
831
|
+
pacing stopping at the deadline. A request already in flight can still exceed the budget
|
|
832
|
+
by at most the remaining slice (floored at 0.25s), and the report is then marked
|
|
833
|
+
`partial` rather than pretending the sweep finished.
|
|
834
|
+
- Heavy crawls against large sites should be bounded with `--max-pages`, `--delay` and
|
|
835
|
+
`--threads`; the scanner honours those limits for every request it makes.
|
|
836
|
+
- WAF evasion is signature-driven at first and adaptive afterwards: each strategy that
|
|
837
|
+
makes a payload reflect verbatim is tried first on later points, and one that only
|
|
838
|
+
produces mangled or blocked responses moves to the end of the budget (the per-scan
|
|
839
|
+
ranking is written to the report as `evasion_learned`). A proprietary or heavily
|
|
840
|
+
customised filter may still be misidentified or missed entirely, and the vendor
|
|
841
|
+
vocabulary only helps when the blocking rule is one of the known shapes. A failed bypass
|
|
842
|
+
is a failed bypass, not proof that the target is safe.
|
|
843
|
+
- `--brute`, `--discover-params` and `--fuzz` are request-hungry by design. Each has an
|
|
844
|
+
explicit budget and stops at it; the report marks a truncated fuzz run instead of
|
|
845
|
+
implying it covered every parameter.
|
|
846
|
+
- JavaScript CVE hints are version indicators, not exploit proof, and only cover the
|
|
847
|
+
libraries in the signature table. A current library can still be vulnerable through the
|
|
848
|
+
application's own code, and a library version read from a filename can be wrong.
|
|
849
|
+
|
|
850
|
+
Only test systems you are authorised to test.
|