"""Keyless scoped protocol profiles. Upstream engines run only through Transport.""" import base64 import contextlib import hashlib import html import importlib import io import json import os import re import sys import time import xml.etree.ElementTree as ET from pathlib import Path from types import SimpleNamespace from urllib.parse import urlsplit, urlunsplit try: from .scope import public_host, DNSResolutionFailed except ImportError: from scope import public_host, DNSResolutionFailed LIMITS = {"graphw00f": (64, 90), "corsy": (12, 30), "takeover": (1, 25), "s3-review": (2, 20), "websocket": (2, 15), "vcs-artifacts": (5, 25)} BROWSER_CORS = {"origin reflected", "post-domain wildcard", "pre-domain wildcard", "null origin allowed"} class Stopped(Exception): pass def origin(url): p = urlsplit(url) if (p.scheme not in ("http", "https") or not p.hostname or p.username or p.password or p.port not in (None, 80, 443, 8080) or "\\" in url or any(c in url for c in "\r\n\x00")): raise ValueError("Invalid origin") return p.scheme, p.hostname, p.port or (443 if p.scheme == "https" else 80) class Transport: def __init__(self, spec, session, pause=time.sleep): self.target, self.module = spec["target"], spec["module"] self.scope = origin(self.target) if self.scope[1] != spec["host"]: raise ValueError("Host mismatch") self.limit, self.seconds = LIMITS[self.module] self.started, self.last = time.monotonic(), None self.requests, self.truncated = 0, False self.delay = max(1.0, min(30.0, float(spec.get("delay", 1.5)))) self.session, self.pause, self.agent = session, pause, spec["agent"] session.trust_env = False def request(self, method, url=None, *, headers=None, params=None, payload=None, body=True): url = url or self.target if origin(url) != self.scope or method not in ("GET", "POST") or method == "POST" and self.module != "graphw00f": raise Stopped("scope_blocked") if self.requests >= self.limit: raise Stopped("request_budget") if self.last is not None: self.pause(max(0, self.delay - (time.monotonic() - self.last))) left = self.seconds - (time.monotonic() - self.started) if left <= 0: raise Stopped("deadline") allowed = {"User-Agent": self.agent} for key, value in (headers or {}).items(): if key not in ("Origin", "Upgrade", "Connection", "Sec-WebSocket-Key", "Sec-WebSocket-Version"): raise Stopped("scope_blocked") allowed[key] = value public_host(self.scope[1], os.environ.get("ALLOW_PRIVATE_TARGETS") == "1") self.requests += 1 self.last = time.monotonic() if getattr(self.session, "cookies", None) is not None: self.session.cookies.clear() try: with self.session.request(method, url, headers=allowed, params=params, json=payload, timeout=min(8, left), allow_redirects=False, stream=True, verify=True) as response: if response.status_code == 429: raise Stopped("rate_limited") if response.status_code in (301, 302, 303, 307, 308): raise Stopped("redirect_not_followed") content = bytearray() if body: for chunk in response.iter_content(8192): if time.monotonic() - self.started >= self.seconds: raise Stopped("deadline") if len(content) + len(chunk) > 65536: content.extend(chunk[:65536 - len(content)]) self.truncated = True break content.extend(chunk) return response.status_code, dict((k.lower(), v) for k, v in response.headers.items()), bytes(content) except Stopped: raise except Exception as exc: raise Stopped("request_failed") from exc def graph_profile(net, report, emit, engine_cls=None): if engine_cls is None: sys.path.insert(0, "/opt/graphw00f") engine_cls = importlib.import_module("graphw00f.lib").GRAPHW00F cache, method = {}, ["POST"] excluded = [0] def query(url, operation="query", payload=""): if (operation != "query" or not isinstance(payload, str) or re.search(r"\b(mutation|subscription|fragment)\b", payload, re.I)): excluded[0] += 1 return {} key = re.sub(r"\s+", "", payload) if key in cache: return cache[key] code, _, data = net.request(method[0], url, payload={"query": payload} if method[0] == "POST" else None, params={"query": payload} if method[0] == "GET" else None) try: result = json.loads(data) except (ValueError, UnicodeError): result = None if method[0] == "POST" and (code in (405, 415) or result is None): method[0] = "GET" code, _, data = net.request("GET", url, params={"query": payload}) try: result = json.loads(data) except (ValueError, UnicodeError): result = None cache[key] = result if isinstance(result, dict) else {} return cache[key] baseline = query(net.target, payload="query { __typename }") data = baseline.get("data") if not isinstance(data, dict) or not isinstance(data.get("__typename"), str): report.update(status="skipped", stop_reason="graphql_unconfirmed") return emit("graphql_present") introspection = query(net.target, payload="query { __schema { queryType { name } } }") if isinstance(introspection.get("data"), dict) and isinstance(introspection["data"].get("__schema"), dict): emit("graphql_introspection") class Engine(engine_cls): def graph_query(self, url, operation="query", payload=""): return query(url, operation, payload) engine = Engine(headers={}, cookies={}, timeout=8, proxies={}, follow_redirects=False) with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): name = engine.execute(net.target) if isinstance(name, str) and re.fullmatch(r"[a-z0-9_-]{1,50}", name): emit("graphql_engine", engine=name) report["excluded_probe_types"] = excluded[0] def browser_origin(value): if value == "null": return True try: p = urlsplit(value) port = p.port return (p.scheme in ("http", "https") and bool(p.hostname) and re.fullmatch(r"[a-zA-Z0-9.-]+", p.hostname) is not None and not p.username and not p.password and not p.path and not p.query and not p.fragment and (port is None or 1 <= port <= 65535)) except (TypeError, ValueError): return False def cors_profile(net, report, emit, native=None): if native is None: sys.path.insert(0, "/opt/corsy") native = importlib.import_module("core.tests") history = [] def requester(url, scheme, header_dict, probe_origin): code, headers, _ = net.request("GET", url, headers={"Origin": probe_origin}, body=False) history.append((probe_origin, code, headers)) return headers # Use the native test order/classifier, with all network/pacing in Transport. previous_requester, previous_time = native.requester, native.time native.requester = requester native.time = SimpleNamespace(sleep=lambda seconds: None) try: with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): result = native.active_tests(net.target, urlsplit(net.target).netloc, net.scope[0], {}, 0) finally: native.requester, native.time = previous_requester, previous_time if result and isinstance(result.get(net.target), dict): kind = result[net.target].get("class") known = BROWSER_CORS | {"wildcard value", "third party allowed", "unrecognized underscore", "broken parser", "unescaped regex", "http origin allowed", "invalid value"} if kind not in known: return candidates = [(code, headers) for probe, code, headers in history if browser_origin(probe) and probe != net.scope[0] + "://" + urlsplit(net.target).netloc and headers.get("access-control-allow-origin") == probe] credentials = any(200 <= code < 300 and headers.get("access-control-allow-credentials", "").strip() == "true" for code, headers in candidates) emit("cors_observation", category=kind, credential_candidate=kind in BROWSER_CORS and credentials) def dns_state(host, resolver=None): import dns.resolver resolver = resolver or dns.resolver.Resolver() resolver.timeout, resolver.lifetime = 2, 3 aliases, current, unknown = [], host, False nxdomain = False for _ in range(4): try: answer = resolver.resolve(current, "CNAME", raise_on_no_answer=False) targets = [str(item.target).lower().rstrip(".") for item in answer if hasattr(item, "target")] except dns.resolver.NXDOMAIN as exc: targets = [] for response in exc.responses().values(): for rrset in response.answer: if rrset.rdtype == 5: targets.extend(str(item.target).lower().rstrip(".") for item in rrset if hasattr(item, "target")) nxdomain = True except dns.resolver.NoAnswer: break except Exception: unknown = True break targets = [name for name in targets if re.fullmatch(r"[a-z0-9_.-]{1,253}", name)] if not targets: break for name in targets: if name != host and name not in aliases: aliases.append(name) current = targets[-1] if nxdomain: break if aliases and not nxdomain and not unknown: try: resolver.resolve(aliases[-1], "A", raise_on_no_answer=False) except dns.resolver.NXDOMAIN: nxdomain = True except dns.resolver.NoAnswer: pass except Exception: unknown = True return {"aliases": aliases[:4], "nxdomain": nxdomain, "unknown": unknown} def takeover_profile(net, report, emit, resolve=dns_state, fingerprints=None): state = resolve(net.scope[1]) report["dns_aliases"] = state["aliases"] if state["unknown"]: raise Stopped("dns_inconclusive") if fingerprints is None: fingerprints = json.loads(Path("/opt/takeover/fingerprints.json").read_text(encoding="utf-8")) rows = [] for item in fingerprints: if not isinstance(item, dict) or item.get("vulnerable") is not True or item.get("cicd_pass") is not True: continue suffixes = [suffix.lower().lstrip("*.") for suffix in item.get("cname", []) if isinstance(suffix, str)] if any(alias == suffix or alias.endswith("." + suffix) for alias in state["aliases"] for suffix in suffixes): rows.append(item) report["provider_candidates"] = len(rows) if not rows: return if state["nxdomain"]: for item in rows: if item.get("nxdomain") is True: emit("takeover_candidate", provider=str(item["service"])[:100], evidence="cname_and_nxdomain") return rows = [item for item in rows if not item.get("nxdomain") and isinstance(item.get("fingerprint"), str) and item["fingerprint"] and item["fingerprint"] != "NXDOMAIN" and not item["fingerprint"].startswith("HTTP_STATUS=") and ".*" not in item["fingerprint"]] if not rows: return code, _, body = net.request("GET") text = html.unescape(body.decode("utf-8", "replace")).casefold() for item in rows: if item.get("http_status") is not None and item["http_status"] != code: continue fingerprint = item.get("fingerprint", "") if (not item.get("nxdomain") and isinstance(fingerprint, str) and fingerprint and fingerprint != "NXDOMAIN" and not fingerprint.startswith("HTTP_STATUS=") and ".*" not in fingerprint and html.unescape(fingerprint).casefold() in text): emit("takeover_candidate", provider=str(item["service"])[:100], evidence="cname_and_literal_fingerprint") def bucket_target(url): p = urlsplit(url) host, path = p.hostname or "", p.path region = r"[a-z]{2}(?:-[a-z]+){1,2}-[0-9]+" service = r"s3(?:[.-]" + region + r")?\.amazonaws\.com" virtual = re.fullmatch(r"([a-z0-9.-]+)\." + service, host) if p.scheme not in ("http", "https") or p.port not in (None, 80, 443): return None if virtual: bucket = virtual.group(1) if path not in ("", "/"): return None root = "/" elif re.fullmatch(service, host): fields = path.strip("/").split("/") if len(fields) != 1 or not fields[0]: return None bucket, root = fields[0], "/" + fields[0] + "/" else: return None if (not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", bucket or "") or ".." in bucket or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+", bucket) or p.query): return None return urlunsplit((p.scheme, p.netloc, root, "", "")), bucket def xml_body(data): if b"= 2 parsed = xml_body(data) return (parsed is not None and local(parsed.tag) == "wc-entries" or text.startswith("10\n") and "\ndir\n" in text) def vcs_profile(net, report, emit): p = urlsplit(net.target) if p.query or p.path and not p.path.endswith("/"): report.update(status="skipped", stop_reason="directory_url_required") return base = net.target.rstrip("/") + "/" from urllib.parse import urljoin _, _, baseline = net.request("GET", urljoin(base, ".asfb-missing-" + os.urandom(8).hex() + ".txt")) for path, kind in ((".git/HEAD", "git_head"), (".git/config", "git_config"), (".hg/requires", "hg_requires"), (".svn/entries", "svn_entries")): code, _, data = net.request("GET", urljoin(base, path)) if code == 200 and data and data != baseline and vcs_match(kind, data) and not vcs_match(kind, baseline): emit("vcs_metadata", artifact=kind) def run(spec, session=None, pause=time.sleep, checkpoint=lambda report: None, **engines): if session is None: import requests session = requests.Session() net = Transport(spec, session, pause) report = {"profile": spec["module"], "status": "running", "observations": [], "requests": 0, "stop_reason": ""} def save(): report["requests"] = net.requests checkpoint(report) def emit(kind, **values): report["observations"].append({"kind": kind, **values}) save() save() try: callbacks = {"graphw00f": graph_profile, "corsy": cors_profile, "takeover": takeover_profile, "s3-review": s3_profile, "websocket": websocket_profile, "vcs-artifacts": vcs_profile} callbacks[spec["module"]](net, report, emit, **engines) if report["status"] == "running": report["status"] = "completed" if net.truncated: report.update(status="partial", stop_reason="response_limit") except Stopped as exc: report.update(status="partial", stop_reason=str(exc)) except DNSResolutionFailed: report.update(status="partial", stop_reason="dns_inconclusive") except Exception: report.update(status="failed", stop_reason="engine_error") finally: session.close() save() return report if __name__ == "__main__": with open(sys.argv[1], encoding="utf-8") as source: spec = json.load(source) output = Path(sys.argv[2]) def checkpoint(report): temporary = output.with_suffix(".tmp") temporary.write_text(json.dumps(report), encoding="utf-8") temporary.replace(output) run(spec, checkpoint=checkpoint)