Six months ago I dropped Wireshark as my primary debugging tool and switched almost entirely to Scapy. That’s not a knock on Wireshark — it’s still open in another tab right now — but once I saw what Scapy could do that passive capture tools simply can’t, my approach to network troubleshooting changed completely.
Scapy lets you build packets from scratch, send them, capture the responses, and analyze everything in Python. Not just observe — actively construct and inject. The gap between “I can see traffic” and “I can generate any traffic I want” is enormous. Especially when you’re debugging a misbehaving firewall rule, or verifying that a server actually responds correctly to malformed input.
Quick Start — Up and Running in 5 Minutes
Install Scapy inside a virtual environment. Raw socket access requires root or Administrator privileges:
python3 -m venv scapy-env
source scapy-env/bin/activate
pip install scapy
Open the interactive shell to test things fast:
sudo scapy
Send your first ICMP ping and get a response back:
from scapy.all import *
# Build a simple ICMP echo request
packet = IP(dst="8.8.8.8") / ICMP()
response = sr1(packet, timeout=2, verbose=False)
if response:
print(f"Got reply from {response.src}, TTL={response.ttl}")
else:
print("No response")
That’s the entire Scapy mental model in three lines: build layers with /, send with sr1() (send + receive 1), inspect the result. Every protocol stack is just layers stacked on each other.
Packet Layers, Sniffing, and PCAP Files
How Layer Stacking Works
Each protocol in Scapy is a class. Stack them with the / operator, and Scapy fills in sensible defaults for any fields you leave unspecified:
from scapy.all import *
# Inspect what fields a layer has
ls(TCP)
# Build Ethernet + IP + TCP + HTTP-like payload
pkt = Ether() / IP(dst="192.168.1.1") / TCP(dport=80, flags="S") / Raw(load="GET / HTTP/1.0\r\n\r\n")
# Show the full packet breakdown
pkt.show()
Running pkt.show() prints every field — checksum, source MAC, sequence numbers, all of it. That single command saved me hours on a project where packets were getting silently corrupted by a NAT device that wasn’t recalculating checksums after rewriting addresses.
Sniffing Live Traffic
Scapy captures packets too, with full Python access to every field:
from scapy.all import sniff, IP, TCP
def analyze_packet(pkt):
if pkt.haslayer(IP) and pkt.haslayer(TCP):
src = pkt[IP].src
dst = pkt[IP].dst
dport = pkt[TCP].dport
flags = pkt[TCP].flags
print(f"{src} -> {dst}:{dport} [{flags}]")
# Capture 20 TCP packets on eth0
sniff(iface="eth0", filter="tcp", prn=analyze_packet, count=20)
BPF filter syntax matches tcpdump exactly — if you’ve written tcpdump filters before, they work here without changes. The real difference is the callback. Full Python means you can write matched packets to a database, trigger alerts, modify and re-inject, or chain into any other tool your workflow uses.
Reading and Writing PCAP Files
from scapy.all import rdpcap, wrpcap
# Load an existing capture
packets = rdpcap("capture.pcap")
# Inspect specific packets
for pkt in packets:
if pkt.haslayer("DNS"):
print(pkt["DNS"].qd.qname)
# Write filtered packets to a new file
dns_packets = [p for p in packets if p.haslayer("DNS")]
wrpcap("dns_only.pcap", dns_packets)
Advanced Usage — Protocol Testing and Network Simulation
TCP Handshake Testing
One of my most-used scripts tests whether a specific port is open and measures the exact SYN/SYN-ACK round-trip time. Ping tells you the host is reachable. This tells you whether the service is actually responding — and how fast:
from scapy.all import *
import time
def test_tcp_port(host, port):
syn = IP(dst=host) / TCP(dport=port, sport=RandShort(), flags="S", seq=1000)
start = time.time()
response = sr1(syn, timeout=3, verbose=False)
elapsed = (time.time() - start) * 1000
if response is None:
return f"{host}:{port} — no response (filtered)"
tcp_resp = response[TCP]
if tcp_resp.flags == 0x12: # SYN-ACK
return f"{host}:{port} — OPEN, RTT={elapsed:.1f}ms"
elif tcp_resp.flags == 0x14: # RST-ACK
return f"{host}:{port} — CLOSED"
else:
return f"{host}:{port} — unexpected flags: {tcp_resp.flags}"
print(test_tcp_port("192.168.1.1", 22))
print(test_tcp_port("192.168.1.1", 443))
ARP Spoofing for Lab Testing
ARP spoofing is one of the most useful things to reproduce in a controlled lab when testing network segmentation. If you understand exactly what the attack looks like at the packet level, verifying that your switches actually block it becomes straightforward. Run this only in isolated environments where you have explicit authorization:
from scapy.all import Ether, ARP, sendp
import time
def arp_spoof(target_ip, spoof_ip, iface="eth0"):
"""
Send gratuitous ARP to target, claiming we are spoof_ip.
Lab use only — requires authorization.
"""
# Get target's MAC address first
target_mac = getmacbyip(target_ip)
if not target_mac:
print(f"Could not resolve MAC for {target_ip}")
return
# Build the ARP reply
pkt = Ether(dst=target_mac) / ARP(
op=2, # ARP reply
pdst=target_ip,
hwdst=target_mac,
psrc=spoof_ip # Claim to be this IP
)
print(f"Sending spoofed ARP: {spoof_ip} is at our MAC -> {target_ip}")
sendp(pkt, iface=iface, verbose=False)
# Example: test if 192.168.1.2 accepts spoofed ARP from gateway IP
arp_spoof("192.168.1.2", "192.168.1.1")
After sending, check the ARP table on the target with arp -n. If Dynamic ARP Inspection is properly configured on your switch, the packet gets dropped before it arrives. If the target’s ARP table actually changed — you have a gap worth fixing before someone else finds it.
DNS Query Fuzzing
Standard DNS clients give you no control over what goes in the query wire format. Scapy does:
from scapy.all import IP, UDP, DNS, DNSQR, sr1
def dns_query(server, name, qtype="A"):
query = IP(dst=server) / UDP(dport=53) / DNS(
rd=1,
qd=DNSQR(qname=name, qtype=qtype)
)
resp = sr1(query, timeout=3, verbose=False)
if resp and resp.haslayer(DNS):
dns = resp[DNS]
print(f"Response code: {dns.rcode}")
if dns.ancount > 0:
for i in range(dns.ancount):
print(f" Answer: {dns.an[i].rdata}")
else:
print("No DNS response received")
dns_query("8.8.8.8", "example.com")
dns_query("8.8.8.8", "example.com", qtype="MX")
Practical Tips from Six Months of Daily Use
Always Set verbose=False in Scripts
The default output is fine interactively. In automated scripts it floods your logs. Add verbose=False to every send(), sr1(), and srp() call outside the interactive shell.
Use conf.iface for Default Interface
from scapy.all import conf
# Set once at the top of your script
conf.iface = "eth0"
# All subsequent sends will use this interface automatically
Parallel Sending with sendpfast()
For load testing, sendpfast() uses tcpreplay under the hood. It’s significantly faster than looping send() — the difference matters when you’re trying to saturate a link or stress-test a firewall’s packet processing:
from scapy.all import sendpfast, IP, ICMP
packets = [IP(dst=f"192.168.1.{i}") / ICMP() for i in range(1, 255)]
sendpfast(packets, mbps=10, loop=2, iface="eth0")
Filtering with BPF vs Python
BPF filters (filter= argument) run at the kernel level before packets even reach Python. Use them for anything performance-critical. Save Python-side filtering for logic BPF can’t express — payload content matching, stateful analysis across multiple packets, that sort of thing.
Watch Your Checksums
Scapy recalculates checksums automatically when sending fresh packets. But if you’re loading from a PCAP and replaying, delete the checksum fields explicitly to force recalculation:
del pkt[IP].chksum
del pkt[TCP].chksum
# Scapy will recalculate on next access or send
Skipping this cost me an afternoon. I was chasing what looked like a broken firewall — it was correctly dropping packets with invalid checksums from my replay script.
Run Inside tmux or screen
Long captures and flood tests belong in a detached session. A dropped SSH connection mid-test corrupts PCAP files and leaves raw sockets open. I always attach to tmux new -s scapy-test before starting anything that runs longer than a minute.
After six months, the shift in how I approach network problems has been real. When something behaves unexpectedly, I no longer try to trigger the right conditions through application-layer changes and hope the right packets appear. I just build the packet I want and send it. That’s the difference — you stop chasing symptoms and go straight to cause.

