Files
2026-02-19 15:39:29 +00:00

1722 lines
71 KiB
Python

#!/usr/bin/env python3
"""
MAPS API Fuzzer — Feature Discovery & Attack Surface Enumeration
Probes Microsoft's MAPS (Microsoft Active Protection Service) web API to
discover undocumented endpoints, hidden Bond fields, alternative report types,
and differential server behavior.
Uses the existing maps_scanner client/bond libraries without modification.
For authorized security research use only.
Usage:
python fuzz_maps.py [options] <module>
Modules:
paths Fuzz URL paths for undiscovered endpoints
report-types Try report type values beyond known 1-7
headers Enumerate custom HTTP headers and values
fields Probe unknown Bond field ordinals in SpynetReport
response-fields Probe unknown response field ordinals
schemas Try alternative Bond schema names
endpoints Discover endpoint hostname variations
versions Fuzz version strings for behavior changes
customer-types Enumerate customer type header values
block-levels Sweep all cloud block level values
heartbeat-types Enumerate heartbeat subtypes beyond known 0-11
bond-types Send fields with unexpected Bond wire types
all Run all modules sequentially
"""
import argparse
import hashlib
import json
import os
import random
import string
import struct
import sys
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Add parent to path so we can import maps_scanner
_HERE = Path(__file__).resolve().parent
if str(_HERE.parent) not in sys.path:
sys.path.insert(0, str(_HERE.parent))
from maps_scanner.bond import (
BOND_CB1_MARSHAL_HEADER,
BondType,
CompactBinaryV1Writer,
bond_deserialize,
bond_hexdump,
bond_marshal,
bond_marshal_with_schema,
bond_pretty_print,
bond_wrap_with_schema,
)
from maps_scanner.client import (
MAPS_BOND_PATH,
MAPS_ENDPOINT_ALT,
MAPS_ENDPOINT_PPE,
MAPS_ENDPOINT_PROD,
MAPS_ENTRA_PATH,
MAPS_FASTPATH_PPE,
MAPS_FASTPATH_PROD,
MAPS_GEO_ENDPOINTS,
MAPS_REST_PATH,
SPYNET_REPORT_SCHEMA,
CRF,
FRF,
MAPSConfig,
MAPSTransport,
ReportType,
SF,
SpynetReportBuilder,
bond_deserialize as client_bond_deserialize,
parse_response,
)
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
@dataclass
class FuzzResult:
"""Single fuzz probe result."""
module: str
probe_id: str
description: str
http_status: int = 0
response_size: int = 0
latency_ms: float = 0.0
response_headers: Dict[str, str] = field(default_factory=dict)
response_body_hex: str = ""
decoded_fields: Optional[Dict] = None
error: str = ""
interesting: bool = False
notes: str = ""
timestamp: str = ""
def to_dict(self) -> dict:
d = {
"module": self.module,
"probe_id": self.probe_id,
"description": self.description,
"http_status": self.http_status,
"response_size": self.response_size,
"latency_ms": round(self.latency_ms, 1),
"interesting": self.interesting,
"timestamp": self.timestamp,
}
if self.error:
d["error"] = self.error
if self.notes:
d["notes"] = self.notes
if self.response_headers:
d["response_headers"] = self.response_headers
if self.decoded_fields:
d["decoded_fields"] = str(self.decoded_fields)[:2000]
if self.response_body_hex and self.interesting:
d["response_body_hex"] = self.response_body_hex[:500]
return d
class FuzzLogger:
"""Structured logging for fuzz results."""
def __init__(self, output_dir: str = "fuzz_results", verbose: bool = False):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.verbose = verbose
self.results: List[FuzzResult] = []
self.session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
self._baseline_size: Optional[int] = None
self._baseline_status: Optional[int] = None
def set_baseline(self, status: int, size: int):
self._baseline_size = size
self._baseline_status = status
def is_interesting(self, result: FuzzResult) -> bool:
"""Determine if a result differs from baseline."""
if result.error:
return False
# Any non-standard status is interesting
if result.http_status not in (200, 400, 404, 500):
return True
# Response size differs from baseline
if self._baseline_size is not None and result.response_size != self._baseline_size:
if result.http_status == 200:
return True
# Got 200 on a path/feature that might not exist
if result.http_status == 200 and result.response_size > 0:
return True
return False
def log(self, result: FuzzResult):
result.timestamp = datetime.now().isoformat()
if not result.interesting:
result.interesting = self.is_interesting(result)
self.results.append(result)
marker = " *** INTERESTING ***" if result.interesting else ""
status_str = f"HTTP {result.http_status}" if result.http_status else "ERROR"
size_str = f"{result.response_size}B" if result.response_size else "0B"
if result.error:
print(f" [{result.probe_id}] {result.description}: ERROR - {result.error}")
elif self.verbose or result.interesting:
print(f" [{result.probe_id}] {result.description}: "
f"{status_str} {size_str} ({result.latency_ms:.0f}ms){marker}")
if result.notes:
print(f" -> {result.notes}")
else:
print(f" [{result.probe_id}] {status_str} {size_str} ({result.latency_ms:.0f}ms)")
def save(self, module_name: str):
"""Save results for a module to JSON."""
outfile = self.output_dir / f"{self.session_id}_{module_name}.json"
data = {
"session_id": self.session_id,
"module": module_name,
"total_probes": len(self.results),
"interesting_count": sum(1 for r in self.results if r.interesting),
"results": [r.to_dict() for r in self.results],
}
outfile.write_text(json.dumps(data, indent=2, default=str))
print(f"\n Saved {len(self.results)} results to {outfile}")
def save_summary(self):
"""Save combined summary of all modules."""
interesting = [r for r in self.results if r.interesting]
summary_file = self.output_dir / f"{self.session_id}_SUMMARY.json"
data = {
"session_id": self.session_id,
"total_probes": len(self.results),
"interesting_count": len(interesting),
"interesting_results": [r.to_dict() for r in interesting],
"status_distribution": {},
}
for r in self.results:
key = str(r.http_status) if r.http_status else "error"
data["status_distribution"][key] = data["status_distribution"].get(key, 0) + 1
summary_file.write_text(json.dumps(data, indent=2, default=str))
print(f"\nSummary: {len(interesting)}/{len(self.results)} interesting results")
print(f"Saved to {summary_file}")
# ---------------------------------------------------------------------------
# Transport helpers
# ---------------------------------------------------------------------------
class FuzzTransport:
"""Thin HTTP transport for fuzzing — more flexible than MAPSTransport."""
def __init__(self, config: MAPSConfig):
self.config = config
self.session = requests.Session()
self.session.verify = config.verify_ssl
if config.proxy:
self.session.proxies = {"http": config.proxy, "https": config.proxy}
def send(
self,
payload: bytes,
path: str = MAPS_BOND_PATH,
endpoint: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
method: str = "POST",
) -> Tuple[int, bytes, Dict[str, str], float]:
"""Send raw payload, return (status, body, response_headers, latency_ms)."""
base = endpoint or self.config.endpoint
url = base.rstrip("/") + path
hdrs = {
"Content-Type": "application/bond",
"Accept": "application/bond",
"Accept-Charset": "utf-8",
"User-Agent": self.config.user_agent,
"Connection": "Keep-Alive",
"X-MS-MAPS-CUSTOMERTYPE": self.config.customer_type,
"X-MS-MAPS-OSVERSION": self.config.os_version_hex,
"X-MS-MAPS-PLATFORMVERSION": self.config.platform_version_hex,
"X-MS-MAPS-ENGINEVERSION": self.config.engine_version_hex,
}
if self.config.bearer_token:
hdrs["Authorization"] = f"Bearer {self.config.bearer_token}"
if headers:
hdrs.update(headers)
start = time.monotonic()
try:
if method.upper() == "GET":
resp = self.session.get(url, headers=hdrs, timeout=self.config.timeout)
elif method.upper() == "PUT":
resp = self.session.put(url, data=payload, headers=hdrs, timeout=self.config.timeout)
elif method.upper() == "OPTIONS":
resp = self.session.options(url, headers=hdrs, timeout=self.config.timeout)
elif method.upper() == "HEAD":
resp = self.session.head(url, headers=hdrs, timeout=self.config.timeout)
else:
resp = self.session.post(url, data=payload, headers=hdrs, timeout=self.config.timeout)
latency = (time.monotonic() - start) * 1000
resp_headers = dict(resp.headers)
return resp.status_code, resp.content, resp_headers, latency
except requests.exceptions.ConnectionError as e:
latency = (time.monotonic() - start) * 1000
raise ConnectionError(f"Connection failed ({latency:.0f}ms): {e}") from e
except requests.exceptions.Timeout as e:
latency = (time.monotonic() - start) * 1000
raise ConnectionError(f"Timeout ({latency:.0f}ms): {e}") from e
except requests.exceptions.RequestException as e:
latency = (time.monotonic() - start) * 1000
raise ConnectionError(f"Request error ({latency:.0f}ms): {e}") from e
def build_minimal_heartbeat(config: MAPSConfig) -> bytes:
"""Build a minimal valid heartbeat payload for baseline measurements."""
builder = SpynetReportBuilder(config)
return builder.build_heartbeat()
def build_minimal_scan(config: MAPSConfig) -> bytes:
"""Build a minimal file scan payload (random file hash)."""
builder = SpynetReportBuilder(config)
rand_hash = hashlib.sha256(os.urandom(32)).hexdigest()
return builder.build_hash_query(sha256=rand_hash)
def try_decode_response(body: bytes) -> Optional[Dict]:
"""Best-effort decode of a Bond response body."""
if not body or len(body) < 4:
return None
try:
# Skip CB marshal header if present
data = body
if len(data) >= 4:
magic = struct.unpack_from('<H', data, 0)[0]
if magic == 0x4243:
data = data[4:]
return bond_deserialize(data)
except Exception:
return None
# ---------------------------------------------------------------------------
# Fuzz modules
# ---------------------------------------------------------------------------
def fuzz_paths(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Probe URL paths for undiscovered WCF/REST endpoints."""
print("\n[PATH FUZZING] Probing URL paths...")
payload = build_minimal_heartbeat(config)
# Known paths plus speculative variations
paths = [
# Known working
"/wdcp.svc/bond/submitreport",
# Known legacy/documented
"/wdcp.svc/submitReport",
"/wdcp.svc/entraReport",
# WCF WSDL/metadata probing
"/wdcp.svc",
"/wdcp.svc?wsdl",
"/wdcp.svc?singleWsdl",
"/wdcp.svc/mex",
"/wdcp.svc/metadata",
# Bond endpoint variations
"/wdcp.svc/bond",
"/wdcp.svc/bond/submitReport", # case variation
"/wdcp.svc/bond/SubmitReport",
"/wdcp.svc/bond/submit",
"/wdcp.svc/bond/query",
"/wdcp.svc/bond/getreport",
"/wdcp.svc/bond/heartbeat",
"/wdcp.svc/bond/signature",
"/wdcp.svc/bond/fastpath",
"/wdcp.svc/bond/sample",
"/wdcp.svc/bond/upload",
"/wdcp.svc/bond/config",
"/wdcp.svc/bond/status",
"/wdcp.svc/bond/version",
"/wdcp.svc/bond/enroll",
"/wdcp.svc/bond/register",
"/wdcp.svc/bond/onboard",
"/wdcp.svc/bond/telemetry",
"/wdcp.svc/bond/behavior",
"/wdcp.svc/bond/remediation",
"/wdcp.svc/bond/certificate",
"/wdcp.svc/bond/policy",
# REST variations
"/wdcp.svc/rest/submitreport",
"/wdcp.svc/json/submitreport",
"/wdcp.svc/xml/submitreport",
# Service alternative names
"/WdCpSrvc.asmx",
"/WdCpSrvc.svc",
"/wdcp.asmx",
"/maps.svc",
"/maps.svc/bond/submitreport",
"/defender.svc",
"/defender.svc/bond/submitreport",
"/spynet.svc",
"/spynet.svc/bond/submitreport",
# API version prefixes
"/v1/wdcp.svc/bond/submitreport",
"/v2/wdcp.svc/bond/submitreport",
"/api/wdcp.svc/bond/submitreport",
# Entra/Enterprise variations
"/wdcp.svc/entra",
"/wdcp.svc/entraReport/bond",
"/wdcp.svc/bond/entraReport",
"/wdcp.svc/enterprise",
"/wdcp.svc/bond/enterprise",
# Fastpath-specific
"/wdcp.svc/fastpath",
"/wdcp.svc/bond/fastpath/signature",
"/wdcp.svc/dss",
"/wdcp.svc/sdn",
# Health/diagnostics
"/health",
"/healthcheck",
"/status",
"/ready",
"/ping",
"/robots.txt",
"/.well-known/openid-configuration",
# Root
"/",
]
for i, path in enumerate(paths):
probe_id = f"PATH-{i:03d}"
try:
status, body, resp_hdrs, latency = transport.send(payload, path=path)
result = FuzzResult(
module="paths",
probe_id=probe_id,
description=f"POST {path}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
response_body_hex=body.hex()[:500] if body else "",
decoded_fields=try_decode_response(body),
)
# Anything returning 200 or non-standard codes on speculative paths
if status == 200 and path != "/wdcp.svc/bond/submitreport":
result.interesting = True
result.notes = f"Got 200 on non-standard path!"
elif status not in (400, 404, 405, 500, 503):
result.interesting = True
result.notes = f"Unexpected status {status}"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="paths", probe_id=probe_id,
description=f"POST {path}", error=str(e),
))
time.sleep(delay)
# Also try GET/OPTIONS/HEAD on the known bond path
for method in ["GET", "OPTIONS", "HEAD", "PUT"]:
probe_id = f"PATH-M-{method}"
try:
status, body, resp_hdrs, latency = transport.send(
payload, path=MAPS_BOND_PATH, method=method
)
result = FuzzResult(
module="paths",
probe_id=probe_id,
description=f"{method} {MAPS_BOND_PATH}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
response_body_hex=body.hex()[:500] if body else "",
)
if status == 200:
result.interesting = True
result.notes = f"{method} accepted!"
elif method == "OPTIONS" and "Allow" in resp_hdrs:
result.interesting = True
result.notes = f"Allow: {resp_hdrs.get('Allow', '')}"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="paths", probe_id=probe_id,
description=f"{method} {MAPS_BOND_PATH}", error=str(e),
))
time.sleep(delay)
def fuzz_report_types(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Enumerate report type values beyond known 1-7."""
print("\n[REPORT TYPE FUZZING] Trying report types 0-20 + edge cases...")
builder = SpynetReportBuilder(config)
rand_hash = hashlib.sha256(os.urandom(32)).hexdigest()
# Known: 1=ASYNC_LOWFI, 2=SYNC_LOWFI, 3=TELEMETRY, 4=HB, 5=URL, 6=SAMPLE, 7=WDO
# Probe 0-20 and some large values
test_values = list(range(0, 21)) + [32, 64, 100, 128, 255, 256, 1000, 65535]
for report_type in test_values:
probe_id = f"RT-{report_type}"
try:
# Build payload with custom report type
w = CompactBinaryV1Writer()
report_guid = str(uuid.uuid4())
builder._write_top_level(w, report_guid)
# FileReportElements with the custom report type
w.write_field_begin(BondType.BT_LIST, SF.FILE_REPORT_ELEMENTS)
w._write_byte(BondType.BT_LIST)
w._write_varint(1)
w._write_byte(BondType.BT_STRUCT)
w._write_varint(1)
w._field_stack.append(0)
w.write_list_begin(FRF.REVISION, BondType.BT_INT16, 1)
w._write_varint(2)
w.write_list_begin(FRF.INDEX, BondType.BT_INT16, 1)
w._write_varint(2)
w.write_list_begin(FRF.CORE_REPORT, BondType.BT_STRUCT, 1)
w._field_stack.append(0)
w.write_list_begin(CRF.REVISION, BondType.BT_INT16, 1)
w._write_varint(2)
w.write_string(CRF.FILE_NAME, "fuzz_test.exe")
w.write_string(CRF.SHA256, rand_hash)
# ReportType as string (field 330)
w.write_string(CRF.REPORT_TYPE - 10, str(report_type))
w._write_byte(BondType.BT_STOP) # end CoreReport
w._field_stack.pop()
w._write_byte(BondType.BT_STOP) # end FileReport
w._field_stack.pop()
w.write_string(SF.ENGINE_REPORT_GUID, str(uuid.uuid4()))
w._write_byte(BondType.BT_STOP)
payload = bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
status, body, resp_hdrs, latency = transport.send(payload)
known_type = report_type in range(1, 8)
result = FuzzResult(
module="report_types",
probe_id=probe_id,
description=f"ReportType={report_type}{'(known)' if known_type else ''}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
response_body_hex=body.hex()[:500] if body else "",
decoded_fields=try_decode_response(body),
)
# Interesting if an unknown type gets a 200 with substantial response
if not known_type and status == 200 and len(body) > 0:
result.interesting = True
result.notes = f"Unknown report type {report_type} got valid response!"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="report_types", probe_id=probe_id,
description=f"ReportType={report_type}", error=str(e),
))
time.sleep(delay)
def fuzz_headers(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Enumerate HTTP request headers for hidden features."""
print("\n[HEADER FUZZING] Probing custom HTTP headers...")
payload = build_minimal_heartbeat(config)
# Headers to probe — based on Microsoft patterns and WCF conventions
header_tests = [
# Customer type variations
("X-MS-MAPS-CUSTOMERTYPE", "Enterprise"),
("X-MS-MAPS-CUSTOMERTYPE", "Government"),
("X-MS-MAPS-CUSTOMERTYPE", "Education"),
("X-MS-MAPS-CUSTOMERTYPE", "Partner"),
("X-MS-MAPS-CUSTOMERTYPE", "Internal"),
("X-MS-MAPS-CUSTOMERTYPE", "Preview"),
("X-MS-MAPS-CUSTOMERTYPE", "Insider"),
("X-MS-MAPS-CUSTOMERTYPE", "MSFT"),
# Potential hidden MAPS headers
("X-MS-MAPS-PARTNERID", str(uuid.uuid4())),
("X-MS-MAPS-TENANTID", str(uuid.uuid4())),
("X-MS-MAPS-DEVICEID", str(uuid.uuid4())),
("X-MS-MAPS-MACHINEID", str(uuid.uuid4())),
("X-MS-MAPS-CLIENTID", str(uuid.uuid4())),
("X-MS-MAPS-SESSIONID", str(uuid.uuid4())),
("X-MS-MAPS-REQUESTID", str(uuid.uuid4())),
("X-MS-MAPS-CORRELATIONID", str(uuid.uuid4())),
("X-MS-MAPS-DIAGNOSTIC", "true"),
("X-MS-MAPS-DEBUG", "true"),
("X-MS-MAPS-VERBOSE", "true"),
("X-MS-MAPS-TRACE", "true"),
("X-MS-MAPS-BETA", "true"),
("X-MS-MAPS-PREVIEW", "true"),
("X-MS-MAPS-INSIDER", "true"),
("X-MS-MAPS-FLIGHTING", "AllFeatures"),
("X-MS-MAPS-RING", "insider"),
("X-MS-MAPS-RING", "canary"),
("X-MS-MAPS-SIGVERSION", config.av_sig_version),
("X-MS-MAPS-ASIMOV", "true"),
("X-MS-MAPS-REGION", "US"),
("X-MS-MAPS-REGION", "EU"),
("X-MS-MAPS-DATACENTER", "westus2"),
# Content-Type variations
("Content-Type", "application/json"),
("Content-Type", "application/xml"),
("Content-Type", "application/octet-stream"),
("Content-Type", "application/bond-compact-binary"),
("Content-Type", "application/bond; protocol=compact_v1"),
# Accept variations
("Accept", "application/json"),
("Accept", "application/xml"),
("Accept", "*/*"),
# Microsoft identity headers
("X-MS-DeviceId", str(uuid.uuid4())),
("X-MS-TokenId", str(uuid.uuid4())),
("X-AnchorMailbox", "test@test.com"),
("X-MS-Client-Request-Id", str(uuid.uuid4())),
("client-request-id", str(uuid.uuid4())),
# Compression
("Accept-Encoding", "gzip, deflate"),
("Accept-Encoding", "br"),
("Content-Encoding", "gzip"),
# WCF specific
("SOAPAction", "http://tempuri.org/IWdCpSrvc/SubmitReport"),
("SOAPAction", "SubmitReport"),
]
# First, get a baseline
try:
status, body, resp_hdrs, latency = transport.send(payload)
baseline_size = len(body)
baseline_status = status
logger.set_baseline(baseline_status, baseline_size)
print(f" Baseline: HTTP {baseline_status}, {baseline_size}B")
except ConnectionError as e:
print(f" Baseline failed: {e}")
return
for i, (header, value) in enumerate(header_tests):
probe_id = f"HDR-{i:03d}"
try:
status, body, resp_hdrs, latency = transport.send(
payload, headers={header: value}
)
result = FuzzResult(
module="headers",
probe_id=probe_id,
description=f"{header}: {value[:50]}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
response_body_hex=body.hex()[:500] if body else "",
decoded_fields=try_decode_response(body),
)
# Interesting if response differs from baseline
if status == 200 and len(body) != baseline_size:
result.interesting = True
result.notes = f"Response size {len(body)}B differs from baseline {baseline_size}B"
elif status != baseline_status:
result.interesting = True
result.notes = f"Status {status} differs from baseline {baseline_status}"
# Check for new response headers
for rh in resp_hdrs:
rh_lower = rh.lower()
if "maps" in rh_lower or "defender" in rh_lower or "debug" in rh_lower:
result.interesting = True
result.notes = f"Interesting response header: {rh}: {resp_hdrs[rh]}"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="headers", probe_id=probe_id,
description=f"{header}: {value[:50]}", error=str(e),
))
time.sleep(delay)
def fuzz_spynet_fields(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Probe unknown field ordinals in SpynetReport for server reactions."""
print("\n[FIELD FUZZING] Probing unknown SpynetReport field ordinals...")
builder = SpynetReportBuilder(config)
# Known field ordinals from SF class — we want to test gaps and beyond
known_ordinals = {
10, 20, 30, 31, 32, 40, 43, 50, 60, 70, 80, 90, 100, 110, 120,
130, 150, 160, 170, 180, 190, 200, 210, 220, 280, 290, 300, 310,
320, 330, 340, 350, 360, 370, 380, 390, 400, 410, 470, 480, 490,
530, 560, 580, 590, 600, 690, 700, 730, 830, 840, 850, 860, 870,
880, 900, 910, 920, 930, 940, 960, 970, 980, 990, 1000, 1010,
1020, 1030, 1040, 1050, 1060, 1070, 1080, 1090, 1100, 1110,
1120, 1130, 1133, 1134, 1140, 1150, 1160, 1170, 1180, 1183,
1189, 1190, 1191, 1192, 1195, 1198, 1201, 1204, 1207, 1210,
1275, 1281, 1284, 1340, 1373, 1400, 1424, 1542,
}
# Probe fields in the gaps and beyond known max
test_ordinals = []
# Fill gaps between known ordinals
for i in range(0, 1600, 10):
if i not in known_ordinals:
test_ordinals.append(i)
# Beyond max known
for i in range(1550, 2000, 10):
test_ordinals.append(i)
# Some specific interesting candidates
test_ordinals.extend([1543, 1544, 1545, 1550, 1555, 1560, 1600, 1700, 1800, 1900, 2000])
test_ordinals = sorted(set(test_ordinals))
# Get baseline
baseline_payload = build_minimal_heartbeat(config)
try:
bstatus, bbody, _, _ = transport.send(baseline_payload)
baseline_size = len(bbody)
logger.set_baseline(bstatus, baseline_size)
print(f" Baseline: HTTP {bstatus}, {baseline_size}B")
except ConnectionError as e:
print(f" Baseline failed: {e}")
return
for ordinal in test_ordinals:
probe_id = f"FLD-{ordinal}"
try:
# Build heartbeat with an extra unknown field injected
w = CompactBinaryV1Writer()
report_guid = str(uuid.uuid4())
builder._write_top_level(w, report_guid)
# Inject the unknown field as a STRING with a marker value
w.write_string(ordinal, f"fuzz_probe_{ordinal}")
w._write_byte(BondType.BT_STOP)
payload = bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
status, body, resp_hdrs, latency = transport.send(payload)
result = FuzzResult(
module="fields",
probe_id=probe_id,
description=f"SpynetReport.F{ordinal} = STRING",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
decoded_fields=try_decode_response(body),
)
if status == 200 and len(body) != baseline_size:
result.interesting = True
result.notes = f"Response {len(body)}B differs from baseline {baseline_size}B — field F{ordinal} may be processed"
elif status != bstatus:
result.interesting = True
result.notes = f"Status changed to {status} — field F{ordinal} caused different behavior"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="fields", probe_id=probe_id,
description=f"SpynetReport.F{ordinal}", error=str(e),
))
time.sleep(delay)
def fuzz_response_fields(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Analyze responses for undocumented fields by varying input."""
print("\n[RESPONSE FIELD ANALYSIS] Probing for hidden response fields...")
builder = SpynetReportBuilder(config)
# Send various well-formed requests and analyze response field ordinals
probes = [
("clean-file", lambda: builder.build_hash_query(sha256=hashlib.sha256(os.urandom(32)).hexdigest())),
("eicar-hash", lambda: builder.build_hash_query(
sha256="275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f",
sha1="3395856ce81f2b7382dee72602f798b642f14140",
md5="44d88612fea8a8f36de82e1278abb02f",
)),
("heartbeat-0", lambda: builder.build_heartbeat(hb_type=0)),
("heartbeat-1", lambda: builder.build_heartbeat(hb_type=1)),
("url-google", lambda: builder.build_url_reputation_query("https://www.google.com")),
("url-malware", lambda: builder.build_url_reputation_query("http://malware.testing.google.test/testing/malware/")),
]
all_seen_fields = {}
for name, build_fn in probes:
probe_id = f"RESP-{name}"
try:
payload = build_fn()
status, body, resp_hdrs, latency = transport.send(payload)
decoded = try_decode_response(body)
result = FuzzResult(
module="response_fields",
probe_id=probe_id,
description=f"Probe '{name}' response analysis",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
decoded_fields=decoded,
response_body_hex=body.hex() if body else "",
)
# Track which field ordinals appear in responses
if decoded:
field_ids = set()
_collect_field_ids(decoded, field_ids)
for fid in field_ids:
if fid not in all_seen_fields:
all_seen_fields[fid] = []
all_seen_fields[fid].append(name)
result.notes = f"Response fields: {sorted(field_ids)}"
result.interesting = True # Always log response analysis
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="response_fields", probe_id=probe_id,
description=f"Probe '{name}'", error=str(e),
))
time.sleep(delay)
# Summary of all seen response fields
if all_seen_fields:
print(f"\n Response field summary ({len(all_seen_fields)} unique ordinals):")
for fid in sorted(all_seen_fields.keys()):
probes_seen = all_seen_fields[fid]
print(f" F{fid}: seen in {len(probes_seen)} probes — {probes_seen}")
def _collect_field_ids(fields: dict, result: set, prefix: str = ""):
"""Recursively collect all field IDs from decoded Bond struct."""
if not isinstance(fields, dict):
return
for fid, val in fields.items():
if isinstance(fid, int):
result.add(fid)
if isinstance(val, tuple) and len(val) == 2:
_, inner = val
if isinstance(inner, dict):
_collect_field_ids(inner, result)
elif isinstance(inner, list):
for item in inner:
if isinstance(item, dict):
_collect_field_ids(item, result)
def fuzz_schemas(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Try alternative Bond schema names in the Bonded<T> envelope."""
print("\n[SCHEMA FUZZING] Probing Bond schema name variations...")
# Build a heartbeat payload body (without schema wrapping)
w = CompactBinaryV1Writer()
builder = SpynetReportBuilder(config)
builder._write_top_level(w, str(uuid.uuid4()))
w._write_byte(BondType.BT_STOP)
inner_data = w.get_data()
schemas = [
# Known working
SPYNET_REPORT_SCHEMA,
# Variations
"Microsoft.ProtectionServices.Entities.Raw.SpynetReportEntity",
"Microsoft.ProtectionServices.Entities.SpynetReportEntity",
"Microsoft.ProtectionServices.SpynetReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.SpynetReport",
"Microsoft.ProtectionServices.Entities.Raw.HeartbeatReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.FileReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.UrlReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.SampleReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.BehaviorReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.TelemetryReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.RemediationReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.ConfigReportEntity",
"Microsoft.ProtectionServices.Entities.Raw.EnrollmentEntity",
"Microsoft.ProtectionServices.Entities.Raw.OnboardingEntity",
"Microsoft.ProtectionServices.Entities.Raw.PolicyEntity",
"Microsoft.ProtectionServices.Entities.Raw.SubmitSpynetReportResult",
# Windows Defender namespace guesses
"Microsoft.Windows.Defender.SpynetReport",
"Microsoft.Windows.Defender.Entities.SpynetReport",
"Microsoft.Antimalware.Entities.SpynetReport",
# Empty/minimal
"",
"SpynetReportEntity",
"SpynetReport",
]
for i, schema in enumerate(schemas):
probe_id = f"SCH-{i:03d}"
try:
payload = bond_wrap_with_schema(schema, inner_data)
status, body, resp_hdrs, latency = transport.send(payload)
result = FuzzResult(
module="schemas",
probe_id=probe_id,
description=f"Schema: {schema[:70]}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
decoded_fields=try_decode_response(body),
)
if status == 200 and schema != SPYNET_REPORT_SCHEMA:
result.interesting = True
result.notes = f"Alternative schema accepted!"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="schemas", probe_id=probe_id,
description=f"Schema: {schema[:70]}", error=str(e),
))
time.sleep(delay)
def fuzz_endpoints(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Discover endpoint hostname variations and geo endpoints."""
print("\n[ENDPOINT FUZZING] Probing endpoint hostname variations...")
payload = build_minimal_heartbeat(config)
endpoints = [
# Known
MAPS_ENDPOINT_PROD,
MAPS_ENDPOINT_ALT,
MAPS_ENDPOINT_PPE,
MAPS_FASTPATH_PROD,
MAPS_FASTPATH_PPE,
# Geo endpoints
*MAPS_GEO_ENDPOINTS.values(),
# Speculative variations
"https://wdcp2.microsoft.com",
"https://wdcp-v2.microsoft.com",
"https://maps.microsoft.com",
"https://defender.microsoft.com",
"https://protection.microsoft.com",
"https://spynet.microsoft.com",
"https://cp.wd.microsoft.com",
"https://wdcp.wd.microsoft.com",
# Regional variations
"https://eastus.cp.wd.microsoft.com",
"https://westus.cp.wd.microsoft.com",
"https://centralus.cp.wd.microsoft.com",
"https://northeurope.cp.wd.microsoft.com",
"https://westeurope.cp.wd.microsoft.com",
"https://japan.cp.wd.microsoft.com",
"https://india.cp.wd.microsoft.com",
"https://brazil.cp.wd.microsoft.com",
"https://canada.cp.wd.microsoft.com",
"https://korea.cp.wd.microsoft.com",
# Defender for Endpoint
"https://winatp-gw-cus.microsoft.com",
"https://winatp-gw-eus.microsoft.com",
]
for i, endpoint in enumerate(endpoints):
probe_id = f"EP-{i:03d}"
try:
status, body, resp_hdrs, latency = transport.send(
payload, endpoint=endpoint
)
result = FuzzResult(
module="endpoints",
probe_id=probe_id,
description=f"Endpoint: {endpoint}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
)
if status == 200:
result.interesting = True
result.notes = f"Endpoint reachable and accepted payload!"
elif status in (301, 302, 307, 308):
loc = resp_hdrs.get("Location", "")
result.interesting = True
result.notes = f"Redirect to: {loc}"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="endpoints", probe_id=probe_id,
description=f"Endpoint: {endpoint}", error=str(e)[:200],
))
time.sleep(delay)
def fuzz_versions(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Fuzz version strings to trigger different server behavior."""
print("\n[VERSION FUZZING] Probing version string variations...")
# Get baseline first
payload = build_minimal_heartbeat(config)
try:
bstatus, bbody, _, _ = transport.send(payload)
baseline_size = len(bbody)
logger.set_baseline(bstatus, baseline_size)
print(f" Baseline: HTTP {bstatus}, {baseline_size}B")
except ConnectionError as e:
print(f" Baseline failed: {e}")
return
version_tests = [
# Very old platform versions
("platform", "4.1.0.0", "old_platform"),
("platform", "4.10.0.0", "legacy_platform"),
("platform", "4.18.0.0", "early_18_platform"),
# Future/preview versions
("platform", "4.19.0.0", "future_platform_19"),
("platform", "5.0.0.0", "future_platform_5"),
("platform", "99.0.0.0", "absurd_platform"),
# Engine variations
("engine", "1.0.0.0", "old_engine"),
("engine", "1.1.0.0", "early_engine"),
("engine", "1.1.99999.1", "future_engine"),
("engine", "2.0.0.0", "next_gen_engine"),
# OS variations
("os", "6.1.7601", "win7"),
("os", "6.3.9600", "win81"),
("os", "10.0.14393", "win10_1607"),
("os", "10.0.17763", "win10_1809"),
("os", "10.0.19041", "win10_2004"),
("os", "10.0.22000", "win11_21h2"),
("os", "10.0.22621", "win11_22h2"),
("os", "10.0.22631", "win11_23h2"),
("os", "10.0.26100", "win11_24h2"),
("os", "10.0.99999", "future_os"),
("os", "11.0.0", "win12"),
# Sig version variations
("sig", "1.0.0.0", "ancient_sigs"),
("sig", "1.445.0.0", "current_sigs"),
("sig", "1.999.0.0", "future_sigs"),
]
for version_type, version_val, tag in version_tests:
probe_id = f"VER-{tag}"
try:
# Clone config with modified version
test_config = MAPSConfig(
endpoint=config.endpoint,
machine_guid=config.machine_guid,
proxy=config.proxy,
verify_ssl=config.verify_ssl,
rotate_guid=True,
)
if version_type == "platform":
test_config.app_version = version_val
elif version_type == "engine":
test_config.engine_version = version_val
elif version_type == "os":
test_config.os_ver = version_val
parts = version_val.split(".")
test_config.os_build = int(parts[-1]) if len(parts) >= 3 else 0
elif version_type == "sig":
test_config.av_sig_version = version_val
test_builder = SpynetReportBuilder(test_config)
test_payload = test_builder.build_heartbeat()
# Use custom headers matching the version
custom_headers = {}
if version_type == "platform":
from maps_scanner.client import encode_maps_version
custom_headers["X-MS-MAPS-PLATFORMVERSION"] = encode_maps_version(version_val)
elif version_type == "engine":
from maps_scanner.client import encode_maps_version
custom_headers["X-MS-MAPS-ENGINEVERSION"] = encode_maps_version(version_val)
elif version_type == "os":
from maps_scanner.client import encode_maps_version
custom_headers["X-MS-MAPS-OSVERSION"] = encode_maps_version(version_val)
status, body, resp_hdrs, latency = transport.send(
test_payload, headers=custom_headers
)
result = FuzzResult(
module="versions",
probe_id=probe_id,
description=f"{version_type}={version_val}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
decoded_fields=try_decode_response(body),
)
if status == 200 and len(body) != baseline_size:
result.interesting = True
result.notes = f"Response {len(body)}B differs from baseline {baseline_size}B"
elif status != bstatus:
result.interesting = True
result.notes = f"Status {status} differs from baseline {bstatus}"
logger.log(result)
except Exception as e:
logger.log(FuzzResult(
module="versions", probe_id=probe_id,
description=f"{version_type}={version_val}", error=str(e)[:200],
))
time.sleep(delay)
def fuzz_customer_types(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Enumerate customer type header values."""
print("\n[CUSTOMER TYPE FUZZING] Probing X-MS-MAPS-CUSTOMERTYPE values...")
payload = build_minimal_heartbeat(config)
types = [
"Consumer", "Enterprise", "Government", "Education",
"Partner", "OEM", "Trial", "Developer", "Internal",
"Preview", "Insider", "MSFT", "Microsoft", "Server",
"IoT", "HoloLens", "Xbox", "Surface", "Azure",
"Intune", "SCCM", "MDATP", "MDE", "M365",
"", "0", "1", "2", "3", "test", "debug",
]
for i, ctype in enumerate(types):
probe_id = f"CT-{i:03d}"
try:
status, body, resp_hdrs, latency = transport.send(
payload, headers={"X-MS-MAPS-CUSTOMERTYPE": ctype}
)
result = FuzzResult(
module="customer_types",
probe_id=probe_id,
description=f"CustomerType: {ctype!r}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
)
if status == 200 and ctype not in ("Consumer", "Enterprise"):
result.interesting = True
result.notes = f"Non-standard customer type accepted!"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="customer_types", probe_id=probe_id,
description=f"CustomerType: {ctype!r}", error=str(e)[:200],
))
time.sleep(delay)
def fuzz_block_levels(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Sweep all cloud block level values in payload."""
print("\n[BLOCK LEVEL FUZZING] Sweeping CloudBlockLevel values...")
# Known: 0=OFF, 1=MOD, 2=HIGH, 4=HIGH+, 6=ZERO_TOL
# Test all values 0-10 plus some edge cases
levels = list(range(0, 11)) + [16, 32, 64, 128, 255]
for level in levels:
probe_id = f"BL-{level}"
try:
test_config = MAPSConfig(
endpoint=config.endpoint,
machine_guid=config.machine_guid,
proxy=config.proxy,
verify_ssl=config.verify_ssl,
cloud_block_level=level,
rotate_guid=True,
)
builder = SpynetReportBuilder(test_config)
# Use EICAR hash with different block levels to see if response differs
payload = builder.build_hash_query(
sha256="275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f",
sha1="3395856ce81f2b7382dee72602f798b642f14140",
md5="44d88612fea8a8f36de82e1278abb02f",
)
status, body, resp_hdrs, latency = transport.send(payload)
known = level in (0, 1, 2, 4, 6)
result = FuzzResult(
module="block_levels",
probe_id=probe_id,
description=f"CloudBlockLevel={level}{'(known)' if known else ''}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
decoded_fields=try_decode_response(body),
)
if not known and status == 200:
result.interesting = True
result.notes = f"Unknown block level {level} accepted"
logger.log(result)
except Exception as e:
logger.log(FuzzResult(
module="block_levels", probe_id=probe_id,
description=f"CloudBlockLevel={level}", error=str(e)[:200],
))
time.sleep(delay)
def fuzz_heartbeat_types(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Enumerate heartbeat subtypes beyond known 0-11."""
print("\n[HEARTBEAT TYPE FUZZING] Probing heartbeat subtypes 0-30+...")
builder = SpynetReportBuilder(config)
# Known: 0-11. Probe up to 50 + edge cases
types = list(range(0, 51)) + [64, 100, 128, 200, 255]
# Get baseline (type 0)
try:
payload = builder.build_heartbeat(hb_type=0)
bstatus, bbody, _, _ = transport.send(payload)
baseline_size = len(bbody)
logger.set_baseline(bstatus, baseline_size)
print(f" Baseline (type=0): HTTP {bstatus}, {baseline_size}B")
except ConnectionError as e:
print(f" Baseline failed: {e}")
return
for hb_type in types:
probe_id = f"HB-{hb_type:03d}"
try:
payload = builder.build_heartbeat(hb_type=hb_type)
status, body, resp_hdrs, latency = transport.send(payload)
known = hb_type <= 11
result = FuzzResult(
module="heartbeat_types",
probe_id=probe_id,
description=f"HeartbeatType={hb_type}{'(known)' if known else ''}",
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
decoded_fields=try_decode_response(body),
)
if status == 200 and len(body) != baseline_size:
result.interesting = True
result.notes = f"Response {len(body)}B differs from baseline {baseline_size}B"
elif not known and status == 200:
result.notes = f"Unknown type {hb_type} accepted (same response size)"
logger.log(result)
except ConnectionError as e:
logger.log(FuzzResult(
module="heartbeat_types", probe_id=probe_id,
description=f"HeartbeatType={hb_type}", error=str(e)[:200],
))
time.sleep(delay)
def fuzz_hidden_features(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Targeted probes designed to surface hidden/undocumented API features.
Strategy: craft payloads that combine unusual but valid field combinations
that real Defender wouldn't normally send together, then watch for
differential responses (new fields, different sizes, new behavior).
"""
print("\n[HIDDEN FEATURE DISCOVERY] Targeted combination probes...")
builder = SpynetReportBuilder(config)
eicar_sha = "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f"
eicar_sha1 = "3395856ce81f2b7382dee72602f798b642f14140"
eicar_md5 = "44d88612fea8a8f36de82e1278abb02f"
# Get baseline for comparison
try:
bp = builder.build_hash_query(sha256=eicar_sha, sha1=eicar_sha1, md5=eicar_md5)
bstatus, bbody, _, _ = transport.send(bp)
baseline_eicar_size = len(bbody)
bp2 = builder.build_heartbeat()
_, bbody2, _, _ = transport.send(bp2)
baseline_hb_size = len(bbody2)
print(f" Baseline EICAR: {baseline_eicar_size}B, Heartbeat: {baseline_hb_size}B")
except ConnectionError as e:
print(f" Baseline failed: {e}")
return
probes: List[Tuple[str, str, callable]] = []
# --- 1. IsMsftInternal=1: Does the server return extra data for internal clients? ---
def _probe_msft_internal():
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_list_begin(SF.IS_MSFT_INTERNAL, BondType.BT_UINT16, 1)
w._write_varint(1)
w.write_field_begin(BondType.BT_LIST, SF.FILE_REPORT_ELEMENTS)
w._write_byte(BondType.BT_LIST); w._write_varint(1)
w._write_byte(BondType.BT_STRUCT); w._write_varint(1)
w._field_stack.append(0)
w.write_list_begin(FRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.INDEX, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.CORE_REPORT, BondType.BT_STRUCT, 1)
w._field_stack.append(0)
w.write_list_begin(CRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_string(CRF.FILE_NAME, "test.exe")
w.write_string(CRF.SHA256, eicar_sha)
w.write_string(CRF.SHA1, eicar_sha1)
w.write_string(CRF.MD5, eicar_md5)
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append(("HF-msft-internal", "IsMsftInternal=1 with EICAR hash", _probe_msft_internal))
# --- 2. TestHook field: Does server recognize test hooks? ---
for hook_val in ["enabled", "debug", "verbose", "trace", "all", "1", "true",
"AllFeatures", "beta", "preview", "internal", "diag"]:
def _probe_testhook(v=hook_val):
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_string(SF.TEST_HOOK, v)
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append((f"HF-testhook-{hook_val}", f"TestHook='{hook_val}'", _probe_testhook))
# --- 3. IsBeta=true: Different handling for beta clients? ---
def _probe_beta():
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_list_begin(SF.IS_BETA, BondType.BT_UINT8, 1)
w._write_byte(1)
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append(("HF-beta", "IsBeta=true heartbeat", _probe_beta))
# --- 4. QueryOnly field: Does this toggle a query-only mode? ---
for qval in ["1", "true", "yes", "query", "readonly"]:
def _probe_queryonly(v=qval):
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_string(SF.QUERY_ONLY, v)
w.write_field_begin(BondType.BT_LIST, SF.FILE_REPORT_ELEMENTS)
w._write_byte(BondType.BT_LIST); w._write_varint(1)
w._write_byte(BondType.BT_STRUCT); w._write_varint(1)
w._field_stack.append(0)
w.write_list_begin(FRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.INDEX, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.CORE_REPORT, BondType.BT_STRUCT, 1)
w._field_stack.append(0)
w.write_list_begin(CRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_string(CRF.FILE_NAME, "test.exe")
w.write_string(CRF.SHA256, eicar_sha)
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append((f"HF-queryonly-{qval}", f"QueryOnly='{qval}'", _probe_queryonly))
# --- 5. MAPS_ORIGIN variations: Does origin affect routing/features? ---
for origin in ["consumer", "enterprise", "mdatp", "mde", "intune", "sccm",
"wdav", "scep", "epp", "ioav", "smartscreen", "defender"]:
def _probe_origin(v=origin):
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_string(SF.MAPS_ORIGIN, v)
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append((f"HF-origin-{origin}", f"MapsOrigin='{origin}'", _probe_origin))
# --- 6. Partner GUID: Does having a partner ID unlock features? ---
def _probe_partner():
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_string(SF.PARTNER_GUID, str(uuid.uuid4()))
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append(("HF-partner", "PartnerGuid set", _probe_partner))
# --- 7. Entra path with consumer payload: Does it handle differently? ---
def _probe_entra_path():
return builder.build_heartbeat()
probes.append(("HF-entra-path", "Heartbeat via /wdcp.svc/entraReport", _probe_entra_path))
# --- 8. Supported compression field: Does declaring compression change response? ---
for comp in ["gzip", "deflate", "br", "zstd", "lz4", "snappy"]:
def _probe_compress(v=comp):
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_string(SF.SUPPORTED_COMPRESS, v)
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append((f"HF-compress-{comp}", f"SupportedCompress='{comp}'", _probe_compress))
# --- 9. VDI_TYPE: Virtual desktop infrastructure type ---
for vdi in [0, 1, 2, 3, 4, 5, 10, 100]:
def _probe_vdi(v=vdi):
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_int32(SF.VDI_TYPE, v)
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append((f"HF-vdi-{vdi}", f"VdiType={vdi}", _probe_vdi))
# --- 10. IsPassiveMode: Passive mode scanning ---
def _probe_passive():
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_bool(SF.IS_PASSIVE_MODE, True)
w.write_field_begin(BondType.BT_LIST, SF.FILE_REPORT_ELEMENTS)
w._write_byte(BondType.BT_LIST); w._write_varint(1)
w._write_byte(BondType.BT_STRUCT); w._write_varint(1)
w._field_stack.append(0)
w.write_list_begin(FRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.INDEX, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.CORE_REPORT, BondType.BT_STRUCT, 1)
w._field_stack.append(0)
w.write_list_begin(CRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_string(CRF.FILE_NAME, "test.exe")
w.write_string(CRF.SHA256, eicar_sha)
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append(("HF-passive", "IsPassiveMode=true with EICAR", _probe_passive))
# --- 11. AsimovDeviceTicket: Does an Asimov token unlock telemetry features? ---
def _probe_asimov():
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_string(SF.ASIMOV_DEVICE_TICKET, "test-asimov-ticket-" + str(uuid.uuid4()))
w.write_string(SF.DEVICE_ID, str(uuid.uuid4()))
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append(("HF-asimov", "AsimovDeviceTicket + DeviceId set", _probe_asimov))
# --- 12. Multiple file reports: Does server handle >1 file differently? ---
def _probe_multi_file():
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_field_begin(BondType.BT_LIST, SF.FILE_REPORT_ELEMENTS)
w._write_byte(BondType.BT_LIST); w._write_varint(1)
w._write_byte(BondType.BT_STRUCT); w._write_varint(3) # 3 files
for idx in range(3):
w._field_stack.append(0)
w.write_list_begin(FRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.INDEX, BondType.BT_INT16, 1)
w._write_varint((idx + 1) * 2)
w.write_list_begin(FRF.CORE_REPORT, BondType.BT_STRUCT, 1)
w._field_stack.append(0)
w.write_list_begin(CRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_string(CRF.FILE_NAME, f"file{idx}.exe")
h = hashlib.sha256(f"fuzz-multi-{idx}".encode()).hexdigest()
w.write_string(CRF.SHA256, h)
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append(("HF-multifile", "3 FileReports in single request", _probe_multi_file))
# --- 13. CloudBlockLevel in payload body: Sweep non-standard values ---
for lvl in [3, 5, 7, 8, 10, 15, 16, 32, 50, 100]:
def _probe_cbl(v=lvl):
w = CompactBinaryV1Writer()
builder._write_top_level(w, str(uuid.uuid4()))
w.write_uint32(SF.CLOUD_BLOCK_LEVEL, v)
w.write_field_begin(BondType.BT_LIST, SF.FILE_REPORT_ELEMENTS)
w._write_byte(BondType.BT_LIST); w._write_varint(1)
w._write_byte(BondType.BT_STRUCT); w._write_varint(1)
w._field_stack.append(0)
w.write_list_begin(FRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.INDEX, BondType.BT_INT16, 1); w._write_varint(2)
w.write_list_begin(FRF.CORE_REPORT, BondType.BT_STRUCT, 1)
w._field_stack.append(0)
w.write_list_begin(CRF.REVISION, BondType.BT_INT16, 1); w._write_varint(2)
w.write_string(CRF.FILE_NAME, "test.exe")
w.write_string(CRF.SHA256, eicar_sha)
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP); w._field_stack.pop()
w._write_byte(BondType.BT_STOP)
return bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
probes.append((f"HF-cbl-{lvl}", f"CloudBlockLevel={lvl} with EICAR", _probe_cbl))
# Run all probes
for probe_id, desc, build_fn in probes:
try:
payload = build_fn()
# Use entra path for the entra probe
path = MAPS_ENTRA_PATH if "entra-path" in probe_id else MAPS_BOND_PATH
status, body, resp_hdrs, latency = transport.send(payload, path=path)
decoded = try_decode_response(body)
result = FuzzResult(
module="hidden_features",
probe_id=probe_id,
description=desc,
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
decoded_fields=decoded,
response_body_hex=body.hex()[:500] if body else "",
)
# Check for interesting differential
is_hb_probe = "heartbeat" in desc.lower() or "Beta" in desc or "Origin" in desc
compare_size = baseline_hb_size if is_hb_probe else baseline_eicar_size
if status == 200 and len(body) != compare_size:
result.interesting = True
result.notes = f"Response {len(body)}B vs baseline {compare_size}B — DIFFERENT!"
elif status != 200:
result.interesting = True
result.notes = f"Non-200 status: {status}"
# Check for new response fields not in standard set
if decoded:
field_ids = set()
_collect_field_ids(decoded, field_ids)
known_resp = {3, 5, 6, 10, 20} # standard response ordinals
novel = field_ids - known_resp
if novel:
result.interesting = True
result.notes = (result.notes or "") + f" Novel response fields: {sorted(novel)}"
logger.log(result)
except Exception as e:
logger.log(FuzzResult(
module="hidden_features", probe_id=probe_id,
description=desc, error=str(e)[:200],
))
time.sleep(delay)
def fuzz_bond_wire_types(transport: FuzzTransport, config: MAPSConfig, logger: FuzzLogger, delay: float):
"""Send known fields with unexpected Bond wire types to probe parsing."""
print("\n[BOND TYPE FUZZING] Sending fields with unexpected wire types...")
builder = SpynetReportBuilder(config)
# For each test, we'll build a heartbeat-like payload but override
# one field with a different Bond wire type
wire_type_tests = [
# (field_ordinal, wire_type, value_bytes, description)
# Send MACHINE_GUID (normally STRING) as different types
(30, BondType.BT_UINT32, b'\x01', "MachineGuid as UINT32"),
(30, BondType.BT_LIST, bytes([BondType.BT_STRING, 1]) + b'\x24' + str(uuid.uuid4()).encode(), "MachineGuid as LIST<STRING>"),
(30, BondType.BT_WSTRING, b'\x24' + str(uuid.uuid4()).encode('utf-16-le'), "MachineGuid as WSTRING"),
# Send REVISION (normally LIST<INT16>) as different types
(20, BondType.BT_INT16, b'\x06', "Revision as INT16 directly"),
(20, BondType.BT_STRING, b'\x01\x33', "Revision as STRING '3'"),
(20, BondType.BT_UINT32, b'\x03', "Revision as UINT32"),
# Send OS_BUILD (normally LIST<UINT32>) as INT32
(170, BondType.BT_INT32, b'\x80\x82\x03', "OsBuild as INT32"),
(170, BondType.BT_STRING, b'\x05\x32\x36\x31\x30\x30', "OsBuild as STRING"),
# Send IS_HEARTBEAT with different values
(90, BondType.BT_UINT8, b'\x00', "IsHeartbeat=0"),
(90, BondType.BT_UINT8, b'\x01', "IsHeartbeat=1"),
(90, BondType.BT_UINT8, b'\x02', "IsHeartbeat=2"),
(90, BondType.BT_UINT8, b'\xFF', "IsHeartbeat=255"),
# IS_MSFT_INTERNAL flag (LIST<UINT16> on wire)
(580, BondType.BT_LIST, b'\x04\x01\x01', "IsMsftInternal=1 LIST<UINT16>"),
(580, BondType.BT_LIST, b'\x04\x01\x00', "IsMsftInternal=0 LIST<UINT16>"),
# TEST_HOOK (STRING field 590)
(590, BondType.BT_STRING, b'\x04test', "TestHook='test'"),
(590, BondType.BT_STRING, b'\x05debug', "TestHook='debug'"),
(590, BondType.BT_STRING, b'\x08internal', "TestHook='internal'"),
# IS_BETA flag (LIST<UINT8> on wire)
(910, BondType.BT_LIST, b'\x03\x01\x01', "IsBeta=1 LIST<UINT8>"),
(910, BondType.BT_LIST, b'\x03\x01\x00', "IsBeta=0 LIST<UINT8>"),
]
# Get baseline
baseline_payload = build_minimal_heartbeat(config)
try:
bstatus, bbody, _, _ = transport.send(baseline_payload)
baseline_size = len(bbody)
logger.set_baseline(bstatus, baseline_size)
print(f" Baseline: HTTP {bstatus}, {baseline_size}B")
except ConnectionError as e:
print(f" Baseline failed: {e}")
return
for i, (ordinal, btype, value_bytes, desc) in enumerate(wire_type_tests):
probe_id = f"BWT-{i:03d}"
try:
# Build minimal payload manually with the injected field
w = CompactBinaryV1Writer()
# Write minimal top-level fields
w.write_string(SF.MACHINE_GUID, str(uuid.uuid4()))
w.write_string(SF.AV_SIG_VERSION, config.av_sig_version)
w.write_string(SF.ENGINE_VERSION, config.engine_version)
w.write_string(SF.OS_VER, "10.0.0.0")
w.write_string(SF.APP_VERSION, config.app_version)
# Inject the test field
w.write_field_begin(btype, ordinal)
w._write(value_bytes)
w._write_byte(BondType.BT_STOP)
payload = bond_wrap_with_schema(SPYNET_REPORT_SCHEMA, w.get_data())
status, body, resp_hdrs, latency = transport.send(payload)
result = FuzzResult(
module="bond_types",
probe_id=probe_id,
description=desc,
http_status=status,
response_size=len(body),
latency_ms=latency,
response_headers=resp_hdrs,
decoded_fields=try_decode_response(body),
)
if status == 200 and len(body) != baseline_size:
result.interesting = True
result.notes = f"Response {len(body)}B differs from baseline {baseline_size}B"
elif status != bstatus:
result.interesting = True
result.notes = f"Status changed from {bstatus} to {status}"
logger.log(result)
except Exception as e:
logger.log(FuzzResult(
module="bond_types", probe_id=probe_id,
description=desc, error=str(e)[:200],
))
time.sleep(delay)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
MODULES = {
"paths": fuzz_paths,
"report-types": fuzz_report_types,
"headers": fuzz_headers,
"fields": fuzz_spynet_fields,
"response-fields": fuzz_response_fields,
"schemas": fuzz_schemas,
"endpoints": fuzz_endpoints,
"versions": fuzz_versions,
"customer-types": fuzz_customer_types,
"block-levels": fuzz_block_levels,
"heartbeat-types": fuzz_heartbeat_types,
"bond-types": fuzz_bond_wire_types,
"hidden": fuzz_hidden_features,
}
def main():
parser = argparse.ArgumentParser(
description="MAPS API Fuzzer — Feature Discovery & Attack Surface Enumeration",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Modules:
paths Fuzz URL paths for undiscovered endpoints
report-types Try report type values beyond known 1-7
headers Enumerate custom HTTP headers and values
fields Probe unknown Bond field ordinals in SpynetReport
response-fields Analyze response fields across different request types
schemas Try alternative Bond schema names
endpoints Discover endpoint hostname variations
versions Fuzz version strings for behavior changes
customer-types Enumerate customer type header values
block-levels Sweep all cloud block level values
heartbeat-types Enumerate heartbeat subtypes beyond known 0-11
bond-types Send fields with unexpected Bond wire types
all Run all modules sequentially
Examples:
python fuzz_maps.py paths
python fuzz_maps.py --delay 2.0 --verbose all
python fuzz_maps.py --proxy http://127.0.0.1:8080 --no-verify headers
python fuzz_maps.py --ppe report-types
python fuzz_maps.py --endpoint https://custom.endpoint.com fields
""")
parser.add_argument("module", choices=list(MODULES.keys()) + ["all"],
help="Fuzz module to run")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show all probe results (not just interesting)")
parser.add_argument("-d", "--delay", type=float, default=1.0,
help="Delay between requests in seconds (default: 1.0)")
parser.add_argument("-o", "--output", default="fuzz_results",
help="Output directory for results (default: fuzz_results)")
parser.add_argument("--endpoint", default=MAPS_ENDPOINT_PROD,
help="MAPS endpoint URL")
parser.add_argument("--ppe", action="store_true",
help="Use pre-production endpoint")
parser.add_argument("--proxy", help="HTTP proxy (e.g. http://127.0.0.1:8080)")
parser.add_argument("--no-verify", action="store_true",
help="Disable TLS certificate verification")
parser.add_argument("--timeout", type=int, default=15,
help="Request timeout in seconds (default: 15)")
parser.add_argument("--machine-guid", help="Override machine GUID")
parser.add_argument("--bearer-token", help="Enterprise AAD Bearer token")
parser.add_argument("--customer-type", default="Consumer",
help="Customer type header (default: Consumer)")
args = parser.parse_args()
if not HAS_REQUESTS:
print("ERROR: 'requests' library required. Install with: pip install requests")
sys.exit(1)
# Build config
endpoint = MAPS_ENDPOINT_PPE if args.ppe else args.endpoint
config = MAPSConfig(
endpoint=endpoint,
proxy=args.proxy,
verify_ssl=not args.no_verify,
timeout=args.timeout,
customer_type=args.customer_type,
rotate_guid=True,
)
if args.machine_guid:
config.machine_guid = args.machine_guid
if args.bearer_token:
config.bearer_token = args.bearer_token
transport = FuzzTransport(config)
logger = FuzzLogger(output_dir=args.output, verbose=args.verbose)
print(f"MAPS API Fuzzer")
print(f" Endpoint: {config.endpoint}")
print(f" Delay: {args.delay}s between probes")
print(f" Output: {args.output}/")
if args.proxy:
print(f" Proxy: {args.proxy}")
if args.module == "all":
for name, func in MODULES.items():
print(f"\n{'='*60}")
print(f"Running module: {name}")
print(f"{'='*60}")
module_logger = FuzzLogger(output_dir=args.output, verbose=args.verbose)
try:
func(transport, config, module_logger, args.delay)
except KeyboardInterrupt:
print("\n Interrupted!")
except Exception as e:
print(f" Module error: {e}")
module_logger.save(name)
logger.results.extend(module_logger.results)
logger.save_summary()
else:
try:
MODULES[args.module](transport, config, logger, args.delay)
except KeyboardInterrupt:
print("\n Interrupted!")
logger.save(args.module)
logger.save_summary()
if __name__ == "__main__":
main()