If you defend switches, you need to know exactly what an attacker can put on the wire, because "the network trusts what the packet says" is the assumption every Layer 2 attack is built on. Scapy is the fastest way to prove that assumption is wrong: it lets you set every field in a frame by hand and send it, which is why red teams reach for it and why you should understand it before you sign off on a switch config. This article walks the attack and then walks the defence, and the defence is the point. On a lab switch with DHCP snooping and Dynamic ARP Inspection turned on, the same forged ARP that poisons an undefended host gets dropped at the port and the switch log names the attacker's real MAC. That is the moment worth reading for, and it sits at the centre of the infrastructure security controls that stop Layer 2 spoofing.
Everything below was captured on lab equipment built only to be attacked. Nothing here is novel tradecraft. The value is the side-by-side: craft the packet, watch it work against an unprotected segment, then watch a correctly configured switch refuse it. Reproduce the defence and you can trust it in production.
The run was Scapy 2.7.01 on Kali (Python 3), attacking a Debian 13 VM (kernel 6.12) and a Cisco IOS-XE 17.18.2 router and switch inside Cisco Modeling Labs. Full topology is in the "What this was captured on" note near the end.
Craft a packet and put it on the wire
Scapy builds a frame as a stack of layers you divide together with /. There is no template and no default you cannot override. Here is a hand-built ICMP echo carrying a custom payload, inspected before it is ever serialised, then sent with sr1() (send one packet at layer 3, wait for one reply):
from scapy.all import *
conf.iface = "eth1"
pkt = IP(dst="192.168.99.2")/ICMP()/Raw(load=b"PINGLABZ-RES0008")
pkt.summary()
pkt.show()
ans = sr1(pkt, timeout=3)The .show() output is the whole point of Scapy: every field is yours, and the ones left as None (length, checksum) are computed at send time so you do not have to.
! Command: pkt.summary()
IP / ICMP 192.168.99.101 > 192.168.99.2 echo-request 0 / Raw
! Command: pkt.show() (the layered structure Scapy will serialize)
###[ IP ]###
version = 4
ttl = 64
proto = icmp
src = 192.168.99.101
dst = 192.168.99.2
###[ ICMP ]###
type = echo-request
code = 0
###[ Raw ]###
load = b'PINGLABZ-RES0008'
! Command: ans = sr1(pkt, timeout=3) # send layer-3, get one reply
! ans.summary(): IP / ICMP 192.168.99.2 > 192.168.99.101 echo-reply 0 / Raw / PaddingA frame you assembled field by field got a real echo-reply from the router. That is the primitive. Everything below is the same call with fields set to values that are not true. If you want to see these frames arrive from the receiving side rather than the sender's, this is where a capture on the target pays off - our companion walkthrough on reading crafted frames with tcpdump shows the same packets landing on the wire.
Forge the source IP and watch the router log the lie
The source address in an IP header is whatever the sender types. A router forwarding a packet has no way to check it, so Scapy can stamp any source you like. To prove the router accepts the lie, R2 runs an inbound ACL with permit ip any any log, so it records the source of everything that arrives. We send from four addresses that do not exist on the segment:
send(IP(src="10.66.66.66", dst="192.168.99.2")/ICMP()/Raw(b"SPOOFED-BY-SCAPY"), count=3)
send(IP(src="172.16.240.5", dst="192.168.99.2")/ICMP()/Raw(b"SPOOFED-BY-SCAPY"), count=3)
send(IP(src="192.0.2.13", dst="192.168.99.2")/ICMP()/Raw(b"SPOOFED-BY-SCAPY"), count=3)
send(IP(src="198.51.100.7", dst="192.168.99.2")/TCP(dport=22, flags="S"), count=2)The router's own log, filtered to just those forged sources, shows all four accepted and recorded exactly as Scapy stamped them:
R2# show logging | include 10.66.66.66|172.16.240.5|192.0.2.13|198.51.100.7
%SEC-6-IPACCESSLOGP: list SCAN-DETECT permitted tcp 198.51.100.7(20) -> 192.168.99.2(22), 2 packets
%SEC-6-IPACCESSLOGDP: list SCAN-DETECT permitted icmp 192.0.2.13 -> 192.168.99.2 (8/0), 3 packets
%SEC-6-IPACCESSLOGDP: list SCAN-DETECT permitted icmp 10.66.66.66 -> 192.168.99.2 (8/0), 3 packets
%SEC-6-IPACCESSLOGDP: list SCAN-DETECT permitted icmp 172.16.240.5 -> 192.168.99.2 (8/0), 3 packetsNone of those hosts exist. The router logged them anyway, because the source IP is data, not identity. This is the single-screenshot argument for why you cannot make trust decisions on source address alone, and why the controls that drop packets with impossible source addresses (uRPF and anti-spoofing ACLs) exist. The router cannot tell truth from forgery in the header; it can only check the source against where the packet actually arrived from.
ARP cache poisoning, and why the textbook one-liner fails
ARP has no authentication at all, which is what makes it the classic Layer 2 attack. The idea is to send an unsolicited "192.168.99.2 is-at my MAC" reply so the victim sends R2's traffic to the attacker instead. In Scapy that is one line:
m = get_if_hwaddr("eth1") # the attacker's own MAC
arp = ARP(op=2, psrc="192.168.99.2", hwsrc=m, pdst="192.168.99.100")
sendp(Ether(dst="ff:ff:ff:ff:ff:ff")/arp, count=5, iface="eth1")Here is the first surprise, and it is the opposite of what most ARP-spoofing tutorials imply. A single unsolicited reply, fired over a cache entry that is already REACHABLE, did not overwrite it. Modern Linux (this was Debian 13, even with arp_accept=1) refuses to replace a live entry on the strength of one gratuitous reply. The famous one-liner does nothing against a host that is currently talking to the real gateway.
The realistic attack is a continuous flood. With Kali spraying roughly three poison replies a second, and the victim forced to re-resolve as a natural cache expiry would, we sampled the victim's ARP table every two seconds:
! victim (Debian VM), watching the entry for R2:
t+2s: 192.168.99.2 lladdr 00:0c:29:6f:c4:ac STALE <-- attacker's MAC (MITM window)
t+4s: 192.168.99.2 lladdr aa:bb:cc:00:dc:00 STALE <-- real R2 reclaims it
t+6s: 192.168.99.2 lladdr aa:bb:cc:00:dc:00 STALE
...
t+24s: 192.168.99.2 lladdr aa:bb:cc:00:dc:00 STALE
! Samples pointing at the ATTACKER (00:0c:29:6f:c4:ac): 1 of 12
! Samples pointing at the REAL R2 (aa:bb:cc:00:dc:00): 11 of 12The entry flaps. Every time it holds the attacker's MAC the victim's traffic to R2 is delivered to Kali, which is a working man-in-the-middle window; but the real R2 is right there answering too, so it keeps reclaiming the slot. One of twelve samples pointed at the attacker in this run. That is the honest result on a live segment, and it explains why real MITM tools spray many times a second and also actively suppress the real host: winning the race once is easy, holding it is not. Do not trust a demo that shows a clean, permanent takeover from a single packet; on a segment where the real gateway is answering, that is not what happens.
The lesson for a defender is that the fragility of the attack is not your defence. An attacker who sprays fast enough and silences the gateway will hold the window. You need a control that never lets the forged reply reach the victim in the first place. That control lives on the switch.
The defence: Dynamic ARP Inspection drops it cold
This is the whole reason to run the attack. Dynamic ARP Inspection (DAI) is the blue-team answer to everything above, and this article is the attack half of a pair: the defence is documented in full in the Dynamic ARP Inspection and IP Source Guard configuration guide. DAI intercepts every ARP on an untrusted port and checks the sender IP-to-MAC claim against a trusted binding. If the claim does not match, the ARP is dropped before it ever reaches the victim. On the lab switch (an ioll2-xe) we bound the two legitimate hosts in a static ARP ACL and made the router uplinks trusted:
arp access-list PLZ-ARP-ACL
permit ip host 192.168.99.2 mac host aabb.cc00.dc00 ! the real R2 binding
permit ip host 192.168.99.100 mac host 000c.29b1.cc47 ! the VM, so it keeps working
!
interface Ethernet0/1
ip arp inspection trust ! router/uplink ports = trusted
ip arp inspection vlan 1
ip arp inspection filter PLZ-ARP-ACL vlan 1
ip arp inspection validate src-mac ipIn production you would drive those bindings from the DHCP snooping table rather than a static ACL, which is why DHCP snooping is DAI's prerequisite - the DHCP snooping and DAI lab walks that pairing end to end. With DAI live, we fired the exact same forged ARP flood from Kali (20 replies this time) and read the switch. The statistics counter tells the story in two columns:
SW1# show ip arp inspection statistics vlan 1
Vlan Forwarded Dropped DHCP Drops ACL Drops
---- --------- ------- ---------- ---------
1 0 44 44 0Zero forged ARPs forwarded. Forty-four dropped. And DAI did not just drop the packets silently - it logged them, with the attacker's real MAC:
SW1# show logging | include SW_DAI
%SW_DAI-4-DHCP_SNOOPING_DENY: 5 Invalid ARPs (Res) on Et0/0, vlan 1.
([000c.296f.c4ac/192.168.99.2/0000.0000.0000/192.168.99.100/...])Read that log line the way the switch did. Sender MAC 000c.296f.c4ac (Kali) claimed to be sender IP 192.168.99.2 (R2), addressed to 192.168.99.100 (the VM). DAI compared the claimed pairing 192.168.99.2 -> 000c.296f.c4ac against the ACL, which binds 192.168.99.2 -> aabb.cc00.dc00, saw the mismatch, dropped the frame, and recorded the attacker's true hardware address in the process. That is the exact packet Scapy built, defeated at the port and attributed to its source.
Two details make this a clean win rather than a blunt instrument. First, the interface trust state is what makes it surgical:
SW1# show ip arp inspection interfaces
Interface Trust State Rate (pps) Burst Interval
Et0/0 Untrusted 15 1
Et0/1 Trusted None N/A
Et0/2 Trusted None N/A
Et0/3 Trusted None N/ASecond, and this is the part that separates a good control from a self-inflicted outage: the legitimate host never noticed. Because the VM's own binding was in the ACL, its ARP passed inspection throughout. During the same attack window, the VM flushed its cache, re-resolved, and pinged R2 with zero loss, landing on the real R2 MAC. DAI dropped only the forgery and left the honest traffic alone. A defence that also breaks the users it protects does not survive contact with a change board; this one does not.
What this was captured on
Everything shown here is a controlled lab. Running these techniques against a network you do not own is illegal, and running the ARP flood against a production segment will cause an outage whether or not it "works". Build the topology, break it, defend it, and keep it inside the lab.
Common mistakes and gotchas
Expecting the one-shot poison to stick. A single gratuitous ARP does not overwrite a live REACHABLE entry on modern Linux, even with arp_accept=1. Tutorials that show a clean takeover from one packet are testing against an empty or expired cache. Against a host actively talking to the gateway, you need a sustained flood, and even then you only win intermittently.
Reading the flood result as a permanent takeover. Our honest sample was one in twelve pointing at the attacker. The real gateway keeps reclaiming its slot, so the man-in-the-middle window flickers. If your defensive testing shows the entry flapping rather than pinning, that is correct behaviour, not a failed capture.
Forgetting the trusted-port config on DAI. If you enable DAI on a VLAN and leave the router uplinks untrusted, the switch will start dropping the router's own legitimate ARP and you will take down the segment you meant to protect. Uplinks and known infrastructure ports must be ip arp inspection trust.
Leaving legitimate hosts out of the binding source. DAI drops anything it cannot validate. Static ARP ACLs are fine for a couple of servers, but for user ports you want DHCP snooping populating the bindings automatically, or every host that renews a lease will get its ARP dropped.
Shared-bridge artifacts in a lab. In our capture, a few DAI drop lines named MACs from a separate 802.1X lab whose ARP crossed the shared external bridge. That is a lab-only side effect of bridging multiple topologies onto one segment; on a production switch the shared bridge does not exist. Do not mistake cross-lab noise for a DAI error.
Treating a MAC as identity. The same spoofability that makes ARP poisoning possible is why MAC-based controls are weak. A forged source MAC is trivial for Scapy, which is the same reason a spoofable Layer 2 identity undermines MAC-based access schemes and why Layer 2 attacks like VLAN hopping deserve the same switch-side hardening.
Key takeaways
- Scapy lets you set every field in a frame, so any header value the network trusts (source IP, ARP sender, MAC) can be forged in one line. Treat headers as claims, not facts.
- A router logs a spoofed source IP verbatim because it cannot verify it. Source address is not identity; uRPF and anti-spoofing ACLs are the answer.
- The textbook one-shot ARP poison fails against a live cache on modern Linux. A sustained flood wins the race only intermittently while the real gateway keeps answering.
- Dynamic ARP Inspection dropped 44 of 44 forged ARPs, forwarded zero, and logged the attacker's true MAC. It is the clean, attributable win against ARP spoofing.
- A correctly scoped DAI deployment is surgical: the legitimate host kept pinging with zero loss because its binding was trusted. Trusted uplinks and a real binding source are what keep it from becoming an outage.
The attack half of this is old news; the defence is what you take to work. Craft the packet so you understand the claim the network is being asked to trust, then put a control at the switch that checks the claim. If you are hardening a switching layer, treat DAI and IP Source Guard as baseline rather than optional, and fold packet captures into the routine so you can see the forgery arrive and confirm it was dropped - the packet analysis workflow makes that a repeatable check. For the wider set of switch-side controls that turn "the network trusts what the packet says" from a liability into a logged, dropped event, work through the rest of the infrastructure hardening series.