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 inspected NSXPCConnection.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 and 127.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:
    1. Scanned tools/list to enforce that 0 mutating/exec tools exist.
    2. Injected a pathological 60-level nested JSON payload to fuzz parser recursion limits.
  • 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:cd:95:64 on en0 ifscope [ethernet]
    [PASS] Default gateway (192.168.64.1 -> d2:c0:50:cd: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)
  1. On the Target Mac, I spun up a dev server:
   python3 -m http.server 8080 --bind 0.0.0.0
  1. RoamSwitch fired a notification immediately:

    🚨 Unknown Listening Port Automatically Blocked

    Detected process “Python” exposing port 8080 to external LAN. Automatically shielded external access.

  2. 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

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.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Caterpillar is bringing to AI deployment what it learned from automating mining

Related Posts