A Japanese version of this is on Zenn.
My desk has a MacBook and, permanently parked next to it, a ThinkPad running Arch Linux that I use for build chores. I was setting up a smart plug I'd just bought when it occurred to me: this random lump of firmware is on the same network as my Mac.
So I decided to simulate "one cheap IoT device got popped" and see how far I could get attacking the Mac from the ThinkPad. My Mac also runs a defense app I built myself — RoamSwitch — so this doubled as a check on how much of this it would actually catch.
Ground rules: the attacker (ThinkPad) has no special privileges, and there's no malware on the Mac. Just "one sketchy machine on the same LAN."
Port scan: how does the Mac look from outside?
Start with a plain nmap.
sudo nmap -sS -sV -n -T4 --top-ports 200 <mac-ip>
The result was fairly healthy. Ping got nothing, 198 of the top 200 ports came back filtered. SSH, SMB, screen sharing — invisible from outside. Stealth mode was working.
But node (3012) and python (8012), which I'd left running for development, plus macOS's own ControlCenter (5000/7000), came back open. Huh. So I threw a curl at one:
curl -m 10 http://<mac-ip>:3012/
# curl: (56) Recv failure: Connection reset by peer
The TCP handshake completes, but the moment you send data it gets RST'd. macOS's application firewall, even set to "block all incoming," seems to let the kernel complete the handshake for a rando binary like node. Almost no real impact — but the scanner still learns "there's something on 3012." Not fully hidden.
While I was there I looked at socketfilterfw --listapps and found about seven stacked "Block incoming" / "Allow incoming" entries for node. Firewall rules get dirty on their own while you develop.
ARP spoofing: this one just worked
The main event. First, on the Mac, note what MAC it currently thinks the gateway (192.168.1.1) has (MAC values redacted below):
arp -n 192.168.1.1
# ? (192.168.1.1) at xx:xx:xx:xx:xx:xx on en0 ifscope [ethernet] <- the real router
From the ThinkPad, use scapy to send fake ARP replies — "the gateway's MAC is the ThinkPad's" — aimed only at the Mac:
from scapy.all import *
sendp(Ether(dst=VICTIM_MAC)/ARP(op=2, psrc="192.168.1.1",
hwsrc=MY_MAC, pdst=VICTIM_IP, hwdst=VICTIM_MAC),
iface="enp0s25")
Sending every 2 seconds and watching the Mac, the cache flipped in about 3 seconds. The gateway's MAC changed from the router's to the ThinkPad's:
arp -n 192.168.1.1
# ? (192.168.1.1) at yy:yy:yy:yy:yy:yy on en0 <- now the attacker's MAC
Now every packet the Mac sends toward the internet comes to the ThinkPad. Set ip_forward to 1 and relay to the real router, and you've got a transparent man-in-the-middle. Unencrypted traffic is readable, DNS responses can be swapped, TLS downgrade attempts, basically anything.
The thing that made me go "oof": this needed zero exploits. ARP has no concept of authentication, so anyone on the same LAN — a popped IoT device, a guest's laptop — can do it. Enterprise networks kill this with DHCP snooping and Dynamic ARP Inspection; home routers have none of that.
The oops category: things exposed on 0.0.0.0
Less an attack, more an accident simulation. On the Mac:
python3 -m http.server 4444 --bind 0.0.0.0
From the ThinkPad, <mac-ip>:4444 just opens. Forgetting --bind 127.0.0.1 is all it takes for everyone on the LAN to see it.
Worse is an unauthenticated DB. Redis often ships with no auth by default, so exposed with --bind 0.0.0.0 anyone on the LAN can redis-cli in and run KEYS * or CONFIG SET. Docker's -p 6379:6379 by accident is the same thing. MongoDB (27017), Elasticsearch (9200), Memcached (11211), Docker API (2375) are all in the same club.
At home, the firewall is not your shield
What RoamSwitch does is basically "automatically switch your exposure between trusted networks and everything else." On registered home/office networks I want AirDrop and file sharing, so the protection level is loose (that first port scan above was run against this at-home state — connect somewhere unknown and it shifts to maximum lockdown automatically).
The flip side: while you're at home, the firewall mostly isn't acting as a shield. ARP spoofing and ports exposed on 0.0.0.0 both go through because the firewall isn't closed.
So the detection pieces run all the time, decoupled from the protection level — designed to be useful in the place where "close the firewall" isn't the answer (i.e. home). Three parts, none of them fancy:
ARP spoofing is nearly impossible to prevent on a home router. But you can detect it. If the gateway's IP is stable, its MAC should be too — so watch that pair, and if the IP stays put while the MAC changes suddenly, treat it as MITM and cut outbound traffic. RoamSwitch loads a temporary block drop all into pf and isolates the machine entirely (air-gap).
A new listening port — baseline the executables listening on 0.0.0.0, and if an executable that wasn't there before starts listening on all interfaces, block just external access with pf and notify. If it's a dev server you started yourself, one click in the port-audit screen allows it. A backdoor and a dev server have the same shape, so the judgment is "it's new," not a signature.
Unauthenticated DBs — keep a short list ("this port unauthenticated and externally reachable is bad": 6379, 27017, 9200, 11211, 2375…) and warn if one matches while the firewall isn't covering it.
The whole idea is "notice something changed, and react faster than a human." Human reaction time is realistically "never notices," so that bar isn't very high.
Actually attacking it surfaced two holes
This is the part that made the exercise worth it. The logic above had been in the app for a while, but this was the first time I'd fired a real ARP-spoofing attack from a second machine at it — and doing so turned up two sloppy spots.
One. The "network fully cut off" modal was up because it had detected the ARP spoof, but the browser on the Mac opened pages fine. pfctl -s rules showed no block drop all — a different ruleset, the one for blocking listening ports, was loaded instead. The emergency cutoff and the port block were each writing pf rulesets independently, and with both active the port-block side was overwriting the other. I fixed it by funneling all pf access through one place that rebuilds the entire ruleset every time. While I was there, I made it read the rules back with pfctl -sr after applying, and if that failed, the modal now says so honestly: "traffic is not stopped yet — turn off Wi-Fi now."
Two. The listening-port detector's identifier was "executable path : port number," so a process that changes its port on every launch (rapportd, for one) looked like a "new executable" every time and got auto-firewalled. rapportd is what backs Handoff and Universal Clipboard, so that's a problem. I changed the identifier to executable path only, and excluded Apple-signed system daemons from the start.
After the fixes, same test again: ARP spoof detected in about 5 seconds, and this time traffic actually cut (confirmed the Mac's curl timing out). Port 4444 was blocked in about 5 seconds, 127.0.0.1:4444 still alive. Redis on 6379 raised the "unauthenticated database service exposed externally" warning.
"It works on my machine" for a security tool means nothing until you point a second machine at it. That's the lesson. These fixes shipped as 1.4.3.
If you want to try the same thing
If you have a spare machine, a VM, or even one Raspberry Pi on your LAN, fire an nmap from it at your Mac once. And take a look at the gateway's ARP entry.
GW=$(route -n get default | awk '/gateway/{print $2}')
arp -n "$GW" # gateway MAC
lsof -nP -iTCP -sTCP:LISTEN | grep -E '\*:|0\.0\.0\.0:' # listeners exposed externally
Both take a couple of minutes and it feels different from reading about it. If the gateway's IP doesn't change but its MAC moves, that's worth being suspicious about.
I've automated this "watch and react" part in RoamSwitch: on an unregistered network it narrows your exposure, and if the gateway's MAC changes or a new port opens, it reacts faster than you would. What it's doing is exactly the checks in this post — just doing them continuously, instead of you.
Top comments (0)