"""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