iotaudit 0.1.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.
- iotaudit/__init__.py +3 -0
- iotaudit/analyze.py +260 -0
- iotaudit/arp.py +313 -0
- iotaudit/capture.py +356 -0
- iotaudit/cli.py +224 -0
- iotaudit/consent.py +29 -0
- iotaudit/data/NOTICE +8 -0
- iotaudit/data/dbip-country-lite.mmdb +0 -0
- iotaudit/data/ip2asn-lite.tsv.gz +0 -0
- iotaudit/data/oui.txt.gz +0 -0
- iotaudit/discover.py +271 -0
- iotaudit/enrich.py +327 -0
- iotaudit/grading.py +328 -0
- iotaudit/hotspot.py +41 -0
- iotaudit/identify.py +405 -0
- iotaudit/mcp_server.py +57 -0
- iotaudit/models.py +101 -0
- iotaudit/profiles/generic-camera.yaml +10 -0
- iotaudit/profiles/imipaw-feeder.yaml +13 -0
- iotaudit/profiles/iotbing-cloud.yaml +13 -0
- iotaudit/profiles/tuya-generic.yaml +10 -0
- iotaudit/profiles/xiaomi-miio.yaml +9 -0
- iotaudit/profiles.py +59 -0
- iotaudit/report.py +17 -0
- iotaudit/review.py +63 -0
- iotaudit/scan.py +59 -0
- iotaudit/templates/report.html.j2 +96 -0
- iotaudit/traffic.py +143 -0
- iotaudit-0.1.0.dist-info/METADATA +143 -0
- iotaudit-0.1.0.dist-info/RECORD +33 -0
- iotaudit-0.1.0.dist-info/WHEEL +4 -0
- iotaudit-0.1.0.dist-info/entry_points.txt +3 -0
- iotaudit-0.1.0.dist-info/licenses/LICENSE +21 -0
iotaudit/__init__.py
ADDED
iotaudit/analyze.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Turn captured flows into per-device findings and grades."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .capture import Collector
|
|
6
|
+
from .enrich import (
|
|
7
|
+
PORT_LABELS,
|
|
8
|
+
Geo,
|
|
9
|
+
asn_org,
|
|
10
|
+
is_cgnat,
|
|
11
|
+
is_special_use,
|
|
12
|
+
is_tracker,
|
|
13
|
+
mac_vendor,
|
|
14
|
+
org_for,
|
|
15
|
+
special_use_label,
|
|
16
|
+
)
|
|
17
|
+
from .grading import findings_for, grade, summarize
|
|
18
|
+
from .models import Destination, Device, TrafficClass
|
|
19
|
+
from .profiles import is_expected, load_profiles, match_profile
|
|
20
|
+
from .traffic import classify_traffic, detect_bursts, encryption_verdict, merge_series
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _apply_flow(d: Destination, f) -> None:
|
|
24
|
+
d.ports.add(f.dport)
|
|
25
|
+
d.bytes_out += f.bytes
|
|
26
|
+
d.bytes_in += getattr(f, "bytes_in", 0) or 0
|
|
27
|
+
d.packets += f.packets
|
|
28
|
+
d.packets_in += getattr(f, "packets_in", 0) or 0
|
|
29
|
+
d.lan = d.lan or f.lan
|
|
30
|
+
if f.sni:
|
|
31
|
+
d.domains.add(f.sni.lower())
|
|
32
|
+
if f.http_host:
|
|
33
|
+
d.domains.add(f.http_host.split(":")[0].lower())
|
|
34
|
+
for k, v in (f.series or {}).items():
|
|
35
|
+
d.series[k] = d.series.get(k, 0) + v
|
|
36
|
+
if f.dport in PORT_LABELS:
|
|
37
|
+
d.protocols.add(PORT_LABELS[f.dport])
|
|
38
|
+
if f.dport == 3478:
|
|
39
|
+
d.protocols.add("STUN/TURN")
|
|
40
|
+
|
|
41
|
+
if f.tls or f.dport in (443, 8883, 853):
|
|
42
|
+
ver = encryption_verdict(f.payload_samples, assumed_tls=True)
|
|
43
|
+
elif f.plaintext_http:
|
|
44
|
+
ver = {
|
|
45
|
+
"kind": "plaintext",
|
|
46
|
+
"confidence": "certain",
|
|
47
|
+
"readable_fraction": 1.0,
|
|
48
|
+
"detail": "HTTP plaintext observed.",
|
|
49
|
+
}
|
|
50
|
+
elif f.dport == 3478:
|
|
51
|
+
ver = {
|
|
52
|
+
"kind": "stun",
|
|
53
|
+
"confidence": "certain",
|
|
54
|
+
"readable_fraction": 0.0,
|
|
55
|
+
"detail": "STUN/TURN signalling (not app-payload encryption).",
|
|
56
|
+
}
|
|
57
|
+
else:
|
|
58
|
+
ver = encryption_verdict(f.payload_samples, assumed_tls=False)
|
|
59
|
+
|
|
60
|
+
order = {"plaintext": 0, "unknown": 1, "custom-encryption": 2, "stun": 3, "tls": 4}
|
|
61
|
+
prev = d.encryption_kind
|
|
62
|
+
if prev is None or order.get(ver["kind"], 9) < order.get(prev, 9):
|
|
63
|
+
d.encryption_kind = ver["kind"]
|
|
64
|
+
d.encryption_detail = ver["detail"]
|
|
65
|
+
d.readable_fraction = ver.get("readable_fraction")
|
|
66
|
+
d.encrypted = d.encryption_kind != "plaintext"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _enrich(d: Destination, geo: Geo, dns_map: dict) -> None:
|
|
70
|
+
d.domains |= set(dns_map.get(d.ip, set()))
|
|
71
|
+
d.country = geo.country(d.ip)
|
|
72
|
+
d.org = org_for(d.domains)
|
|
73
|
+
if not d.org:
|
|
74
|
+
_asn, org = asn_org(d.ip)
|
|
75
|
+
if org:
|
|
76
|
+
d.org = org
|
|
77
|
+
if is_cgnat(d.ip):
|
|
78
|
+
d.org = d.org or "CGNAT / P2P peer"
|
|
79
|
+
d.protocols.add("CGNAT-peer")
|
|
80
|
+
if is_special_use(d.ip):
|
|
81
|
+
d.__dict__["special_use"] = True
|
|
82
|
+
d.org = d.org or "special-use / bogon"
|
|
83
|
+
d.protocols.add("special-use")
|
|
84
|
+
d.__dict__.setdefault("looks_like", special_use_label(d.ip))
|
|
85
|
+
d.tracker = is_tracker(d.domains)
|
|
86
|
+
if d.series:
|
|
87
|
+
duration = max(d.series) - min(d.series) + 10
|
|
88
|
+
avg_pkt = (d.bytes_out / d.packets) if d.packets else None
|
|
89
|
+
tc = classify_traffic(d.bytes_out, duration, d.ports, avg_pkt, d.protocols)
|
|
90
|
+
d.traffic_class = TrafficClass(
|
|
91
|
+
label=tc["label"], confidence=tc["confidence"], evidence=tc.get("evidence") or {}
|
|
92
|
+
)
|
|
93
|
+
d.__dict__["_tc_detail"] = tc.get("detail")
|
|
94
|
+
d.bursts = detect_bursts(d.series)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _idle_upload_verdict(device: Device) -> str:
|
|
99
|
+
"""Compare low-activity baseline vs larger outbound bursts on internet destinations."""
|
|
100
|
+
series = device.series_total or {}
|
|
101
|
+
if len(series) < 3:
|
|
102
|
+
return "insufficient data"
|
|
103
|
+
rates = sorted(v * 8 / 10 for v in series.values())
|
|
104
|
+
n = max(1, len(rates) // 5)
|
|
105
|
+
baseline = sum(rates[:n]) / n
|
|
106
|
+
threshold = max(baseline * 3, 50_000)
|
|
107
|
+
spikes = [r for r in rates if r >= threshold]
|
|
108
|
+
if not spikes:
|
|
109
|
+
return "none detected"
|
|
110
|
+
wan = sum(d.bytes_out for d in device.destinations.values())
|
|
111
|
+
if wan < 100_000:
|
|
112
|
+
return "none detected (bursts were small / likely signalling; check LAN peers for video)"
|
|
113
|
+
return "detected"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_devices(
|
|
117
|
+
col: Collector,
|
|
118
|
+
geo: Geo | None = None,
|
|
119
|
+
extra_profiles: str | None = None,
|
|
120
|
+
scans: dict[str, list] | None = None,
|
|
121
|
+
peer_labels: dict[str, str] | None = None,
|
|
122
|
+
) -> list[Device]:
|
|
123
|
+
geo = geo or Geo()
|
|
124
|
+
profiles = load_profiles(extra_profiles)
|
|
125
|
+
devices: dict[str, Device] = {}
|
|
126
|
+
|
|
127
|
+
def ensure(ip: str) -> Device:
|
|
128
|
+
return devices.setdefault(ip, Device(ip=ip, mac=col.macs.get(ip)))
|
|
129
|
+
|
|
130
|
+
from .capture import is_broadcast_or_multicast
|
|
131
|
+
|
|
132
|
+
all_flows = list(col.flows.values()) + list(getattr(col, "lan_flows", {}).values())
|
|
133
|
+
for f in all_flows:
|
|
134
|
+
dest = ensure(f.src)
|
|
135
|
+
if f.lan and is_broadcast_or_multicast(f.dst):
|
|
136
|
+
# Discovery broadcasts (e.g. Tuya 255.255.255.255:6667) — not media peers
|
|
137
|
+
b = next((x for x in dest.broadcasts if x.get("ip") == f.dst and x.get("port") == f.dport), None)
|
|
138
|
+
if not b:
|
|
139
|
+
b = {"ip": f.dst, "port": f.dport, "proto": f.proto, "bytes_out": 0, "packets": 0}
|
|
140
|
+
dest.broadcasts.append(b)
|
|
141
|
+
b["bytes_out"] += f.bytes
|
|
142
|
+
b["packets"] += f.packets
|
|
143
|
+
continue
|
|
144
|
+
bucket = dest.lan_peers if f.lan else dest.destinations
|
|
145
|
+
d = bucket.setdefault(f.dst, Destination(ip=f.dst, lan=f.lan))
|
|
146
|
+
_apply_flow(d, f)
|
|
147
|
+
|
|
148
|
+
for ip, qs in col.queries.items():
|
|
149
|
+
if ip in devices:
|
|
150
|
+
devices[ip].dns_queries |= qs
|
|
151
|
+
|
|
152
|
+
for dest in devices.values():
|
|
153
|
+
dest.vendor = mac_vendor(dest.mac)
|
|
154
|
+
for d in list(dest.destinations.values()) + list(dest.lan_peers.values()):
|
|
155
|
+
_enrich(d, geo, col.dns)
|
|
156
|
+
for port in d.ports:
|
|
157
|
+
if port in PORT_LABELS:
|
|
158
|
+
d.protocols.add(PORT_LABELS[port])
|
|
159
|
+
|
|
160
|
+
prof = match_profile(dest, profiles)
|
|
161
|
+
soft_hint = None
|
|
162
|
+
cloud_dns = any("." in n and not n.endswith(".local") for n in dest.dns_queries) or any(
|
|
163
|
+
d.domains for d in dest.destinations.values()
|
|
164
|
+
)
|
|
165
|
+
if not prof:
|
|
166
|
+
ports = {p for d in dest.destinations.values() for p in d.ports}
|
|
167
|
+
orgs = {d.org or "" for d in dest.destinations.values()}
|
|
168
|
+
aws_mqtt = 8883 in ports and any(("Amazon" in o or "AWS" in o) for o in orgs)
|
|
169
|
+
if aws_mqtt and not cloud_dns:
|
|
170
|
+
soft_hint = (
|
|
171
|
+
"MQTT cloud device, possibly Tuya (AWS :8883, no DNS/SNI)",
|
|
172
|
+
"low",
|
|
173
|
+
)
|
|
174
|
+
dest.profile = prof.get("name") if prof else None
|
|
175
|
+
if soft_hint and not dest.profile:
|
|
176
|
+
dest.__dict__["_soft_hint"] = soft_hint
|
|
177
|
+
for d in dest.destinations.values():
|
|
178
|
+
d.expected = is_expected(d.domains, prof)
|
|
179
|
+
if scans and dest.ip in scans:
|
|
180
|
+
dest.open_ports = scans[dest.ip]
|
|
181
|
+
if peer_labels:
|
|
182
|
+
for ip, d in dest.lan_peers.items():
|
|
183
|
+
if ip in peer_labels:
|
|
184
|
+
d.__dict__["looks_like"] = peer_labels[ip]
|
|
185
|
+
|
|
186
|
+
dest.series_total = merge_series([d.series for d in dest.destinations.values()])
|
|
187
|
+
dest.bursts = detect_bursts(dest.series_total)
|
|
188
|
+
dest.idle_upload = _idle_upload_verdict(dest)
|
|
189
|
+
try:
|
|
190
|
+
dest.__dict__["ipv6"] = col.ipv6_summary(dest.mac)
|
|
191
|
+
except Exception: # noqa: BLE001
|
|
192
|
+
dest.__dict__["ipv6"] = {}
|
|
193
|
+
dest.findings = findings_for(dest)
|
|
194
|
+
dest.score, dest.grade = grade(dest.findings)
|
|
195
|
+
dest.summary = summarize(dest)
|
|
196
|
+
|
|
197
|
+
return sorted(devices.values(), key=lambda d: d.ip)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _dest_dict(d: Destination) -> dict:
|
|
201
|
+
tc = None
|
|
202
|
+
if d.traffic_class:
|
|
203
|
+
tc = {
|
|
204
|
+
"label": d.traffic_class.label,
|
|
205
|
+
"confidence": d.traffic_class.confidence,
|
|
206
|
+
"detail": d.__dict__.get("_tc_detail"),
|
|
207
|
+
"evidence": d.traffic_class.evidence,
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
"ip": d.ip,
|
|
211
|
+
"looks_like": d.__dict__.get("looks_like"),
|
|
212
|
+
"domains": sorted(d.domains),
|
|
213
|
+
"country": d.country,
|
|
214
|
+
"org": d.org,
|
|
215
|
+
"ports": sorted(d.ports),
|
|
216
|
+
"bytes_out": d.bytes_out,
|
|
217
|
+
"bytes_in": d.bytes_in,
|
|
218
|
+
"packets": d.packets,
|
|
219
|
+
"packets_in": d.packets_in,
|
|
220
|
+
"encrypted": d.encrypted,
|
|
221
|
+
"encryption_kind": d.encryption_kind,
|
|
222
|
+
"encryption_detail": d.encryption_detail,
|
|
223
|
+
"readable_fraction": d.readable_fraction,
|
|
224
|
+
"tracker": d.tracker,
|
|
225
|
+
"expected": d.expected,
|
|
226
|
+
"protocols": sorted(d.protocols),
|
|
227
|
+
"traffic_class": tc,
|
|
228
|
+
"series": {str(k): v for k, v in sorted(d.series.items())},
|
|
229
|
+
"bursts": d.bursts,
|
|
230
|
+
"lan": d.lan,
|
|
231
|
+
"special_use": bool(d.__dict__.get("special_use")),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def to_dict(dev: Device) -> dict:
|
|
236
|
+
return {
|
|
237
|
+
"ip": dev.ip,
|
|
238
|
+
"mac": dev.mac,
|
|
239
|
+
"vendor": dev.vendor,
|
|
240
|
+
"profile": dev.profile,
|
|
241
|
+
"grade": dev.grade,
|
|
242
|
+
"score": dev.score,
|
|
243
|
+
"summary": dev.summary,
|
|
244
|
+
"idle_upload": dev.idle_upload,
|
|
245
|
+
"findings": dev.findings,
|
|
246
|
+
"open_ports": dev.open_ports,
|
|
247
|
+
"dns_queries": sorted(dev.dns_queries),
|
|
248
|
+
"destinations": [
|
|
249
|
+
_dest_dict(d)
|
|
250
|
+
for d in sorted(dev.destinations.values(), key=lambda x: -x.bytes_out)
|
|
251
|
+
],
|
|
252
|
+
"lan_peers": [
|
|
253
|
+
_dest_dict(d)
|
|
254
|
+
for d in sorted(dev.lan_peers.values(), key=lambda x: -x.bytes_out)
|
|
255
|
+
],
|
|
256
|
+
"broadcasts": dev.broadcasts,
|
|
257
|
+
"series_total": {str(k): v for k, v in sorted(dev.series_total.items())},
|
|
258
|
+
"bursts": dev.bursts,
|
|
259
|
+
"ipv6": dev.__dict__.get("ipv6", {}),
|
|
260
|
+
}
|
iotaudit/arp.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""ARP mode (own network only): ARP-spoof one owned device so its traffic flows through this
|
|
2
|
+
computer, capture it, forward it, and restore ARP tables on exit.
|
|
3
|
+
|
|
4
|
+
Uses sendp() with an explicit Ether(dst=<known MAC>) so scapy never ARP-resolves L2 itself
|
|
5
|
+
(which on macOS produced 'MAC address to reach destination not found. Using broadcast.' and
|
|
6
|
+
empty captures). MACs should come from the LAN scan; getmacbyip is only a last resort.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ipaddress
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import warnings
|
|
20
|
+
|
|
21
|
+
from .enrich import normalize_mac
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IPForwarding:
|
|
25
|
+
"""Enable OS IP forwarding; restore the prior value on exit."""
|
|
26
|
+
|
|
27
|
+
KEYS = {"Darwin": "net.inet.ip.forwarding", "Linux": "net.ipv4.ip_forward"}
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.key = self.KEYS.get(platform.system())
|
|
31
|
+
self.prev: str | None = None
|
|
32
|
+
|
|
33
|
+
def _get(self) -> str | None:
|
|
34
|
+
r = subprocess.run(["sysctl", "-n", self.key], capture_output=True, text=True, check=False)
|
|
35
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
36
|
+
|
|
37
|
+
def __enter__(self):
|
|
38
|
+
if not self.key:
|
|
39
|
+
raise RuntimeError("IP forwarding toggle is only supported on macOS and Linux.")
|
|
40
|
+
self.prev = self._get()
|
|
41
|
+
subprocess.run(["sysctl", "-w", f"{self.key}=1"], capture_output=True, text=True, check=False)
|
|
42
|
+
now = self._get()
|
|
43
|
+
if now != "1":
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"Could not enable IP forwarding ({self.key}={now!r}). "
|
|
46
|
+
"ARP mode needs it so the device keeps internet access. Re-run with sudo."
|
|
47
|
+
)
|
|
48
|
+
print(f"IP forwarding enabled ({self.key}=1; was {self.prev}).", file=sys.stderr)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, *exc):
|
|
52
|
+
if self.prev is not None:
|
|
53
|
+
subprocess.run(["sysctl", "-w", f"{self.key}={self.prev}"],
|
|
54
|
+
capture_output=True, text=True, check=False)
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _require_private(*ips: str) -> None:
|
|
59
|
+
for ip in ips:
|
|
60
|
+
if not ipaddress.ip_address(ip).is_private:
|
|
61
|
+
raise ValueError(f"ARP mode only works on your private LAN; {ip} is not private.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_gateway() -> str | None:
|
|
65
|
+
if platform.system() == "Darwin":
|
|
66
|
+
r = subprocess.run(["route", "-n", "get", "default"], capture_output=True, text=True, check=False)
|
|
67
|
+
for line in r.stdout.splitlines():
|
|
68
|
+
if "gateway:" in line:
|
|
69
|
+
return line.split(":", 1)[1].strip()
|
|
70
|
+
else:
|
|
71
|
+
r = subprocess.run(["ip", "route", "show", "default"], capture_output=True, text=True, check=False)
|
|
72
|
+
parts = r.stdout.split()
|
|
73
|
+
if "via" in parts:
|
|
74
|
+
return parts[parts.index("via") + 1]
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_iface(iface: str | None = None) -> str:
|
|
79
|
+
"""Turn 'auto'/None into the real interface that has the default route (e.g. en0)."""
|
|
80
|
+
if iface and iface not in ("auto", "any", "default"):
|
|
81
|
+
return iface
|
|
82
|
+
if platform.system() == "Darwin":
|
|
83
|
+
r = subprocess.run(["route", "-n", "get", "default"], capture_output=True, text=True, check=False)
|
|
84
|
+
for line in r.stdout.splitlines():
|
|
85
|
+
if line.strip().startswith("interface:"):
|
|
86
|
+
return line.split(":", 1)[1].strip()
|
|
87
|
+
try:
|
|
88
|
+
from scapy.all import conf
|
|
89
|
+
|
|
90
|
+
# conf.route.route("0.0.0.0") -> (iface, gw, ip)
|
|
91
|
+
route = conf.route.route("0.0.0.0")
|
|
92
|
+
if route and route[0]:
|
|
93
|
+
return route[0]
|
|
94
|
+
except Exception: # noqa: BLE001
|
|
95
|
+
pass
|
|
96
|
+
if platform.system() != "Darwin":
|
|
97
|
+
r = subprocess.run(["ip", "route", "show", "default"], capture_output=True, text=True, check=False)
|
|
98
|
+
parts = r.stdout.split()
|
|
99
|
+
if "dev" in parts:
|
|
100
|
+
return parts[parts.index("dev") + 1]
|
|
101
|
+
raise RuntimeError("Could not resolve capture interface; pass --iface en0 (or your Wi-Fi iface).")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def our_mac(iface: str) -> str:
|
|
105
|
+
"""MAC address of our own interface."""
|
|
106
|
+
try:
|
|
107
|
+
from scapy.all import get_if_hwaddr
|
|
108
|
+
|
|
109
|
+
mac = normalize_mac(get_if_hwaddr(iface))
|
|
110
|
+
if mac:
|
|
111
|
+
return mac
|
|
112
|
+
except Exception: # noqa: BLE001
|
|
113
|
+
pass
|
|
114
|
+
if platform.system() == "Darwin":
|
|
115
|
+
r = subprocess.run(["ifconfig", iface], capture_output=True, text=True, check=False)
|
|
116
|
+
for line in r.stdout.splitlines():
|
|
117
|
+
if "ether" in line:
|
|
118
|
+
mac = normalize_mac(line.split()[1])
|
|
119
|
+
if mac:
|
|
120
|
+
return mac
|
|
121
|
+
raise RuntimeError(f"Could not read MAC of interface {iface}.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_mac(ip: str, iface: str | None, known: str | None = None) -> str:
|
|
125
|
+
if known:
|
|
126
|
+
mac = normalize_mac(known)
|
|
127
|
+
if mac:
|
|
128
|
+
return mac
|
|
129
|
+
from scapy.layers.l2 import getmacbyip
|
|
130
|
+
|
|
131
|
+
for _ in range(3):
|
|
132
|
+
mac = normalize_mac(getmacbyip(ip) or "")
|
|
133
|
+
if mac:
|
|
134
|
+
return mac
|
|
135
|
+
time.sleep(0.4)
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"Could not resolve MAC for {ip}. Re-run without --device so the LAN scan "
|
|
138
|
+
"caches it, or check 2.4GHz / client isolation."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _quiet_scapy():
|
|
143
|
+
logging.getLogger("scapy").setLevel(logging.ERROR)
|
|
144
|
+
logging.getLogger("scapy.runtime").setLevel(logging.ERROR)
|
|
145
|
+
warnings.filterwarnings("ignore", module="scapy")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ArpSpoofer:
|
|
149
|
+
"""Two-way ARP spoof between target and gateway via sendp() + known MACs."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, target_ip: str, gateway_ip: str, iface: str,
|
|
152
|
+
target_mac: str | None = None, gateway_mac: str | None = None,
|
|
153
|
+
our_mac_addr: str | None = None, interval: float = 2.0,
|
|
154
|
+
extra_peers: list[tuple[str, str]] | None = None):
|
|
155
|
+
"""extra_peers: [(ip, mac), ...] other LAN hosts to poison both ways with target."""
|
|
156
|
+
_require_private(target_ip, gateway_ip)
|
|
157
|
+
self.target_ip = target_ip
|
|
158
|
+
self.gateway_ip = gateway_ip
|
|
159
|
+
self.iface = iface
|
|
160
|
+
self.interval = interval
|
|
161
|
+
self._known_target_mac = target_mac
|
|
162
|
+
self._known_gateway_mac = gateway_mac
|
|
163
|
+
self.our_mac = our_mac_addr
|
|
164
|
+
self.extra_peers = list(extra_peers or [])
|
|
165
|
+
self._stop = threading.Event()
|
|
166
|
+
self._thread: threading.Thread | None = None
|
|
167
|
+
self.target_mac: str | None = None
|
|
168
|
+
self.gateway_mac: str | None = None
|
|
169
|
+
|
|
170
|
+
def _sendp(self, dst_mac: str, pdst: str, hwdst: str, psrc: str, hwsrc: str | None = None):
|
|
171
|
+
from scapy.layers.l2 import ARP, Ether
|
|
172
|
+
from scapy.sendrecv import sendp
|
|
173
|
+
|
|
174
|
+
pkt = Ether(dst=dst_mac, src=self.our_mac) / ARP(
|
|
175
|
+
op=2, pdst=pdst, hwdst=hwdst, psrc=psrc, hwsrc=hwsrc or self.our_mac,
|
|
176
|
+
)
|
|
177
|
+
sendp(pkt, iface=self.iface, verbose=False, count=1)
|
|
178
|
+
|
|
179
|
+
def _loop(self):
|
|
180
|
+
while not self._stop.is_set():
|
|
181
|
+
# Tell target: "I (our MAC) am the gateway"
|
|
182
|
+
self._sendp(self.target_mac, self.target_ip, self.target_mac, self.gateway_ip)
|
|
183
|
+
# Tell gateway: "I (our MAC) am the target"
|
|
184
|
+
self._sendp(self.gateway_mac, self.gateway_ip, self.gateway_mac, self.target_ip)
|
|
185
|
+
# Optional: poison target <-> each LAN peer (see phone P2P video)
|
|
186
|
+
for peer_ip, peer_mac in self.extra_peers:
|
|
187
|
+
self._sendp(self.target_mac, self.target_ip, self.target_mac, peer_ip)
|
|
188
|
+
self._sendp(peer_mac, peer_ip, peer_mac, self.target_ip)
|
|
189
|
+
self._stop.wait(self.interval)
|
|
190
|
+
|
|
191
|
+
def start(self):
|
|
192
|
+
_quiet_scapy()
|
|
193
|
+
if not self.our_mac:
|
|
194
|
+
self.our_mac = our_mac(self.iface)
|
|
195
|
+
self.target_mac = _resolve_mac(self.target_ip, self.iface, self._known_target_mac)
|
|
196
|
+
self.gateway_mac = _resolve_mac(self.gateway_ip, self.iface, self._known_gateway_mac)
|
|
197
|
+
self._thread = threading.Thread(target=self._loop, name="arp-spoof", daemon=True)
|
|
198
|
+
self._thread.start()
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def restore(self):
|
|
202
|
+
if not (self.target_mac and self.gateway_mac and self.our_mac):
|
|
203
|
+
return
|
|
204
|
+
_quiet_scapy()
|
|
205
|
+
for _ in range(5):
|
|
206
|
+
self._sendp("ff:ff:ff:ff:ff:ff", self.target_ip, "ff:ff:ff:ff:ff:ff",
|
|
207
|
+
self.gateway_ip, hwsrc=self.gateway_mac)
|
|
208
|
+
self._sendp("ff:ff:ff:ff:ff:ff", self.gateway_ip, "ff:ff:ff:ff:ff:ff",
|
|
209
|
+
self.target_ip, hwsrc=self.target_mac)
|
|
210
|
+
for peer_ip, peer_mac in self.extra_peers:
|
|
211
|
+
self._sendp("ff:ff:ff:ff:ff:ff", self.target_ip, "ff:ff:ff:ff:ff:ff",
|
|
212
|
+
peer_ip, hwsrc=peer_mac)
|
|
213
|
+
self._sendp("ff:ff:ff:ff:ff:ff", peer_ip, "ff:ff:ff:ff:ff:ff",
|
|
214
|
+
self.target_ip, hwsrc=self.target_mac)
|
|
215
|
+
|
|
216
|
+
def stop(self):
|
|
217
|
+
self._stop.set()
|
|
218
|
+
if self._thread:
|
|
219
|
+
self._thread.join(timeout=3)
|
|
220
|
+
self.restore()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _ll_from_mac(mac: str) -> str | None:
|
|
224
|
+
"""Derive the EUI-64 IPv6 link-local address (fe80::/64) from a MAC."""
|
|
225
|
+
try:
|
|
226
|
+
from scapy.utils6 import in6_mactoifaceid
|
|
227
|
+
|
|
228
|
+
iface_id = in6_mactoifaceid(mac).lower()
|
|
229
|
+
return "fe80::" + iface_id
|
|
230
|
+
except Exception: # noqa: BLE001
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class NDPSpoofer:
|
|
235
|
+
"""Experimental IPv6 neighbor-discovery spoof so device<->peer link-local unicast passes
|
|
236
|
+
through us, the IPv6 analogue of ARP spoofing. Sends unsolicited Neighbor Advertisements
|
|
237
|
+
(override set) claiming our MAC for each side's link-local address. Best-effort: on a Wi-Fi
|
|
238
|
+
network with client isolation the stream may still never reach us, which the report notes."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, iface: str, our_mac_addr: str,
|
|
241
|
+
pairs: list[tuple[str, str]] | None = None, interval: float = 2.0):
|
|
242
|
+
"""pairs: [(ipv6_or_mac_a, ipv6_or_mac_b), ...] — each side given as a link-local
|
|
243
|
+
address or a MAC (we derive the link-local via EUI-64)."""
|
|
244
|
+
self.iface = iface
|
|
245
|
+
self.our_mac = our_mac_addr
|
|
246
|
+
self.interval = interval
|
|
247
|
+
self.interfaces: list[tuple[str, str]] = [] # resolved (ll_a, ll_b)
|
|
248
|
+
for a, b in (pairs or []):
|
|
249
|
+
la = a if a.lower().startswith("fe80") else _ll_from_mac(a)
|
|
250
|
+
lb = b if b.lower().startswith("fe80") else _ll_from_mac(b)
|
|
251
|
+
if la and lb:
|
|
252
|
+
self.interfaces.append((la, lb))
|
|
253
|
+
self._stop = threading.Event()
|
|
254
|
+
self._thread: threading.Thread | None = None
|
|
255
|
+
|
|
256
|
+
def _advert(self, victim_ll: str, claimed_ll: str):
|
|
257
|
+
from scapy.layers.inet6 import ICMPv6ND_NA, ICMPv6NDOptDstLLAddr, IPv6
|
|
258
|
+
from scapy.layers.l2 import Ether
|
|
259
|
+
from scapy.sendrecv import sendp
|
|
260
|
+
|
|
261
|
+
pkt = (Ether(src=self.our_mac)
|
|
262
|
+
/ IPv6(src=claimed_ll, dst=victim_ll)
|
|
263
|
+
/ ICMPv6ND_NA(tgt=claimed_ll, R=0, S=0, O=1)
|
|
264
|
+
/ ICMPv6NDOptDstLLAddr(lladdr=self.our_mac))
|
|
265
|
+
sendp(pkt, iface=self.iface, verbose=False, count=1)
|
|
266
|
+
|
|
267
|
+
def _loop(self):
|
|
268
|
+
while not self._stop.is_set():
|
|
269
|
+
for la, lb in self.interfaces:
|
|
270
|
+
self._advert(la, lb) # tell A: our MAC is B
|
|
271
|
+
self._advert(lb, la) # tell B: our MAC is A
|
|
272
|
+
self._stop.wait(self.interval)
|
|
273
|
+
|
|
274
|
+
def start(self):
|
|
275
|
+
_quiet_scapy()
|
|
276
|
+
if not self.interfaces:
|
|
277
|
+
return self
|
|
278
|
+
self._thread = threading.Thread(target=self._loop, name="ndp-spoof", daemon=True)
|
|
279
|
+
self._thread.start()
|
|
280
|
+
print(f"NDP mode (experimental): spoofing {len(self.interfaces)} IPv6 link-local "
|
|
281
|
+
f"pair(s) on {self.iface}.", file=sys.stderr)
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def stop(self):
|
|
285
|
+
self._stop.set()
|
|
286
|
+
if self._thread:
|
|
287
|
+
self._thread.join(timeout=3)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def start(target_ip: str, gateway_ip: str | None = None, iface: str | None = None,
|
|
291
|
+
target_mac: str | None = None, gateway_mac: str | None = None,
|
|
292
|
+
extra_peers: list[tuple[str, str]] | None = None):
|
|
293
|
+
"""Start spoofing. Returns ArpSpoofer (caller must stop()). Needs root."""
|
|
294
|
+
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
|
295
|
+
raise PermissionError("ARP mode needs root. Re-run with sudo.")
|
|
296
|
+
gw = gateway_ip or default_gateway()
|
|
297
|
+
if not gw:
|
|
298
|
+
raise RuntimeError("Could not determine the default gateway; pass --gateway.")
|
|
299
|
+
real_iface = resolve_iface(iface)
|
|
300
|
+
us = our_mac(real_iface)
|
|
301
|
+
peers = list(extra_peers or [])
|
|
302
|
+
print(
|
|
303
|
+
f"ARP mode: spoofing {target_ip} ({target_mac or '?'}) <-> "
|
|
304
|
+
f"gateway {gw} ({gateway_mac or '?'})"
|
|
305
|
+
+ (f" + {len(peers)} LAN peer(s)" if peers else "")
|
|
306
|
+
+ f" on {real_iface} (us {us}).",
|
|
307
|
+
file=sys.stderr,
|
|
308
|
+
)
|
|
309
|
+
return ArpSpoofer(
|
|
310
|
+
target_ip, gw, real_iface,
|
|
311
|
+
target_mac=target_mac, gateway_mac=gateway_mac, our_mac_addr=us,
|
|
312
|
+
extra_peers=peers,
|
|
313
|
+
).start()
|