Add LAN-broadcast lockdown, per-rotation MAC, leak audit panel
Tier-1 paranoid hardening: privacy_lan.py disables LLMNR/NetBIOS/mDNS with reversible snapshot; service rotates MAC on every chain rotation when enabled; leak_audit.py probes every leak surface (IP, DNS, IPv6, WPAD, GPO, ProxySettingsPerUser, LAN broadcast, VPN, WebRTC) and renders pass/fail in Privacy tab. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
291
proxy_chain_manager/privacy_lan.py
Normal file
291
proxy_chain_manager/privacy_lan.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""LAN-scope hostname leak lockdown.
|
||||
|
||||
Windows by default broadcasts the local hostname on every adapter via:
|
||||
- LLMNR (UDP 5355) — link-local multicast name resolution
|
||||
- NetBIOS over TCP/IP — broadcast name registration on the subnet
|
||||
- mDNS (UDP 5353) — Bonjour-style name advertising on newer Windows
|
||||
|
||||
Anyone on the same Wi-Fi / VLAN can grab the hostname and tie it to the MAC
|
||||
address. Killing these is a paranoid-mode no-brainer.
|
||||
|
||||
All three are reversible. We snapshot original state on engage and restore on
|
||||
disengage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import winreg
|
||||
|
||||
from .firewall import is_admin
|
||||
from .win_compat import probe as _win_probe
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_LLMNR_POLICY_PATH = r"SOFTWARE\Policies\Microsoft\Windows NT\DNSClient"
|
||||
_LLMNR_VALUE = "EnableMulticast"
|
||||
_MDNS_DNSCLIENT = r"SYSTEM\CurrentControlSet\Services\Dnscache\Parameters"
|
||||
_MDNS_VALUE = "EnableMDNS"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LanSnapshot:
|
||||
netbios_per_interface: dict[str, int] = field(default_factory=dict)
|
||||
llmnr_present: bool = False
|
||||
llmnr_prev_value: int | None = None
|
||||
mdns_present: bool = False
|
||||
mdns_prev_value: int | None = None
|
||||
|
||||
|
||||
def _run(args: list[str], timeout: float = 12.0) -> tuple[int, str, str]:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
return r.returncode, r.stdout or "", r.stderr or ""
|
||||
except Exception as e:
|
||||
return 1, "", str(e)
|
||||
|
||||
|
||||
def _list_netbios_via_wmic() -> dict[str, int]:
|
||||
"""Per-NIC NetBIOS setting via wmic (works back to Server 2008 R2).
|
||||
|
||||
Returns: { settings_id : tcpip_netbios_options }
|
||||
0 = use DHCP, 1 = enabled, 2 = disabled
|
||||
"""
|
||||
code, out, _ = _run([
|
||||
"wmic", "nicconfig", "where", "IPEnabled=true",
|
||||
"get", "SettingID,TcpipNetbiosOptions", "/format:list",
|
||||
])
|
||||
if code != 0 or not out:
|
||||
return {}
|
||||
settings: dict[str, int] = {}
|
||||
cur_id = ""
|
||||
cur_val: int | None = None
|
||||
for raw in out.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
if cur_id and cur_val is not None:
|
||||
settings[cur_id] = cur_val
|
||||
cur_id, cur_val = "", None
|
||||
continue
|
||||
if line.startswith("SettingID="):
|
||||
cur_id = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("TcpipNetbiosOptions="):
|
||||
try:
|
||||
cur_val = int(line.split("=", 1)[1].strip())
|
||||
except ValueError:
|
||||
cur_val = None
|
||||
if cur_id and cur_val is not None:
|
||||
settings[cur_id] = cur_val
|
||||
return settings
|
||||
|
||||
|
||||
def _set_netbios_via_wmic(settings_id: str, mode: int) -> bool:
|
||||
"""mode: 0=DHCP, 1=enabled, 2=disabled."""
|
||||
code, _, _ = _run([
|
||||
"wmic", "nicconfig", "where",
|
||||
f"SettingID='{settings_id}'",
|
||||
"call", "SetTcpipNetbios", str(mode),
|
||||
])
|
||||
return code == 0
|
||||
|
||||
|
||||
def _llmnr_read() -> tuple[bool, int | None]:
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_QUERY_VALUE
|
||||
) as key:
|
||||
val, _ = winreg.QueryValueEx(key, _LLMNR_VALUE)
|
||||
return True, int(val)
|
||||
except OSError:
|
||||
return False, None
|
||||
|
||||
|
||||
def _llmnr_write(value: int) -> bool:
|
||||
try:
|
||||
with winreg.CreateKeyEx(
|
||||
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_SET_VALUE
|
||||
) as key:
|
||||
winreg.SetValueEx(key, _LLMNR_VALUE, 0, winreg.REG_DWORD, value)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _llmnr_delete() -> None:
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_SET_VALUE
|
||||
) as key:
|
||||
winreg.DeleteValue(key, _LLMNR_VALUE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _mdns_read() -> tuple[bool, int | None]:
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_QUERY_VALUE
|
||||
) as key:
|
||||
val, _ = winreg.QueryValueEx(key, _MDNS_VALUE)
|
||||
return True, int(val)
|
||||
except OSError:
|
||||
return False, None
|
||||
|
||||
|
||||
def _mdns_write(value: int) -> bool:
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_SET_VALUE
|
||||
) as key:
|
||||
winreg.SetValueEx(key, _MDNS_VALUE, 0, winreg.REG_DWORD, value)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _restart_dnscache() -> None:
|
||||
"""Bounce the DNS Client service so the LLMNR/mDNS toggles take effect.
|
||||
|
||||
Server hardening baselines sometimes mark this service "denied" — failure
|
||||
is logged and ignored; the change still applies on next reboot.
|
||||
"""
|
||||
_run(["net", "stop", "Dnscache", "/y"], timeout=20)
|
||||
_run(["net", "start", "Dnscache"], timeout=20)
|
||||
|
||||
|
||||
def lan_status() -> dict[str, str]:
|
||||
"""Human-readable current state. Returned even when not Admin."""
|
||||
nb = _list_netbios_via_wmic()
|
||||
if nb:
|
||||
disabled = sum(1 for v in nb.values() if v == 2)
|
||||
nb_summary = f"{disabled}/{len(nb)} NICs with NetBIOS disabled"
|
||||
else:
|
||||
nb_summary = "unavailable"
|
||||
llmnr_present, llmnr_v = _llmnr_read()
|
||||
if not llmnr_present:
|
||||
llmnr_summary = "default (ON)"
|
||||
else:
|
||||
llmnr_summary = "OFF" if llmnr_v == 0 else f"ON (policy={llmnr_v})"
|
||||
mdns_present, mdns_v = _mdns_read()
|
||||
if not mdns_present:
|
||||
mdns_summary = "default (ON on Win10 1903+)"
|
||||
else:
|
||||
mdns_summary = "OFF" if mdns_v == 0 else f"ON (EnableMDNS={mdns_v})"
|
||||
return {
|
||||
"netbios": nb_summary,
|
||||
"llmnr": llmnr_summary,
|
||||
"mdns": mdns_summary,
|
||||
}
|
||||
|
||||
|
||||
def engage_lan_lockdown() -> tuple[LanSnapshot | None, list[str]]:
|
||||
"""Disable LLMNR / NetBIOS / mDNS. Returns (snapshot for restore, log lines).
|
||||
|
||||
snapshot is None when the operation was refused (not Admin, etc.).
|
||||
"""
|
||||
logs: list[str] = []
|
||||
if not is_admin():
|
||||
return None, ["LAN privacy lockdown skipped — needs Administrator."]
|
||||
|
||||
snap = LanSnapshot()
|
||||
snap.netbios_per_interface = _list_netbios_via_wmic()
|
||||
snap.llmnr_present, snap.llmnr_prev_value = _llmnr_read()
|
||||
snap.mdns_present, snap.mdns_prev_value = _mdns_read()
|
||||
|
||||
if snap.netbios_per_interface:
|
||||
changed = 0
|
||||
for sid in snap.netbios_per_interface:
|
||||
if _set_netbios_via_wmic(sid, 2):
|
||||
changed += 1
|
||||
logs.append(f"NetBIOS over TCP/IP disabled on {changed}/{len(snap.netbios_per_interface)} NICs.")
|
||||
else:
|
||||
logs.append("NetBIOS: could not enumerate adapters (wmic missing?).")
|
||||
|
||||
if _llmnr_write(0):
|
||||
logs.append("LLMNR (UDP 5355) disabled via DNSClient policy.")
|
||||
else:
|
||||
logs.append("LLMNR disable failed (policy write rejected).")
|
||||
|
||||
if _mdns_write(0):
|
||||
logs.append("mDNS (UDP 5353) disabled via Dnscache parameters.")
|
||||
else:
|
||||
logs.append("mDNS disable failed (Dnscache parameters not writable).")
|
||||
|
||||
_restart_dnscache()
|
||||
logs.append("DNS Client service bounced — LAN lockdown active.")
|
||||
return snap, logs
|
||||
|
||||
|
||||
def restore_lan(snap: LanSnapshot | None) -> list[str]:
|
||||
"""Roll back to the snapshot captured by engage_lan_lockdown."""
|
||||
if snap is None or not is_admin():
|
||||
return []
|
||||
logs: list[str] = []
|
||||
|
||||
if snap.netbios_per_interface:
|
||||
restored = 0
|
||||
for sid, prev in snap.netbios_per_interface.items():
|
||||
if _set_netbios_via_wmic(sid, prev):
|
||||
restored += 1
|
||||
logs.append(f"NetBIOS restored on {restored}/{len(snap.netbios_per_interface)} NICs.")
|
||||
|
||||
if snap.llmnr_present and snap.llmnr_prev_value is not None:
|
||||
_llmnr_write(snap.llmnr_prev_value)
|
||||
logs.append(f"LLMNR policy restored to {snap.llmnr_prev_value}.")
|
||||
else:
|
||||
_llmnr_delete()
|
||||
logs.append("LLMNR policy removed (was default).")
|
||||
|
||||
if snap.mdns_present and snap.mdns_prev_value is not None:
|
||||
_mdns_write(snap.mdns_prev_value)
|
||||
logs.append(f"mDNS restored (EnableMDNS={snap.mdns_prev_value}).")
|
||||
else:
|
||||
# If mDNS key was absent originally, delete the value we created.
|
||||
try:
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_SET_VALUE
|
||||
) as key:
|
||||
winreg.DeleteValue(key, _MDNS_VALUE)
|
||||
logs.append("mDNS toggle removed (was default).")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_restart_dnscache()
|
||||
return logs
|
||||
|
||||
|
||||
def snapshot_to_json(snap: LanSnapshot | None) -> str:
|
||||
if snap is None:
|
||||
return ""
|
||||
return json.dumps({
|
||||
"netbios": snap.netbios_per_interface,
|
||||
"llmnr_present": snap.llmnr_present,
|
||||
"llmnr_prev_value": snap.llmnr_prev_value,
|
||||
"mdns_present": snap.mdns_present,
|
||||
"mdns_prev_value": snap.mdns_prev_value,
|
||||
})
|
||||
|
||||
|
||||
def snapshot_from_json(raw: str) -> LanSnapshot | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
return LanSnapshot(
|
||||
netbios_per_interface={k: int(v) for k, v in (d.get("netbios") or {}).items()},
|
||||
llmnr_present=bool(d.get("llmnr_present")),
|
||||
llmnr_prev_value=d.get("llmnr_prev_value"),
|
||||
mdns_present=bool(d.get("mdns_present")),
|
||||
mdns_prev_value=d.get("mdns_prev_value"),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
Reference in New Issue
Block a user