A man-in-the-middle attack chain built from the ground up in Python and Scapy: a credential-sniffing packet capture tool, a host-discovery scanner, and an ARP cache poisoner used together to intercept live traffic between two other hosts on the LAN. Built and verified against a real Windows target and a Metasploitable2 victim — all on my own authorised home lab.
One flat LAN segment, four hosts. The attack box forges its way into the middle of a conversation between two hosts that were never talking to it in the first place.
| Host | Role | IP |
|---|---|---|
| Kali | Attacker — runs the sniffer, scanner, and poisoner | 192.168.0.85 |
| Router | Real gateway, spoofed to the victim | 192.168.0.1 |
| Windows laptop | Victim — ARP cache poisoned to route via Kali | 192.168.0.32 |
| Metasploitable2 | Cleartext FTP/HTTP target for the credential capture | 192.168.0.95 |
Three standalone Scapy tools, each doing one job, chained together into a working attack.
Filters traffic on POP3/IMAP/SMTP ports at the BPF level, then inspects the TCP payload for user/pass strings and prints the source and destination when found.
Broadcasts an ARP "who-has" request across the subnet and collects every reply, rebuilding what a tool like arp-scan does in about twenty lines of Scapy.
Resolves both targets' real MAC addresses first, aborts cleanly if either can't be reached at Layer 2, then continuously sends forged is-at replies to each side while relaying their real traffic and sniffing it — restoring the genuine ARP entries on exit.
The core lie — forging the ARP replies
# Tell the victim: "the gateway is at my MAC"
poison_victim = Ether(dst=victim_mac)/ARP(
op=2, psrc=gateway_ip, pdst=victim_ip,
hwdst=victim_mac, hwsrc=my_mac)
# Tell the gateway: "the victim is at my MAC"
poison_gateway = Ether(dst=gateway_mac)/ARP(
op=2, psrc=victim_ip, pdst=gateway_ip,
hwdst=gateway_mac, hwsrc=my_mac)
# Repeat every 2s for the duration of the attack — ARP entries expire,
# and the real owner keeps re-announcing itself unless drowned out.
while poisoning:
sendp(poison_victim, iface=iface)
sendp(poison_gateway, iface=iface)
time.sleep(2)
The entire attack rests on one property of ARP, not a bug in any specific implementation.
Any host on the segment can claim to own any IP address, and there is no signature or challenge to verify it. Whichever reply arrives is simply trusted and cached.
Sending the lie every two seconds keeps overwriting the entry faster than the real gateway's own occasional re-announcements, so the poisoned mapping wins.
With net.ipv4.ip_forward=1 set on the attack box, the victim's connection keeps working exactly as before — it just happens to pass through an extra, uninvited hop first.
Each stage was confirmed independently — scapy's own output, a second capture tool, and the target's own ARP table — before trusting the next one.
Sniffer verified two ways — credentials sent over a fake POP3 session appeared both in the script's own output and in a parallel Wireshark capture on the same interface.
Host discovery cross-checked against arp-scan — the Scapy scanner returned the same IP/MAC pairs as the standard tool, run back to back on the same subnet.
Poison confirmed on the victim's own ARP table — arp -a on the Windows host showed the gateway's IP mapped to the attacker's MAC, not the router's real one, for the duration of the attack.
750 packets of the victim's live traffic — TLS sessions to external hosts the victim had no reason to route through the attack box — captured and written to disk purely as a side effect of the poisoning.
ARP tables cleanly restored on exit — the poisoner's restore() step sends the genuine mappings back out on Ctrl-C, returning the network to normal rather than leaving it broken.
Victim host — arp -a, while under attack
Interface: 192.168.0.32
Internet Address Physical Address Type
192.168.0.1 00-0c-29-4f-1a-7e dynamic ← attacker's MAC, not the router's
Attacker — poisoner output
[*] Interface eth0 | my MAC 00:0c:29:4f:1a:7e
[+] Victim 192.168.0.32 is-at 0c:8b:fd:2c:05:9a
[+] Gateway 192.168.0.1 is-at c0:a3:6e:7b:31:e0
[*] Poisoning. Ctrl-C to stop and restore.
[*] Sniffing traffic to/from 192.168.0.32 (Ctrl-C to stop)...
^C
[*] Restoring ARP tables...
[*] Done.
[+] Wrote 750 packets to arper.pcap
The code was the easy part. Getting a real answer out of a network that refused to cooperate took longer, and taught more.
Forwarding worked, but the kernel noticed a more direct route existed between two hosts on the same subnet and sent an ICMP Redirect telling the victim to bypass the attacker entirely — defeating the MITM at the IP layer even though ARP poisoning was succeeding. Fixed with net.ipv4.conf.all.send_redirects=0 on the relevant interfaces.
Against a laptop on a consumer Wi-Fi network, the poison never landed — confirmed definitively by checking the victim's own ARP table directly rather than continuing to assume a code or routing bug. The forged replies were leaving the attacker's NIC correctly (verified with tcpdump), but the router's AP/client isolation was dropping Layer 2 traffic between wireless clients before it ever reached the target. No amount of scapy code fixes a router blocking the frames outright. Switching to a network without client isolation resolved it immediately.
ARP spoofing remains a standard technique for intercepting traffic during an internal network engagement, particularly on flat, unsegmented LANs without port security or Dynamic ARP Inspection.
The Wi-Fi isolation issue cost far more time than any bug in the Python itself. Checking Layer 2 reachability directly — rather than assuming the attack tool is broken — is the faster path to a real answer.
Dynamic ARP Inspection, static ARP entries on critical hosts, and 802.1X port authentication all directly close this attack path; on wireless networks, client/AP isolation defeats it by design.
Site-based or remote, in and around Milton Keynes.