#!/usr/bin/env python3 """BLE security assessment using bleak for device scanning and GATT enumeration.""" import argparse import asyncio import json import sys import time from bleak import BleakClient, BleakScanner SENSITIVE_SERVICE_UUIDS = { "0000180d-0000-1000-8000-00805f9b34fb": "Heart Rate", "00001810-0000-1000-8000-00805f9b34fb": "Blood Pressure", "00001808-0000-1000-8000-00805f9b34fb": "Glucose", "00001809-0000-1000-8000-00805f9b34fb": "Health Thermometer", "0000180f-0000-1000-8000-00805f9b34fb": "Battery Service", "0000180a-0000-1000-8000-00805f9b34fb": "Device Information", "00001812-0000-1000-8000-00805f9b34fb": "Human Interface Device", "00001802-0000-1000-8000-00805f9b34fb": "Immediate Alert", } SENSITIVE_CHAR_UUIDS = { "00002a37-0000-1000-8000-00805f9b34fb": "Heart Rate Measurement", "00002a35-0000-1000-8000-00805f9b34fb": "Blood Pressure Measurement", "00002a18-0000-1000-8000-00805f9b34fb": "Glucose Measurement", "00002a1c-0000-1000-8000-00805f9b34fb": "Temperature Measurement", "00002a19-0000-1000-8000-00805f9b34fb": "Battery Level", "00002a29-0000-1000-8000-00805f9b34fb": "Manufacturer Name", "00002a26-0000-1000-8000-00805f9b34fb": "Firmware Revision", "00002a28-0000-1000-8000-00805f9b34fb": "Software Revision", "00002a25-0000-1000-8000-00805f9b34fb": "Serial Number", } VULNERABLE_DEVICE_PATTERNS = [ "ITAG", "SmartLock", "BLE_Door", "FitBand", "iTag", "CC2541", "HM-10", "JDY-08", "AT-09", "MLT-BT05", ] async def scan_devices(scan_time: float) -> list: devices = await BleakScanner.discover(timeout=scan_time, return_adv=True) results = [] for addr, (device, adv_data) in devices.items(): name = adv_data.local_name or device.name or "Unknown" vuln_match = None for pattern in VULNERABLE_DEVICE_PATTERNS: if pattern.lower() in name.lower(): vuln_match = pattern break results.append({ "address": str(addr), "name": name, "rssi": adv_data.rssi, "service_uuids": [str(u) for u in (adv_data.service_uuids or [])], "manufacturer_data": {str(k): v.hex() for k, v in (adv_data.manufacturer_data or {}).items()}, "known_vulnerable_pattern": vuln_match, }) results.sort(key=lambda d: d["rssi"], reverse=True) return results async def enumerate_gatt(device_address: str) -> dict: findings = [] services_info = [] total_chars = 0 async with BleakClient(device_address, timeout=15.0) as client: if not client.is_connected: return {"error": f"Failed to connect to {device_address}"} for service in client.services: svc_uuid = str(service.uuid) svc_name = SENSITIVE_SERVICE_UUIDS.get(svc_uuid, service.description or "Unknown") is_sensitive_svc = svc_uuid in SENSITIVE_SERVICE_UUIDS chars_info = [] for char in service.characteristics: total_chars += 1 char_uuid = str(char.uuid) props = char.properties char_name = SENSITIVE_CHAR_UUIDS.get(char_uuid, char.description or "Unknown") is_sensitive_char = char_uuid in SENSITIVE_CHAR_UUIDS char_entry = { "uuid": char_uuid, "name": char_name, "properties": list(props), "handle": char.handle, } if is_sensitive_char and ("read" in props): findings.append({ "severity": "high", "finding": f"{char_name} readable without encryption", "uuid": char_uuid, "service": svc_name, "properties": list(props), "remediation": "Enable encryption requirement on characteristic", }) if "write-without-response" in props and is_sensitive_svc: findings.append({ "severity": "critical", "finding": f"{char_name} writable without response in sensitive service", "uuid": char_uuid, "service": svc_name, "properties": list(props), "remediation": "Remove write-without-response or require authenticated pairing", }) if "write" in props and not is_sensitive_svc: findings.append({ "severity": "medium", "finding": f"{char_name} writable without known authentication", "uuid": char_uuid, "service": svc_name, "properties": list(props), "remediation": "Verify write access requires bonded connection", }) chars_info.append(char_entry) services_info.append({ "uuid": svc_uuid, "name": svc_name, "sensitive": is_sensitive_svc, "characteristics": chars_info, }) severity_weights = {"critical": 10, "high": 7, "medium": 4, "low": 1} risk_total = sum(severity_weights.get(f["severity"], 0) for f in findings) risk_score = min(10.0, round(risk_total / max(len(findings), 1), 1)) return { "services_found": len(services_info), "characteristics_found": total_chars, "services": services_info, "findings": findings, "risk_score": risk_score, } async def run_audit(device_address: str, scan_time: float) -> dict: scan_results = await scan_devices(scan_time) target = None for dev in scan_results: if dev["address"].upper() == device_address.upper(): target = dev break if not target: return {"error": f"Device {device_address} not found in scan", "scanned_devices": len(scan_results)} gatt_result = await enumerate_gatt(device_address) return { "assessment_type": "ble_security_audit", "target_device": target, **gatt_result, } def main(): parser = argparse.ArgumentParser(description="BLE Security Assessment Tool") parser.add_argument("--action", choices=["scan", "enumerate", "audit"], required=True, help="Action to perform") parser.add_argument("--scan-time", type=float, default=10.0, help="BLE scan duration in seconds") parser.add_argument("--device-address", type=str, default=None, help="Target BLE device address (MAC or UUID)") parser.add_argument("--output", type=str, default=None, help="Output JSON file path") args = parser.parse_args() if args.action in ("enumerate", "audit") and not args.device_address: print(json.dumps({"error": "Device address required for enumerate/audit"})) sys.exit(1) start = time.time() if args.action == "scan": result = asyncio.run(scan_devices(args.scan_time)) output = {"action": "scan", "devices_found": len(result), "devices": result} elif args.action == "enumerate": result = asyncio.run(enumerate_gatt(args.device_address)) output = {"action": "enumerate", "target": args.device_address, **result} elif args.action == "audit": output = asyncio.run(run_audit(args.device_address, args.scan_time)) output["elapsed_seconds"] = round(time.time() - start, 2) report = json.dumps(output, indent=2) if args.output: with open(args.output, "w") as f: f.write(report) print(report) if __name__ == "__main__": main()