feat(prober): active IPv6 link-local solicitation phase

Add ipv6_leak.py with solicit_ipv6_leak() — sends ICMPv6 Echo to
ff02::1 on the attacker's iface and returns fe80:: evidence when a
link-local response arrives. Gated on _is_on_link(): skips when
attacker is behind a router (no L2 adjacency).

Add _ipv6_leak_phase() to worker.py (Phase 4 in _probe_cycle).
Phase runs once per attacker IP per cycle (sentinel at port 0 in
ip_probed["ipv6_leak"]) and publishes kind="ipv6_leak" via publish_fn.

Add list_v6_addrs(iface) to network.py: returns [(addr, scope)] for
all IPv6 addresses on an interface, required for source-routing ICMPv6
from the correct link-local address.
This commit is contained in:
2026-05-17 20:20:19 -04:00
parent aa833ddda9
commit 504340745e
4 changed files with 338 additions and 0 deletions

View File

@@ -78,6 +78,34 @@ def get_host_ip(interface: str) -> str:
raise RuntimeError(f"Could not determine host IP for interface {interface}.")
def list_v6_addrs(interface: str) -> list[tuple[str, str]]:
"""Return [(addr, scope)] for all IPv6 addresses on *interface*.
addr — the IPv6 address without prefix length (e.g. "fe80::1")
scope — the kernel scope label (e.g. "link", "global", "host")
Returns an empty list when the interface has no IPv6 addresses or
when `ip addr show` fails.
"""
try:
result = _run(["ip", "-6", "addr", "show", interface])
except Exception:
return []
out: list[tuple[str, str]] = []
for line in result.stdout.splitlines():
line = line.strip()
if not line.startswith("inet6 "):
continue
# "inet6 fe80::1/64 scope link"
parts = line.split()
addr = parts[1].split("/")[0]
scope = ""
if "scope" in parts:
scope = parts[parts.index("scope") + 1]
out.append((addr, scope))
return out
# ---------------------------------------------------------------------------
# IP allocation
# ---------------------------------------------------------------------------