Hacking My Own Mac App: Penetration Testing macOS Defense Boundaries in a VM
A Japanese version of this is on Zenn. I build and sell a macOS network-security menu bar app called RoamSwitch. In a previous post, I wrote about attacking my own Mac from an Arch Linux box on the same LAN to see how it handled basic reconnaissance and rogue device probes. Since then, as I kept adding features and refactoring, a nagging question kept resurfacing: Are we introducing regressions? Is our privileged helper still watertight? Did a recent update accidentally punch a hole in our packet filter rules? Manually poking at firewalls on every release is tedious and risky-messing with Packet Filter (pf ) and root LaunchDaemons on your primary dev machine is a great way to accidentally drop your own network connection. So, to be absolutely thorough, I set up a repeatable, automated pentest suite inside a disposable macOS virtual machine on a Mac (using Tart) to rigorously probe all 5 defense boundaries from the outside. Here is how the test harness works and what the logs showed when I attacked it. Test Architecture: Host Mac โ Target Guest VM Running destructive firewall tests or killing root helpers on your daily driver is stressful. Instead, I used Tart, a lightweight macOS virtualization tool, to spin up a clean macOS Sonoma guest VM as the Target, with the Host machine acting as the Attacker. +------------------------------------+ +-----------------------------------------+ | Host Mac (Attacker) | | macOS VM (Target Guest) | | - Inbound Port Probing (nc/nmap) | -----> | - RoamSwitch 1.4.8 (Defense Engine) | | - Unauthorized HTTP Probing (curl)| Virtual | - Root Privileged Helper | | - Rogue ARP Spoofing (scapy) | Bridge | - Packet Filter (pf) Ruleset Anchor | +------------------------------------+ +-----------------------------------------+ The 5 Defense Boundaries Tested graph TD A[Automated Defense Suite] --> B[1. XPC Authorization Boundary (ยง3)] A --> C[2. pf Ruleset & Air-Gap Precedence (ยง4, ยง5)] A --> D[3. Port Anomaly & Global Exposure (ยง6)] A --> E[4. MCP Read-Only Invariant & Fuzzing (ยง8)] A --> F[5. ARP Gateway Consistency (ยง11)] 1. Privileged Helper XPC Boundary (ยง3) The privileged helper (com.tetsuharu.RoamSwitch.Helper ) runs as root . If an unapproved local process can send Mach messages to it, that's an instant root privilege escalation. - Test: Dynamically compiled an unsigned (ad-hoc) Swift probe binary lacking my Apple Developer Team ID and attempted calling enableAirGap . - Result: Attempting XPC connection from unauthorized client to /Library/PrivilegedHelperTools/... REJECTED: Helper rejected unauthorized client as expected. [PASS] Privileged helper correctly rejected unauthorized XPC caller The helper's ClientValidator inspectedNSXPCConnection.audit_token and immediately severed the connection. 2. Packet Filter (pf) Ruleset Priority & Air-Gap Containment (ยง4, ยง5) Verifies that when emergency Air-Gap containment is engaged, block drop all strictly takes precedence over any open user port rules (Fail-Closed). - Test: Dumped active pfctl anchor hierarchies and verified loopback (127.0.0.1 ) policy responsiveness while external egress was 100% dropped. - Result: [PASS] pf ruleset anchor structure verified 3. Port Anomaly Guard & Exposure Detection (ยง6) When a developer accidentally starts a local server bound to 0.0.0.0 (all interfaces), RoamSwitch should detect it and shield external access without breaking localhost (127.0.0.1 ). - Test: Spawned 0.0.0.0:18888 and127.0.0.1:18889 simultaneously, querying the diagnostic engine. - Result (MCP Diagnostics Output): { "port": 18888, "processName": "Python", "isGloballyExposed": true, "isFirewallShielded": true, "overallRisk": "warning", "findings": [ { "title": "0.0.0.0 Binding (All LAN Exposure)", "description": "Process is listening on 0.0.0.0 (all interfaces). While shielded by RoamSwitch firewall, consider binding to localhost." } ] } 4. MCP Server Read-Only Invariant & Parser Fuzzing (ยง8) To prevent AI coding assistants (Claude Desktop, Cursor) from being tricked by prompt injection into modifying security settings (Confused Deputy): - Test: - Scanned tools/list to enforce that 0 mutating/exec tools exist. - Injected a pathological 60-level nested JSON payload to fuzz parser recursion limits. - Scanned - Result: [PASS] Read-Only Invariant Confirmed: 0 mutating tools found in MCP catalog [PASS] Parser Robustness Confirmed: Malformed/pathological JSON safely rejected without crash 5. ARP Gateway Monitor & Integrity (ยง11) Validates that the kernel ARP table tracking accurately binds default gateway IP to MAC address. - Result: ? (192.168.64.1) at d2:c0:50๐ฟ95:64 on en0 ifscope [ethernet] [PASS] Default gateway (192.168.64.1 -> d2:c0:50๐ฟ95:64) properly resolved and monitored The Most Realistic Threat: Compromised Smart Home Devices With smart plugs, IP cameras, and cheap IoT gadgets littered across modern home networks, rogue devices scanning the local subnet for lateral movement is no longer a theoretical edge case-it's an everyday reality. Even if you trust your home Wi-Fi, you can't guarantee every random device on that same subnet is clean. So I tested the most common real-world situation: "A compromised smart device on the home network tries to probe and attack the Mac." [Host Mac (Rogue IoT Device)] โโโโโโ Lateral Probe โโโโโโ> [Target Mac VM (Home Wi-Fi)] (192.168.64.1) RoamSwitch Active (192.168.64.2) - On the Target Mac, I spun up a dev server: python3 -m http.server 8080 --bind 0.0.0.0 - RoamSwitch fired a notification immediately: ๐จ Unknown Listening Port Automatically Blocked Detected process "Python" exposing port 8080 to external LAN. Automatically shielded external access. From the Attacker Node (Host), I tried to hit the server: % curl -I --connect-timeout 2 http://192.168.64.2:8080 curl: (28) Failed to connect to 192.168.64.2 port 8080 after 2006 ms: Timeout was reached The Efficacy - From the Outside: The pf firewall dynamically injected an inbound drop rule. The attacking IoT device got a dead timeout-it couldn't even tell a web service was running. - For the Developer: The Mac retained full access via http://localhost:8080 (127.0.0.1 ), allowing normal local development without risk of lateral network intrusion. Reproduce It Yourself: Open Source Audit Suite All scripts used in this audit are published under the open-source support repository: git clone https://github.com/lafine1211/roamswitch-support cd roamswitch-support/audit ./rs-defense-audit.sh all - Live Audit Results Log: audit/RESULTS-DEFENSE-2026-08-30.md - Step-by-Step Guide: audit/README-SECURITY.md What it felt like seeing it work Even though I built the app, there was a genuine rush of satisfaction seeing curl time out cleanly from the outside while localhost kept humming along seamlessly on the inside (along with a sigh of relief that our defense rules didn't regress). In security software, claiming "it's secure" in marketing copy is cheap. Handing developers a runnable script that lets them attack the app and inspect the raw logs is the only honest way to build real trust. If you have a Mac and Tart handy, feel free to clone roamswitch-support and try poking holes in rs-defense-audit.sh yourself. Top comments (0)
Comments
No comments yet. Start the discussion.