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/browser.py
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
"""Headless browser execution verifier.
|
|
2
|
+
|
|
3
|
+
A reflected payload that survives verbatim is strong evidence, but only a real
|
|
4
|
+
engine proves execution -- and a real engine is also what proves that a Content
|
|
5
|
+
Security Policy *stopped* it. We look for a local Chrome/Chromium/Edge, drive it
|
|
6
|
+
over the DevTools protocol (stdlib WebSocket, no dependencies) and ask the live
|
|
7
|
+
DOM whether the injected script managed to create its artifact element.
|
|
8
|
+
|
|
9
|
+
Asking the DOM instead of grepping the serialized HTML matters: the handler
|
|
10
|
+
source itself contains the marker string, so a substring match would "confirm"
|
|
11
|
+
payloads that never ran.
|
|
12
|
+
|
|
13
|
+
For POST injections a tiny auto-submitting form is written to disk and opened,
|
|
14
|
+
so the browser performs a genuine cross-document POST to the target.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import platform
|
|
22
|
+
import shutil
|
|
23
|
+
import socket
|
|
24
|
+
import struct
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import urllib.parse
|
|
30
|
+
import urllib.request
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from .verify import Verification
|
|
34
|
+
|
|
35
|
+
CANDIDATES_WIN = [
|
|
36
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
37
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
38
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
|
|
39
|
+
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
|
40
|
+
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
|
41
|
+
r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
|
|
42
|
+
r"C:\Program Files\Chromium\Application\chrome.exe",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
CANDIDATES_NIX = [
|
|
46
|
+
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
|
|
47
|
+
"/usr/bin/chromium-browser", "/snap/bin/chromium", "/usr/bin/microsoft-edge",
|
|
48
|
+
"/usr/bin/brave-browser", "/usr/bin/vivaldi",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
CANDIDATES_MAC = [
|
|
52
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
53
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
54
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
55
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_browser() -> str | None:
|
|
60
|
+
system = platform.system()
|
|
61
|
+
if system == "Windows":
|
|
62
|
+
paths = CANDIDATES_WIN
|
|
63
|
+
elif system == "Darwin":
|
|
64
|
+
paths = CANDIDATES_MAC
|
|
65
|
+
else:
|
|
66
|
+
paths = CANDIDATES_NIX
|
|
67
|
+
for p in paths:
|
|
68
|
+
if p and os.path.isfile(p):
|
|
69
|
+
return p
|
|
70
|
+
for name in ("chrome", "chromium", "google-chrome", "msedge", "brave-browser"):
|
|
71
|
+
found = shutil.which(name)
|
|
72
|
+
if found:
|
|
73
|
+
return found
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DevToolsError(RuntimeError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WebSocket:
|
|
82
|
+
"""Bare-bones RFC 6455 client: text frames out, text messages in."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, url: str, timeout: float = 15.0):
|
|
85
|
+
parts = urllib.parse.urlsplit(url)
|
|
86
|
+
if parts.scheme not in ("ws", "wss"):
|
|
87
|
+
raise DevToolsError(f"unsupported websocket url: {url}")
|
|
88
|
+
host, port = parts.hostname, parts.port or (443 if parts.scheme == "wss" else 80)
|
|
89
|
+
path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
|
|
90
|
+
self.sock = socket.create_connection((host, port), timeout=timeout)
|
|
91
|
+
self.sock.settimeout(timeout)
|
|
92
|
+
key = base64.b64encode(os.urandom(16)).decode()
|
|
93
|
+
request = (
|
|
94
|
+
f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n"
|
|
95
|
+
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
96
|
+
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
|
|
97
|
+
)
|
|
98
|
+
self.sock.sendall(request.encode())
|
|
99
|
+
raw = b""
|
|
100
|
+
while b"\r\n\r\n" not in raw:
|
|
101
|
+
chunk = self.sock.recv(4096)
|
|
102
|
+
if not chunk:
|
|
103
|
+
raise DevToolsError("devtools closed during handshake")
|
|
104
|
+
raw += chunk
|
|
105
|
+
head, _, self._buf = raw.partition(b"\r\n\r\n")
|
|
106
|
+
status = head.split(b"\r\n", 1)[0]
|
|
107
|
+
if b" 101 " not in status + b" ":
|
|
108
|
+
raise DevToolsError(f"devtools handshake failed: {status[:80]!r}")
|
|
109
|
+
|
|
110
|
+
# -- framing -------------------------------------------------------
|
|
111
|
+
def _read(self, n: int) -> bytes:
|
|
112
|
+
while len(self._buf) < n:
|
|
113
|
+
chunk = self.sock.recv(max(4096, n - len(self._buf)))
|
|
114
|
+
if not chunk:
|
|
115
|
+
raise DevToolsError("devtools connection closed")
|
|
116
|
+
self._buf += chunk
|
|
117
|
+
out, self._buf = self._buf[:n], self._buf[n:]
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def _send_frame(self, opcode: int, payload: bytes) -> None:
|
|
121
|
+
mask = os.urandom(4)
|
|
122
|
+
header = bytearray([0x80 | opcode])
|
|
123
|
+
n = len(payload)
|
|
124
|
+
if n < 126:
|
|
125
|
+
header.append(0x80 | n)
|
|
126
|
+
elif n < 65536:
|
|
127
|
+
header.append(0x80 | 126)
|
|
128
|
+
header += struct.pack(">H", n)
|
|
129
|
+
else:
|
|
130
|
+
header.append(0x80 | 127)
|
|
131
|
+
header += struct.pack(">Q", n)
|
|
132
|
+
header += mask
|
|
133
|
+
masked = bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
|
|
134
|
+
self.sock.sendall(bytes(header) + masked)
|
|
135
|
+
|
|
136
|
+
def send(self, text: str) -> None:
|
|
137
|
+
self._send_frame(0x1, text.encode("utf-8"))
|
|
138
|
+
|
|
139
|
+
def recv(self) -> str:
|
|
140
|
+
chunks: list[bytes] = []
|
|
141
|
+
while True:
|
|
142
|
+
try:
|
|
143
|
+
b1, b2 = self._read(2)
|
|
144
|
+
except socket.timeout as exc:
|
|
145
|
+
raise DevToolsError("devtools read timed out") from exc
|
|
146
|
+
fin, opcode = b1 & 0x80, b1 & 0x0F
|
|
147
|
+
masked, length = b2 & 0x80, b2 & 0x7F
|
|
148
|
+
if length == 126:
|
|
149
|
+
length = struct.unpack(">H", self._read(2))[0]
|
|
150
|
+
elif length == 127:
|
|
151
|
+
length = struct.unpack(">Q", self._read(8))[0]
|
|
152
|
+
mask = self._read(4) if masked else None
|
|
153
|
+
payload = self._read(length)
|
|
154
|
+
if mask:
|
|
155
|
+
payload = bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
|
|
156
|
+
if opcode == 0x9: # ping
|
|
157
|
+
self._send_frame(0xA, payload)
|
|
158
|
+
continue
|
|
159
|
+
if opcode == 0x8:
|
|
160
|
+
raise DevToolsError("devtools closed the session")
|
|
161
|
+
chunks.append(payload)
|
|
162
|
+
if fin:
|
|
163
|
+
return b"".join(chunks).decode("utf-8", "replace")
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
try:
|
|
167
|
+
self._send_frame(0x8, b"")
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
try:
|
|
171
|
+
self.sock.close()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CDP:
|
|
177
|
+
"""One request at a time; events are collected but never awaited."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, ws: WebSocket):
|
|
180
|
+
self.ws = ws
|
|
181
|
+
self._id = 0
|
|
182
|
+
self.events: list[dict] = []
|
|
183
|
+
|
|
184
|
+
def call(self, method: str, params: dict | None = None, timeout: float = 20.0) -> dict:
|
|
185
|
+
self._id += 1
|
|
186
|
+
mid = self._id
|
|
187
|
+
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
|
|
188
|
+
deadline = time.time() + timeout
|
|
189
|
+
while True:
|
|
190
|
+
if time.time() > deadline:
|
|
191
|
+
raise DevToolsError(f"devtools timeout on {method}")
|
|
192
|
+
message = json.loads(self.ws.recv())
|
|
193
|
+
if message.get("id") == mid:
|
|
194
|
+
if "error" in message:
|
|
195
|
+
raise DevToolsError(str(message["error"].get("message", message["error"])))
|
|
196
|
+
return message.get("result") or {}
|
|
197
|
+
if "method" in message:
|
|
198
|
+
self.events.append(message)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class BrowserVerifier:
|
|
202
|
+
def __init__(self, enabled: bool = True, timeout: float = 25.0, log=None,
|
|
203
|
+
headless: str = "new", proxy: str | None = None):
|
|
204
|
+
self.log = log
|
|
205
|
+
self.timeout = timeout
|
|
206
|
+
self.headless = headless
|
|
207
|
+
self.proxy = proxy
|
|
208
|
+
self.path = find_browser() if enabled else None
|
|
209
|
+
self.available = bool(enabled and self.path)
|
|
210
|
+
self.runs = 0
|
|
211
|
+
self.hits = 0
|
|
212
|
+
self.launches = 0 # browser process starts, expected to stay at 1
|
|
213
|
+
self.seconds = 0.0 # wall time spent inside verification
|
|
214
|
+
self.last_error = ""
|
|
215
|
+
self.last_result: dict = {}
|
|
216
|
+
self.cookies: list[dict] = [] # [{'name','value','url'}] applied over CDP
|
|
217
|
+
self._profile: str | None = None
|
|
218
|
+
self._proc: subprocess.Popen | None = None
|
|
219
|
+
self._cdp: CDP | None = None
|
|
220
|
+
self._port: int | None = None
|
|
221
|
+
self._observers_on = False
|
|
222
|
+
self._lock = threading.RLock()
|
|
223
|
+
|
|
224
|
+
def describe(self) -> str:
|
|
225
|
+
"""One line for the report header.
|
|
226
|
+
|
|
227
|
+
``launches`` is reported on purpose: the verifier keeps one browser
|
|
228
|
+
process and one DevTools session alive for the whole scan, so a run with
|
|
229
|
+
many checks and a single launch is the evidence that page loads were
|
|
230
|
+
reused instead of paying a browser start per payload.
|
|
231
|
+
"""
|
|
232
|
+
if not self.available:
|
|
233
|
+
return "browser verification: disabled (no Chromium/Chrome/Edge found)"
|
|
234
|
+
note = f"browser verification: {os.path.basename(self.path or '')}"
|
|
235
|
+
if self.runs:
|
|
236
|
+
note += (f" ({self.runs} check(s), {self.launches} launch(es), "
|
|
237
|
+
f"{1000.0 * self.seconds / self.runs:.0f} ms/check)")
|
|
238
|
+
return note
|
|
239
|
+
|
|
240
|
+
# -- lifecycle -----------------------------------------------------
|
|
241
|
+
def _profile_dir(self) -> str:
|
|
242
|
+
if self._profile is None:
|
|
243
|
+
self._profile = tempfile.mkdtemp(prefix="xssmap-profile-")
|
|
244
|
+
return self._profile
|
|
245
|
+
|
|
246
|
+
def _launch(self) -> None:
|
|
247
|
+
if self._cdp is not None and self._port and self._alive(self._port):
|
|
248
|
+
return
|
|
249
|
+
self._shutdown_browser()
|
|
250
|
+
profile = self._profile_dir()
|
|
251
|
+
port = _free_port()
|
|
252
|
+
proxy_args = (["--proxy-server=" + self.proxy] if self.proxy
|
|
253
|
+
else ["--no-proxy-server"])
|
|
254
|
+
args = [
|
|
255
|
+
self.path or "",
|
|
256
|
+
f"--headless={self.headless}" if self.headless in ("new", "old") else "--headless",
|
|
257
|
+
"--no-sandbox",
|
|
258
|
+
*proxy_args,
|
|
259
|
+
f"--remote-debugging-port={port}",
|
|
260
|
+
f"--user-data-dir={profile}",
|
|
261
|
+
"--disable-gpu",
|
|
262
|
+
"--disable-dev-shm-usage",
|
|
263
|
+
"--no-first-run",
|
|
264
|
+
"--no-default-browser-check",
|
|
265
|
+
"--disable-extensions",
|
|
266
|
+
"--disable-background-networking",
|
|
267
|
+
"--disable-sync",
|
|
268
|
+
"--disable-popup-blocking",
|
|
269
|
+
"--mute-audio",
|
|
270
|
+
"--window-size=1280,900",
|
|
271
|
+
"about:blank",
|
|
272
|
+
]
|
|
273
|
+
kwargs: dict = {
|
|
274
|
+
"stdin": subprocess.DEVNULL,
|
|
275
|
+
"stdout": subprocess.DEVNULL,
|
|
276
|
+
"stderr": subprocess.DEVNULL,
|
|
277
|
+
}
|
|
278
|
+
if platform.system() == "Windows":
|
|
279
|
+
kwargs["creationflags"] = 0x08000000 # CREATE_NO_WINDOW
|
|
280
|
+
else:
|
|
281
|
+
kwargs["start_new_session"] = True
|
|
282
|
+
self._proc = subprocess.Popen(args, **kwargs)
|
|
283
|
+
|
|
284
|
+
# The Windows launcher often exits at once while the real browser keeps
|
|
285
|
+
# running detached, so liveness is judged by the DevTools endpoint only.
|
|
286
|
+
if not self._wait_for_devtools(port, 20.0):
|
|
287
|
+
raise DevToolsError(self.last_error or "browser did not expose a DevTools port")
|
|
288
|
+
self._port = port
|
|
289
|
+
ws_url = self._page_socket(port)
|
|
290
|
+
cdp = CDP(WebSocket(ws_url, timeout=20.0))
|
|
291
|
+
self._cdp = cdp
|
|
292
|
+
for domain in ("Page.enable", "Runtime.enable"):
|
|
293
|
+
try:
|
|
294
|
+
cdp.call(domain, timeout=10.0)
|
|
295
|
+
except DevToolsError:
|
|
296
|
+
pass
|
|
297
|
+
self.launches += 1
|
|
298
|
+
|
|
299
|
+
def _alive(self, port: int) -> bool:
|
|
300
|
+
try:
|
|
301
|
+
with urllib.request.urlopen(
|
|
302
|
+
f"http://127.0.0.1:{port}/json/version", timeout=1.5) as resp:
|
|
303
|
+
return getattr(resp, "status", 0) == 200
|
|
304
|
+
except Exception:
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
def _wait_for_devtools(self, port: int, timeout: float) -> bool:
|
|
308
|
+
deadline = time.time() + timeout
|
|
309
|
+
while time.time() < deadline:
|
|
310
|
+
if self._alive(port):
|
|
311
|
+
return True
|
|
312
|
+
time.sleep(0.15)
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
def _page_socket(self, port: int) -> str:
|
|
316
|
+
base = f"http://127.0.0.1:{port}"
|
|
317
|
+
for _ in range(40):
|
|
318
|
+
try:
|
|
319
|
+
with urllib.request.urlopen(base + "/json/list", timeout=2) as resp:
|
|
320
|
+
targets = json.loads(resp.read().decode("utf-8", "replace"))
|
|
321
|
+
for t in targets:
|
|
322
|
+
if t.get("type") == "page" and t.get("webSocketDebuggerUrl"):
|
|
323
|
+
return t["webSocketDebuggerUrl"]
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
|
326
|
+
time.sleep(0.25)
|
|
327
|
+
# no page target yet: ask for one explicitly
|
|
328
|
+
try:
|
|
329
|
+
req = urllib.request.Request(base + "/json/new?about:blank", method="PUT")
|
|
330
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
331
|
+
target = json.loads(resp.read().decode("utf-8", "replace"))
|
|
332
|
+
if target.get("webSocketDebuggerUrl"):
|
|
333
|
+
return target["webSocketDebuggerUrl"]
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
raise DevToolsError("browser exposed no page target")
|
|
337
|
+
|
|
338
|
+
def _shutdown_browser(self) -> None:
|
|
339
|
+
# graceful: ask the browser to exit through its own DevTools endpoint
|
|
340
|
+
if self._port:
|
|
341
|
+
try:
|
|
342
|
+
with urllib.request.urlopen(
|
|
343
|
+
f"http://127.0.0.1:{self._port}/json/version", timeout=1.5) as resp:
|
|
344
|
+
info = json.loads(resp.read().decode("utf-8", "replace"))
|
|
345
|
+
browser_ws = info.get("webSocketDebuggerUrl")
|
|
346
|
+
if browser_ws:
|
|
347
|
+
CDP(WebSocket(browser_ws, timeout=4.0)).call("Browser.close", timeout=3.0)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
if self._cdp is not None:
|
|
351
|
+
try:
|
|
352
|
+
self._cdp.ws.close()
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
self._cdp = None
|
|
356
|
+
self._observers_on = False
|
|
357
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
358
|
+
try:
|
|
359
|
+
self._proc.terminate()
|
|
360
|
+
self._proc.wait(timeout=3)
|
|
361
|
+
except Exception:
|
|
362
|
+
try:
|
|
363
|
+
self._proc.kill()
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
self._proc = None
|
|
367
|
+
if self._profile:
|
|
368
|
+
_kill_by_profile(self._profile)
|
|
369
|
+
self._port = None
|
|
370
|
+
|
|
371
|
+
# -- navigation ----------------------------------------------------
|
|
372
|
+
def _evaluate(self, expression: str, timeout: float = 15.0) -> str:
|
|
373
|
+
cdp = self._cdp
|
|
374
|
+
if cdp is None:
|
|
375
|
+
return ""
|
|
376
|
+
result = cdp.call("Runtime.evaluate", {
|
|
377
|
+
"expression": expression, "returnByValue": True, "awaitPromise": False,
|
|
378
|
+
}, timeout=timeout)
|
|
379
|
+
return str((result.get("result") or {}).get("value") or "")
|
|
380
|
+
|
|
381
|
+
def _evaluate_raw(self, expression: str, timeout: float = 15.0):
|
|
382
|
+
"""Evaluate and return the value with its type intact (dicts stay dicts)."""
|
|
383
|
+
cdp = self._cdp
|
|
384
|
+
if cdp is None:
|
|
385
|
+
return None
|
|
386
|
+
result = cdp.call("Runtime.evaluate", {
|
|
387
|
+
"expression": expression, "returnByValue": True, "awaitPromise": False,
|
|
388
|
+
}, timeout=timeout)
|
|
389
|
+
if result.get("exceptionDetails"):
|
|
390
|
+
return None
|
|
391
|
+
return (result.get("result") or {}).get("value")
|
|
392
|
+
|
|
393
|
+
def _load(self, url: str) -> None:
|
|
394
|
+
cdp = self._cdp
|
|
395
|
+
if cdp is None:
|
|
396
|
+
return
|
|
397
|
+
result = cdp.call("Page.navigate", {"url": url}, timeout=20.0)
|
|
398
|
+
self.last_result = {"requested": url, "error": result.get("errorText") or ""}
|
|
399
|
+
deadline = time.time() + 8.0
|
|
400
|
+
while time.time() < deadline:
|
|
401
|
+
try:
|
|
402
|
+
if self._evaluate("document.readyState", timeout=5.0) == "complete":
|
|
403
|
+
break
|
|
404
|
+
except DevToolsError:
|
|
405
|
+
pass
|
|
406
|
+
time.sleep(0.1)
|
|
407
|
+
time.sleep(0.15) # let load handlers (and inline onload) settle
|
|
408
|
+
|
|
409
|
+
def _dom_check(self, target_id: str) -> str:
|
|
410
|
+
expr = ("(function(){var e=document.getElementById(" + json.dumps(target_id) +
|
|
411
|
+
");return e?(e.tagName+':'+e.id):''})()")
|
|
412
|
+
return self._evaluate(expr)
|
|
413
|
+
|
|
414
|
+
def _enable_observers(self) -> None:
|
|
415
|
+
"""Turn on the event domains we need to explain a non-execution.
|
|
416
|
+
|
|
417
|
+
Without these, "the handler never fired" and "CSP refused the handler"
|
|
418
|
+
look identical from the outside. Enabling them costs one round trip per
|
|
419
|
+
browser launch and buys a defensible reason for every negative.
|
|
420
|
+
"""
|
|
421
|
+
if self._cdp is None or self._observers_on:
|
|
422
|
+
return
|
|
423
|
+
for domain in ("Runtime.enable", "Log.enable", "Network.enable"):
|
|
424
|
+
try:
|
|
425
|
+
self._cdp.call(domain, timeout=10.0)
|
|
426
|
+
except DevToolsError:
|
|
427
|
+
pass # not fatal: we just lose a diagnostic channel
|
|
428
|
+
self._observers_on = True
|
|
429
|
+
self._apply_cookies()
|
|
430
|
+
|
|
431
|
+
def set_cookies(self, cookies) -> None:
|
|
432
|
+
"""Carry an authenticated session into the verifier (secrets stay in memory)."""
|
|
433
|
+
self.cookies = [dict(c) for c in (cookies or []) if c.get("name")]
|
|
434
|
+
|
|
435
|
+
def _apply_cookies(self) -> None:
|
|
436
|
+
if self._cdp is None or not self.cookies:
|
|
437
|
+
return
|
|
438
|
+
for cookie in self.cookies:
|
|
439
|
+
try:
|
|
440
|
+
self._cdp.call("Network.setCookie", {
|
|
441
|
+
"name": cookie["name"], "value": cookie["value"],
|
|
442
|
+
"url": cookie["url"], "path": cookie.get("path", "/"),
|
|
443
|
+
}, timeout=10.0)
|
|
444
|
+
except (DevToolsError, KeyError):
|
|
445
|
+
continue # a rejected cookie must not break verification
|
|
446
|
+
|
|
447
|
+
def _event_mark(self) -> int:
|
|
448
|
+
return len(self._cdp.events) if self._cdp is not None else 0
|
|
449
|
+
|
|
450
|
+
def _events_since(self, mark: int) -> dict:
|
|
451
|
+
"""Distil events after ``mark`` into the handful of facts we care about."""
|
|
452
|
+
out: dict[str, Any] = {"csp": [], "errors": [], "failed": [], "scripts": 0}
|
|
453
|
+
if self._cdp is None:
|
|
454
|
+
return out
|
|
455
|
+
for event in self._cdp.events[mark:]:
|
|
456
|
+
method = event.get("method", "")
|
|
457
|
+
params = event.get("params") or {}
|
|
458
|
+
if method == "Log.entryAdded":
|
|
459
|
+
entry = params.get("entry") or {}
|
|
460
|
+
text = (entry.get("text") or "")[:300]
|
|
461
|
+
low = text.lower()
|
|
462
|
+
if "content security policy" in low or entry.get("source") == "security":
|
|
463
|
+
out["csp"].append(text)
|
|
464
|
+
elif entry.get("level") == "error":
|
|
465
|
+
out["errors"].append(text)
|
|
466
|
+
elif method == "Runtime.exceptionThrown":
|
|
467
|
+
details = params.get("exceptionDetails") or {}
|
|
468
|
+
text = (details.get("text") or "") + " " + str(
|
|
469
|
+
(details.get("exception") or {}).get("description", ""))
|
|
470
|
+
if "content security policy" in text.lower():
|
|
471
|
+
out["csp"].append(text[:300])
|
|
472
|
+
else:
|
|
473
|
+
out["errors"].append(text.strip()[:300])
|
|
474
|
+
elif method == "Network.loadingFailed":
|
|
475
|
+
reason = params.get("blockedReason") or params.get("errorText") or ""
|
|
476
|
+
if reason:
|
|
477
|
+
out["failed"].append(str(reason)[:120])
|
|
478
|
+
if "csp" in str(reason).lower():
|
|
479
|
+
out["csp"].append(f"request blocked: {reason}")
|
|
480
|
+
elif method == "Network.requestWillBeSent":
|
|
481
|
+
kind = params.get("type")
|
|
482
|
+
if kind == "Script":
|
|
483
|
+
out["scripts"] += 1
|
|
484
|
+
# Drop the consumed window: a long scan would otherwise accumulate every
|
|
485
|
+
# event of every navigation for the whole run. Each caller takes a fresh
|
|
486
|
+
# mark immediately before loading, so trimming here is safe.
|
|
487
|
+
self._cdp.events[:] = self._cdp.events[mark:][-500:]
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
# -- public API ----------------------------------------------------
|
|
491
|
+
def evaluate(self, expression: str, timeout: float = 15.0) -> str:
|
|
492
|
+
with self._lock:
|
|
493
|
+
self._launch()
|
|
494
|
+
return self._evaluate(expression, timeout=timeout)
|
|
495
|
+
|
|
496
|
+
def navigate(self, url: str, settle: float = 0.2) -> dict:
|
|
497
|
+
"""Load a URL in the current tab and report what actually happened."""
|
|
498
|
+
with self._lock:
|
|
499
|
+
self._launch()
|
|
500
|
+
self._apply_cookies()
|
|
501
|
+
try:
|
|
502
|
+
before = self._evaluate("document.URL")
|
|
503
|
+
except DevToolsError:
|
|
504
|
+
before = ""
|
|
505
|
+
self.last_result = {"url": url, "loaded": False}
|
|
506
|
+
cdp = self._cdp
|
|
507
|
+
if cdp is None:
|
|
508
|
+
self.last_result = {"url": url, "loaded": False,
|
|
509
|
+
"error": "browser connection is not open"}
|
|
510
|
+
return self.last_result
|
|
511
|
+
try:
|
|
512
|
+
result = cdp.call("Page.navigate", {"url": url}, timeout=25.0)
|
|
513
|
+
except DevToolsError as exc:
|
|
514
|
+
self.last_result = {"url": url, "loaded": False, "error": str(exc)[:200]}
|
|
515
|
+
return self.last_result
|
|
516
|
+
error_text = result.get("errorText")
|
|
517
|
+
# Page.navigate returns before the new document commits. Poll until the
|
|
518
|
+
# URL actually changes; otherwise readyState still describes the old page
|
|
519
|
+
# and every check races the load.
|
|
520
|
+
same_doc = before.split("#", 1)[0] == url.split("#", 1)[0]
|
|
521
|
+
deadline = time.time() + (3.0 if same_doc else 10.0)
|
|
522
|
+
final_url = before
|
|
523
|
+
ready = ""
|
|
524
|
+
while time.time() < deadline:
|
|
525
|
+
try:
|
|
526
|
+
final_url = self._evaluate("document.URL")
|
|
527
|
+
ready = self._evaluate("document.readyState")
|
|
528
|
+
except DevToolsError:
|
|
529
|
+
time.sleep(0.1)
|
|
530
|
+
continue
|
|
531
|
+
if ready in ("interactive", "complete") and (same_doc or final_url != before):
|
|
532
|
+
break
|
|
533
|
+
time.sleep(0.1)
|
|
534
|
+
time.sleep(settle)
|
|
535
|
+
ready = self._evaluate("document.readyState") or ready
|
|
536
|
+
loaded = bool(final_url) and ready in ("interactive", "complete")
|
|
537
|
+
self.last_result = {
|
|
538
|
+
"url": final_url, "requested": url,
|
|
539
|
+
"loaded": loaded, "error": error_text or "",
|
|
540
|
+
"title": self._evaluate("document.title"),
|
|
541
|
+
}
|
|
542
|
+
return self.last_result
|
|
543
|
+
|
|
544
|
+
def _wait_ready(self, timeout: float = 8.0) -> None:
|
|
545
|
+
deadline = time.time() + timeout
|
|
546
|
+
while time.time() < deadline:
|
|
547
|
+
try:
|
|
548
|
+
if self._evaluate("document.readyState", timeout=5.0) in ("interactive", "complete"):
|
|
549
|
+
return
|
|
550
|
+
except DevToolsError:
|
|
551
|
+
pass
|
|
552
|
+
time.sleep(0.1)
|
|
553
|
+
|
|
554
|
+
def screenshot(self, path: str) -> bool:
|
|
555
|
+
"""Capture the viewport as PNG evidence."""
|
|
556
|
+
with self._lock:
|
|
557
|
+
if self._cdp is None:
|
|
558
|
+
return False
|
|
559
|
+
try:
|
|
560
|
+
data = self._cdp.call("Page.captureScreenshot", {"format": "png"}, timeout=15.0)
|
|
561
|
+
raw = base64.b64decode(data.get("data", ""))
|
|
562
|
+
if not raw:
|
|
563
|
+
return False
|
|
564
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
565
|
+
with open(path, "wb") as fh:
|
|
566
|
+
fh.write(raw)
|
|
567
|
+
return True
|
|
568
|
+
except Exception:
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
def _page_live(self) -> dict:
|
|
572
|
+
"""Is there a committed, parsed document in the tab right now?"""
|
|
573
|
+
try:
|
|
574
|
+
probe = self._evaluate(
|
|
575
|
+
"(function(){return JSON.stringify({u:document.URL,"
|
|
576
|
+
"r:document.readyState,b:!!document.body,"
|
|
577
|
+
"n:(document.body?document.body.childElementCount:0)})})()")
|
|
578
|
+
except DevToolsError as exc:
|
|
579
|
+
return {"live": False, "error": str(exc)[:160]}
|
|
580
|
+
try:
|
|
581
|
+
data = json.loads(probe) if probe else {}
|
|
582
|
+
except ValueError:
|
|
583
|
+
return {"live": False, "error": "unreadable page probe"}
|
|
584
|
+
data["live"] = bool(data.get("u")) and data.get("r") in ("interactive", "complete")
|
|
585
|
+
return data
|
|
586
|
+
|
|
587
|
+
def verify_execution(self, req, target_id: str,
|
|
588
|
+
screenshot_path: str | None = None) -> Verification:
|
|
589
|
+
"""Load the injected request and return a tri-state verdict.
|
|
590
|
+
|
|
591
|
+
The distinction that matters: a page that loaded and did nothing is
|
|
592
|
+
``not_observed`` (a real negative), while anything that stopped us from
|
|
593
|
+
getting a valid look is ``not_tested`` (an absence of evidence). Only
|
|
594
|
+
the first may be used to argue that a payload does not work.
|
|
595
|
+
"""
|
|
596
|
+
if not self.available:
|
|
597
|
+
return Verification.not_tested("no Chromium/Chrome/Edge on this host")
|
|
598
|
+
with self._lock:
|
|
599
|
+
self.runs += 1
|
|
600
|
+
started = time.monotonic()
|
|
601
|
+
wrapper = None
|
|
602
|
+
self.last_result = {"loaded": False}
|
|
603
|
+
try:
|
|
604
|
+
target = req.url
|
|
605
|
+
if req.method.upper() == "POST":
|
|
606
|
+
wrapper = self._post_wrapper(req)
|
|
607
|
+
if wrapper is None:
|
|
608
|
+
return Verification.not_tested(
|
|
609
|
+
"could not build a POST wrapper page for the browser")
|
|
610
|
+
target = "file:///" + wrapper.replace("\\", "/")
|
|
611
|
+
self._launch()
|
|
612
|
+
self._enable_observers()
|
|
613
|
+
mark = self._event_mark()
|
|
614
|
+
self._load(target)
|
|
615
|
+
live = self._page_live()
|
|
616
|
+
events = self._events_since(mark)
|
|
617
|
+
self.last_result = {
|
|
618
|
+
"loaded": bool(live.get("live")),
|
|
619
|
+
"url": live.get("u", ""),
|
|
620
|
+
"requested": req.url,
|
|
621
|
+
"csp": events["csp"],
|
|
622
|
+
"console_errors": events["errors"],
|
|
623
|
+
"failed_requests": events["failed"],
|
|
624
|
+
"scripts": events["scripts"],
|
|
625
|
+
}
|
|
626
|
+
if not live.get("live"):
|
|
627
|
+
reason = (live.get("error") or "no committed document")
|
|
628
|
+
nav = (self.last_result or {}).get("error") or ""
|
|
629
|
+
if nav:
|
|
630
|
+
reason = f"{reason}; navigation error: {nav}"
|
|
631
|
+
return Verification.not_tested(f"page never committed: {reason}"[:240])
|
|
632
|
+
|
|
633
|
+
found = self._dom_check(target_id)
|
|
634
|
+
if screenshot_path:
|
|
635
|
+
self.screenshot(screenshot_path)
|
|
636
|
+
if found:
|
|
637
|
+
self.hits += 1
|
|
638
|
+
return Verification.confirmed(
|
|
639
|
+
f"DOM contains injected node <{found}> after execution",
|
|
640
|
+
artifact=found, url=live.get("u", ""))
|
|
641
|
+
|
|
642
|
+
detail = {"url": live.get("u", ""), "scripts": events["scripts"],
|
|
643
|
+
"csp": events["csp"], "console_errors": events["errors"]}
|
|
644
|
+
if events["csp"]:
|
|
645
|
+
return Verification.not_observed(
|
|
646
|
+
"Content-Security-Policy refused execution: "
|
|
647
|
+
+ events["csp"][0][:160], **detail)
|
|
648
|
+
if events["errors"]:
|
|
649
|
+
return Verification.not_observed(
|
|
650
|
+
"handler did not run; console reported: "
|
|
651
|
+
+ events["errors"][0][:160], **detail)
|
|
652
|
+
return Verification.not_observed(
|
|
653
|
+
"page rendered; the payload handler produced no effect", **detail)
|
|
654
|
+
except DevToolsError as exc:
|
|
655
|
+
self.last_error = str(exc)[:200]
|
|
656
|
+
self.last_result = {"loaded": False, "error": self.last_error}
|
|
657
|
+
self._shutdown_browser()
|
|
658
|
+
return Verification.not_tested(f"devtools failure, browser recycled: {exc}"[:240])
|
|
659
|
+
except Exception as exc:
|
|
660
|
+
self.last_error = f"{type(exc).__name__}: {exc}"[:200]
|
|
661
|
+
self.last_result = {"loaded": False, "error": self.last_error}
|
|
662
|
+
self._shutdown_browser()
|
|
663
|
+
return Verification.from_exception(exc, "verifier error")
|
|
664
|
+
finally:
|
|
665
|
+
self.seconds += time.monotonic() - started
|
|
666
|
+
if wrapper and os.path.exists(wrapper):
|
|
667
|
+
try:
|
|
668
|
+
os.remove(wrapper)
|
|
669
|
+
except OSError:
|
|
670
|
+
pass
|
|
671
|
+
|
|
672
|
+
def prove(self, req, probe_js: str, target_id: str = "",
|
|
673
|
+
trigger_js: str = "") -> dict:
|
|
674
|
+
"""Load a request and report what the injected script actually reached.
|
|
675
|
+
|
|
676
|
+
:meth:`verify_execution` answers "did this payload run" with one DOM
|
|
677
|
+
artifact. This answers the next question: once it runs, what can it
|
|
678
|
+
read and do? The probe is the injected script's own report (cookies it
|
|
679
|
+
can see, same-origin fetches it can make, whether it still owns the
|
|
680
|
+
document), so an exploit claim rests on the page's behaviour rather
|
|
681
|
+
than on the scanner's expectation of it.
|
|
682
|
+
"""
|
|
683
|
+
out: dict = {"loaded": False, "probe": None, "artifact": "",
|
|
684
|
+
"csp": [], "errors": [], "failed": [], "url": "", "error": ""}
|
|
685
|
+
if not self.available:
|
|
686
|
+
out["error"] = "no Chromium/Chrome/Edge on this host"
|
|
687
|
+
return out
|
|
688
|
+
with self._lock:
|
|
689
|
+
wrapper = None
|
|
690
|
+
try:
|
|
691
|
+
target = req.url
|
|
692
|
+
if req.method.upper() == "POST":
|
|
693
|
+
wrapper = self._post_wrapper(req)
|
|
694
|
+
if wrapper is None:
|
|
695
|
+
body = (req.body or b"").lstrip()
|
|
696
|
+
if body.startswith((b"{", b"[")):
|
|
697
|
+
out["error"] = ("JSON request bodies have no form equivalent, so "
|
|
698
|
+
"this POST cannot be replayed from a wrapper page")
|
|
699
|
+
else:
|
|
700
|
+
out["error"] = "could not build a POST wrapper page for the browser"
|
|
701
|
+
return out
|
|
702
|
+
target = "file:///" + wrapper.replace("\\", "/")
|
|
703
|
+
self._launch()
|
|
704
|
+
self._enable_observers()
|
|
705
|
+
mark = self._event_mark()
|
|
706
|
+
self._load(target)
|
|
707
|
+
live = self._page_live()
|
|
708
|
+
events = self._events_since(mark)
|
|
709
|
+
out.update({"loaded": bool(live.get("live")),
|
|
710
|
+
"url": live.get("u", ""),
|
|
711
|
+
"csp": events["csp"], "errors": events["errors"],
|
|
712
|
+
"failed": events["failed"], "scripts": events["scripts"]})
|
|
713
|
+
self.last_result = dict(out)
|
|
714
|
+
if not out["loaded"]:
|
|
715
|
+
out["error"] = live.get("error") or "no committed document"
|
|
716
|
+
return out
|
|
717
|
+
if trigger_js:
|
|
718
|
+
try:
|
|
719
|
+
self._evaluate(trigger_js, timeout=5.0)
|
|
720
|
+
except DevToolsError:
|
|
721
|
+
pass
|
|
722
|
+
time.sleep(0.2) # let the dispatched handlers run
|
|
723
|
+
if target_id:
|
|
724
|
+
out["artifact"] = self._dom_check(target_id)
|
|
725
|
+
if probe_js:
|
|
726
|
+
out["probe"] = self._evaluate_raw(probe_js)
|
|
727
|
+
return out
|
|
728
|
+
except DevToolsError as exc:
|
|
729
|
+
self.last_error = str(exc)[:200]
|
|
730
|
+
self._shutdown_browser()
|
|
731
|
+
out["error"] = f"devtools failure, browser recycled: {exc}"[:240]
|
|
732
|
+
return out
|
|
733
|
+
except Exception as exc:
|
|
734
|
+
self.last_error = f"{type(exc).__name__}: {exc}"[:200]
|
|
735
|
+
self._shutdown_browser()
|
|
736
|
+
out["error"] = self.last_error
|
|
737
|
+
return out
|
|
738
|
+
finally:
|
|
739
|
+
if wrapper and os.path.exists(wrapper):
|
|
740
|
+
try:
|
|
741
|
+
os.remove(wrapper)
|
|
742
|
+
except OSError:
|
|
743
|
+
pass
|
|
744
|
+
|
|
745
|
+
def roundtrip(self, markup: str, target_id: str) -> dict:
|
|
746
|
+
"""Serialize *markup* the way a sanitiser would, then re-parse it live.
|
|
747
|
+
|
|
748
|
+
Mutation XSS lives in the gap between the parser that produced a
|
|
749
|
+
document and the parser that later consumes it. This mirrors that gap:
|
|
750
|
+
parse -> serialise -> parse in a fresh document that actually executes,
|
|
751
|
+
and report whether the second parse produced a live node.
|
|
752
|
+
"""
|
|
753
|
+
if not self.available:
|
|
754
|
+
return {"ok": False, "error": "browser unavailable"}
|
|
755
|
+
script = _ROUNDTRIP_JS % (json.dumps(markup), json.dumps(target_id))
|
|
756
|
+
with self._lock:
|
|
757
|
+
self._launch()
|
|
758
|
+
if self._cdp is None:
|
|
759
|
+
return {"ok": False, "error": "browser not running"}
|
|
760
|
+
try:
|
|
761
|
+
result = self._cdp.call("Runtime.evaluate", {
|
|
762
|
+
"expression": script, "returnByValue": True, "awaitPromise": True,
|
|
763
|
+
}, timeout=20.0)
|
|
764
|
+
raw = str((result.get("result") or {}).get("value") or "")
|
|
765
|
+
except DevToolsError as exc:
|
|
766
|
+
self._shutdown_browser()
|
|
767
|
+
return {"ok": False, "error": str(exc)[:200]}
|
|
768
|
+
try:
|
|
769
|
+
data = json.loads(raw) if raw else {}
|
|
770
|
+
except ValueError:
|
|
771
|
+
return {"ok": False, "error": "unreadable round-trip result"}
|
|
772
|
+
data["ok"] = True
|
|
773
|
+
data["activated"] = bool(data.get("hit"))
|
|
774
|
+
data["control_activated"] = bool(data.get("control_hit"))
|
|
775
|
+
return data
|
|
776
|
+
|
|
777
|
+
def _post_wrapper(self, req) -> str | None:
|
|
778
|
+
try:
|
|
779
|
+
body = (req.body or b"").decode("utf-8", "replace")
|
|
780
|
+
pairs = urllib.parse.parse_qsl(body, keep_blank_values=True)
|
|
781
|
+
if not pairs:
|
|
782
|
+
return None
|
|
783
|
+
inputs = "\n".join(
|
|
784
|
+
f'<input type="hidden" name="{_attr(k)}" value="{_attr(v)}">' for k, v in pairs
|
|
785
|
+
)
|
|
786
|
+
html = (
|
|
787
|
+
"<!doctype html><html><body>"
|
|
788
|
+
f'<form id="f" method="POST" action="{_attr(req.url)}">{inputs}</form>'
|
|
789
|
+
"<script>document.getElementById('f').submit();</script>"
|
|
790
|
+
"</body></html>"
|
|
791
|
+
)
|
|
792
|
+
fd, path = tempfile.mkstemp(prefix="xssmap-post-", suffix=".html")
|
|
793
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
794
|
+
fh.write(html)
|
|
795
|
+
return path
|
|
796
|
+
except Exception:
|
|
797
|
+
return None
|
|
798
|
+
|
|
799
|
+
def cleanup(self) -> None:
|
|
800
|
+
with self._lock:
|
|
801
|
+
self._shutdown_browser()
|
|
802
|
+
if self._profile and os.path.isdir(self._profile):
|
|
803
|
+
shutil.rmtree(self._profile, ignore_errors=True)
|
|
804
|
+
self._profile = None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _free_port() -> int:
|
|
808
|
+
sock = socket.socket()
|
|
809
|
+
try:
|
|
810
|
+
sock.bind(("127.0.0.1", 0))
|
|
811
|
+
return sock.getsockname()[1]
|
|
812
|
+
finally:
|
|
813
|
+
sock.close()
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _kill_by_profile(profile: str) -> None:
|
|
817
|
+
"""Stop browser processes still holding *profile* (they can outlive the launcher)."""
|
|
818
|
+
names = ("msedge.exe", "chrome.exe", "brave.exe", "chromium.exe")
|
|
819
|
+
if platform.system() == "Windows":
|
|
820
|
+
quoted = profile.replace("'", "''")
|
|
821
|
+
filter_names = " or ".join(f"Name='{n}'" for n in names)
|
|
822
|
+
script = (
|
|
823
|
+
f"Get-CimInstance Win32_Process -Filter \"{filter_names}\" | "
|
|
824
|
+
f"Where-Object {{ $_.CommandLine -like '*{quoted}*' }} | "
|
|
825
|
+
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
|
826
|
+
)
|
|
827
|
+
try:
|
|
828
|
+
subprocess.run(
|
|
829
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
830
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
831
|
+
stderr=subprocess.DEVNULL, timeout=25,
|
|
832
|
+
creationflags=0x08000000,
|
|
833
|
+
)
|
|
834
|
+
except Exception:
|
|
835
|
+
pass
|
|
836
|
+
else:
|
|
837
|
+
try:
|
|
838
|
+
subprocess.run(
|
|
839
|
+
["pkill", "-f", profile], stdin=subprocess.DEVNULL,
|
|
840
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
|
841
|
+
)
|
|
842
|
+
except Exception:
|
|
843
|
+
pass
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _attr(value: str) -> str:
|
|
847
|
+
return (value.replace("&", "&").replace('"', """)
|
|
848
|
+
.replace("<", "<").replace(">", ">"))
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
# Parse -> serialise -> re-parse, with a paired control so the comparison is
|
|
852
|
+
# meaningful. Both stages load a same-origin srcdoc iframe that really executes:
|
|
853
|
+
# control = a fresh document parsed from the raw markup
|
|
854
|
+
# test = a fresh document parsed from serialize(parse(raw markup))
|
|
855
|
+
# A vector is mutation XSS only when the control stays inert and the test
|
|
856
|
+
# executes. Without the control, an ordinary payload that the serialiser merely
|
|
857
|
+
# re-quotes (src=x -> src="x") would be mislabelled as a mutation.
|
|
858
|
+
# The stage parse happens in a detached div, so nothing runs there; any stray
|
|
859
|
+
# artifact that a detached parse somehow created is cleaned up afterwards.
|
|
860
|
+
# Two %s slots, filled with json.dumps(): braces are never interpreted.
|
|
861
|
+
_ROUNDTRIP_JS = """(async function(){
|
|
862
|
+
var payload = %s, target = %s;
|
|
863
|
+
var stage = document.createElement('div');
|
|
864
|
+
stage.innerHTML = payload;
|
|
865
|
+
var serialized = stage.innerHTML;
|
|
866
|
+
function loadInto(html) {
|
|
867
|
+
return new Promise(function(resolve){
|
|
868
|
+
var frame = document.createElement('iframe');
|
|
869
|
+
frame.setAttribute('aria-hidden','true');
|
|
870
|
+
frame.style.cssText = 'width:0;height:0;border:0;position:absolute;left:-9999px';
|
|
871
|
+
(document.body || document.documentElement).appendChild(frame);
|
|
872
|
+
var settled = false;
|
|
873
|
+
function finish(how) {
|
|
874
|
+
if (settled) { return; }
|
|
875
|
+
settled = true;
|
|
876
|
+
setTimeout(function(){
|
|
877
|
+
var hit = '', err = '';
|
|
878
|
+
try {
|
|
879
|
+
var doc = frame.contentDocument;
|
|
880
|
+
var el = doc && doc.getElementById(target);
|
|
881
|
+
if (el) { hit = el.tagName + ':' + el.id; }
|
|
882
|
+
} catch (e) { err = e.name; }
|
|
883
|
+
frame.remove();
|
|
884
|
+
resolve({hit: hit, error: err, how: how});
|
|
885
|
+
}, 200);
|
|
886
|
+
}
|
|
887
|
+
frame.addEventListener('load', function(){ finish('load'); }, {once:true});
|
|
888
|
+
setTimeout(function(){ finish('timeout'); }, 2000);
|
|
889
|
+
frame.srcdoc = '<!doctype html><html><body>' + html + '</body></html>';
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
var control = await loadInto(payload);
|
|
893
|
+
var test = await loadInto(serialized);
|
|
894
|
+
var stray = document.getElementById(target);
|
|
895
|
+
if (stray && stray.parentNode) { stray.parentNode.removeChild(stray); }
|
|
896
|
+
stage.remove();
|
|
897
|
+
return JSON.stringify({serialized: serialized,
|
|
898
|
+
control_hit: control.hit, control_how: control.how,
|
|
899
|
+
hit: test.hit, how: test.how, error: test.error});
|
|
900
|
+
})()"""
|