Offensive Security · Lab project

ARP Spoofing & MITM

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.

Scapy ARP Cache Poisoning Layer 2 MITM Packet Sniffing IP Forwarding Wireshark Verification
PlatformKali Linux (VMware) · Python 3 / Scapy
TargetsWindows 11 laptop, Metasploitable2
FormatFrom-scratch build, Black Hat Python Ch.4 method
DifficultyIntermediate

01Topology

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.

ARP spoofing MITM topology: Kali attacker at 192.168.0.85 forges ARP replies to the gateway at 192.168.0.1 and the Windows victim at 192.168.0.32, so real traffic between them routes through Kali instead of directly.
Kali forges ARP replies to both the gateway and the victim, then relays their traffic through itself while sniffing it — the classic on-path attack, entirely at Layer 2.
HostRoleIP
KaliAttacker — runs the sniffer, scanner, and poisoner192.168.0.85
RouterReal gateway, spoofed to the victim192.168.0.1
Windows laptopVictim — ARP cache poisoned to route via Kali192.168.0.32
Metasploitable2Cleartext FTP/HTTP target for the credential capture192.168.0.95

02What I built

Three standalone Scapy tools, each doing one job, chained together into a working attack.

Credential sniffer

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.

Host discovery scanner

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.

ARP cache poisoner

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)

03Why it works

The entire attack rests on one property of ARP, not a bug in any specific implementation.

ARP has no authentication

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.

Forged replies beat real ones by volume

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.

Forwarding keeps the illusion alive

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.

04Verification & results

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 tablearp -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

05Troubleshooting notes

The code was the easy part. Getting a real answer out of a network that refused to cooperate took longer, and taught more.

Linux ICMP redirects silently undid the poison

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.

Wi-Fi client isolation The one that got me

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.

06Real-world relevance

Internal pentest staple

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.

Verify the topology before the code

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.

Defensive takeaway

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.

Open to network & security engineering roles

Site-based or remote, in and around Milton Keynes.