~/portfolio/ blog/ scapy-sniffer

Network forensics with a 200-line Scapy sniffer.

Why I wrote a custom packet sniffer instead of leaning on tcpdump — and what structured logging unlocks for downstream correlation in Wazuh.

Why not just use tcpdump?

tcpdump is excellent. The issue is its output format. Raw pcap or line-oriented text doesn't feed cleanly into a JSON-native SIEM like Wazuh + OpenSearch. You can use tshark's JSON mode, but it produces deeply nested objects with Wireshark's verbose field naming — writing decoders for it is painful.

The goal was a sniffer that outputs flat, typed JSON per packet, with exactly the fields Wazuh decoders expect: src_ip, dst_ip, protocol, src_port, dst_port, flags, payload_len. Nothing else. 200 lines of Python is a reasonable price for that precision.

The Scapy sniff loop

Scapy's sniff() function takes a callback that receives each captured packet. The callback extracts fields and writes a JSON line to a log file that Wazuh's log collector watches.

// sniffer.py — core loop
from scapy.all import sniff, IP, TCP, UDP, ICMP import json, time, sys def process_packet(pkt): if not pkt.haslayer(IP): return record = { "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "src_ip": pkt[IP].src, "dst_ip": pkt[IP].dst, "proto": pkt[IP].proto, "pkt_len": len(pkt), } record.update(extract_transport(pkt)) log(record) sniff(iface="eth0", prn=process_packet, store=False)

The store=False flag is important — without it, Scapy buffers every packet in memory and will OOM on a busy interface within minutes.

Protocol parsing (TCP / UDP / ICMP)

// extract_transport()
def extract_transport(pkt): if pkt.haslayer(TCP): flags = pkt[TCP].flags return {"proto_name": "tcp", "sport": pkt[TCP].sport, "dport": pkt[TCP].dport, "flags": str(flags), "syn": bool(flags & 0x02), "rst": bool(flags & 0x04)} elif pkt.haslayer(UDP): return {"proto_name": "udp", "sport": pkt[UDP].sport, "dport": pkt[UDP].dport} elif pkt.haslayer(ICMP): return {"proto_name": "icmp", "icmp_type": pkt[ICMP].type} return {"proto_name": "other"}

Wazuh integration via custom decoder

The sniffer writes NDJSON to /var/log/net_sniffer.json. Wazuh's localfile block watches it. A custom decoder maps the flat JSON fields to Wazuh's internal field names so rules can reference data.src_ip and data.dport directly.

// ossec.conf — log collector
<localfile> <log_format>json</log_format> <location>/var/log/net_sniffer.json</location> </localfile>
The payoff: Once the sniffer feeds Wazuh, you can write rules like "alert if the same source IP hits 5 different destination ports within 30 seconds" — which is exactly how port scans look. tcpdump doesn't give you that correlation without extra tooling.

Limitations to be honest about

  • Encrypted traffic — TLS payloads show up as opaque bytes; you get metadata (src, dst, len) but not content. That's fine for detecting scans and DDoS patterns, not for DLP.
  • High-throughput links — Scapy's Python overhead means you'll drop packets on a 1 Gbps link under heavy load. For production high-throughput capture, use AF_PACKET or DPDK.
  • Privilege — raw socket capture requires root or CAP_NET_RAW. Run the sniffer in its own systemd service with minimal capabilities.
← dlp playbook next: tryhackme series →