diff --git a/README.md b/README.md index 2d05447..ccc8848 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ The currently working alert types are: * Public IP address changes * Local IP address changes (per interface) * DNS resolver changes + * ARP spoofing/poisoning attacks (gateway & own IP) **File & Process Monitoring:** * New `.env` files created in home directory (via Spotlight) @@ -87,7 +88,6 @@ The currently working alert types are: **Get more alerts like Wifi, VPN, LAN, bluetooth, USB device and other config changes using [HardwareGrowler](https://www.macupdate.com/app/mac/40750/hardwaregrowler) and [MetaGrowler](http://en.freedownloadmanager.org/Mac-OS/MetaGrowler-FREE.html).** TODO: - * ARP resolution alerts tracked via [issues](https://github.com/pirate/security-growler/issues/) * keychain auth events ### Config @@ -117,6 +117,7 @@ Configuration is done through xbar's plugin variables. Click on the plugin in th | `MONITOR_PUBLIC_IP` | `true` | Alert when public IP address changes | | `MONITOR_LOCAL_IP` | `true` | Alert when local IP addresses change | | `MONITOR_MDM` | `true` | Alert on Kandji/MDM management events | +| `MONITOR_ARP_SPOOF` | `true` | Alert on ARP spoofing/poisoning attacks | Default ports monitored: - **21**: FTP @@ -145,6 +146,7 @@ In general, don't assume you're being attacked just because you get an alert, th - DNS changes: check if you changed networks or VPN; unexpected DNS changes could indicate MITM attacks - Public/Local IP changes: normal when switching networks; unexpected changes while stationary could indicate network issues - MDM events: review Kandji/MDM console if you're an admin; unexpected profile installs could indicate compromise + - ARP spoofing: disconnect from network immediately; someone may be intercepting your traffic; check for malicious devices on your network using `arp -a`; consider using static ARP entries for critical hosts or enabling ARP inspection on managed networks You can check for processes listening on a given TCP port (e.g. 80) using `sudo lsof +c 0 -i:80`. You can see active network connections with `sudo netstat -t` or `iftop` (`brew install iftop`). diff --git a/security-growler.30s.py b/security-growler.30s.py index 905be11..a1c7f90 100755 --- a/security-growler.30s.py +++ b/security-growler.30s.py @@ -27,6 +27,7 @@ boolean(MONITOR_PUBLIC_IP=true): Monitor public IP address changes boolean(MONITOR_LOCAL_IP=true): Monitor local IP address changes boolean(MONITOR_MDM=true): Monitor Kandji/MDM events +boolean(MONITOR_ARP_SPOOF=true): Monitor for ARP spoofing attacks """ import os @@ -68,6 +69,7 @@ MONITOR_PUBLIC_IP = os.environ.get("MONITOR_PUBLIC_IP", "true").lower() == "true" MONITOR_LOCAL_IP = os.environ.get("MONITOR_LOCAL_IP", "true").lower() == "true" MONITOR_MDM = os.environ.get("MONITOR_MDM", "true").lower() == "true" +MONITOR_ARP_SPOOF = os.environ.get("MONITOR_ARP_SPOOF", "true").lower() == "true" # Listening port range to monitor LISTENING_PORT_MIN = 21 @@ -1137,6 +1139,194 @@ def parse_mdm_events(state: Dict[str, Any]) -> List[Tuple[str, str, str]]: return events +# ============================================================================= +# ARP Spoofing Detection +# ============================================================================= + +def get_gateway_info() -> Optional[Dict[str, str]]: + """Get the default gateway IP and interface.""" + cmd = "route -n get default 2>/dev/null | grep -E 'gateway:|interface:' | awk '{print $2}'" + + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=5 + ) + + lines = [line.strip() for line in result.stdout.strip().splitlines() if line.strip()] + if len(lines) >= 2: + return { + "gateway_ip": lines[0], + "interface": lines[1] + } + + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + return None + + +def get_mac_address(ip: str) -> Optional[str]: + """Get MAC address for a given IP from ARP table.""" + cmd = f"arp -n {ip} 2>/dev/null | grep -v 'no entry' | grep -v 'incomplete' | tail -1 | awk '{{print $4}}'" + + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=5 + ) + + mac = result.stdout.strip() + # Validate MAC address format (aa:bb:cc:dd:ee:ff) + if mac and ":" in mac and len(mac) >= 14: + return mac.lower() + + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + return None + + +def get_own_ip_and_mac(interface: str = "en0") -> Optional[Dict[str, str]]: + """Get our own IP and MAC address for the given interface.""" + info = {} + + # Get IP address + try: + result = subprocess.run( + ["ipconfig", "getifaddr", interface], + capture_output=True, + text=True, + timeout=2 + ) + ip = result.stdout.strip() + if ip: + info["ip"] = ip + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + return None + + # Get MAC address + cmd = f"ifconfig {interface} 2>/dev/null | grep ether | awk '{{print $2}}'" + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=2 + ) + mac = result.stdout.strip() + if mac: + info["mac"] = mac.lower() + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + return None + + return info if "ip" in info and "mac" in info else None + + +def check_arp_table_for_duplicates(our_ip: str) -> List[str]: + """Check ARP table for multiple MAC addresses claiming our IP.""" + cmd = f"arp -a 2>/dev/null | grep '({our_ip})' | awk '{{print $4}}'" + + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=5 + ) + + macs = [] + for line in result.stdout.strip().splitlines(): + mac = line.strip().lower() + if mac and ":" in mac and len(mac) >= 14: + macs.append(mac) + + return macs + + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + return [] + + +def parse_arp_spoof_events(state: Dict[str, Any]) -> List[Tuple[str, str, str]]: + """Monitor for ARP spoofing attacks.""" + if not MONITOR_ARP_SPOOF: + return [] + + events = [] + + # Get gateway information + gateway_info = get_gateway_info() + if not gateway_info: + # Can't monitor without gateway info + return events + + gateway_ip = gateway_info["gateway_ip"] + interface = gateway_info["interface"] + + # Method 1: Check if gateway MAC has changed (gateway spoofing) + try: + # Ping gateway to ensure fresh ARP entry + subprocess.run( + ["ping", "-c", "1", "-W", "1", gateway_ip], + capture_output=True, + timeout=3 + ) + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + current_gateway_mac = get_mac_address(gateway_ip) + + if current_gateway_mac: + known_gateway_mac = state.get("known_gateway_mac") + known_gateway_ip = state.get("known_gateway_ip") + + # Check if gateway MAC changed for the same IP + if known_gateway_mac and known_gateway_ip == gateway_ip: + if current_gateway_mac != known_gateway_mac: + title = "ARP SPOOF: Gateway MAC Changed" + body = f"Gateway {gateway_ip} MAC changed: {known_gateway_mac} → {current_gateway_mac}" + events.append(("alert", title, body)) + + # Update known gateway + state["known_gateway_mac"] = current_gateway_mac + state["known_gateway_ip"] = gateway_ip + + # Method 2: Check if our own IP is being claimed by another MAC (own IP spoofing) + own_info = get_own_ip_and_mac(interface) + + if own_info: + our_ip = own_info["ip"] + our_mac = own_info["mac"] + + # Check ARP table for duplicates of our IP + arp_macs = check_arp_table_for_duplicates(our_ip) + + # Filter out our own MAC + foreign_macs = [mac for mac in arp_macs if mac != our_mac] + + if foreign_macs: + # Someone else is claiming our IP! + for foreign_mac in foreign_macs: + # Create unique event ID + event_id = f"arp_spoof_own_ip_{our_ip}_{foreign_mac}" + + if event_id not in state["seen_events"]: + title = "ARP SPOOF: Own IP Claimed" + body = f"MAC {foreign_mac} is claiming your IP {our_ip}" + events.append(("alert", title, body)) + state["seen_events"].append(event_id) + + return events + + # ============================================================================= # Main Plugin Logic # ============================================================================= @@ -1166,6 +1356,9 @@ def collect_all_events(state: Dict[str, Any]) -> List[Tuple[str, str, str]]: all_events.extend(parse_public_ip_events(state)) all_events.extend(parse_local_ip_events(state)) + # ARP spoofing detection + all_events.extend(parse_arp_spoof_events(state)) + return all_events @@ -1239,6 +1432,9 @@ def format_xbar_output(state: Dict[str, Any], new_events: List[Tuple[str, str, s monitors.append("Local IPs") if MONITOR_MDM: monitors.append("Kandji/MDM") + if MONITOR_ARP_SPOOF: + gateway_mac = state.get("known_gateway_mac", "?")[:8] if state.get("known_gateway_mac") else "?" + monitors.append(f"ARP Spoofing (GW: {gateway_mac}...)") for m in monitors: print(f"--✓ {m} | color=#228B22 size=12") @@ -1292,6 +1488,7 @@ def format_xbar_output(state: Dict[str, Any], new_events: List[Tuple[str, str, s print(f"----MONITOR_PUBLIC_IP={MONITOR_PUBLIC_IP} | color=#999999 size=11") print(f"----MONITOR_LOCAL_IP={MONITOR_LOCAL_IP} | color=#999999 size=11") print(f"----MONITOR_MDM={MONITOR_MDM} | color=#999999 size=11") + print(f"----MONITOR_ARP_SPOOF={MONITOR_ARP_SPOOF} | color=#999999 size=11") print("-----") print(f"--SHOW_NOTIFICATIONS={SHOW_NOTIFICATIONS} | color=#999999 size=11")