DEV Community

Yoshi
Yoshi

Posted on

Exploring the World of Packets with Scapy: A Beginner’s Hands-on Journey to Understanding

Playing with Packets

I had a conceptual understanding of how each layer of the network works, but the actual picture was still vague, and my understanding wasn’t progressing much. That’s when I came across a tool called Scapy. With Scapy, you can create packets, observe them, and trace their routes... or so I heard. But I wasn’t sure what I could really learn by observing packets, so I decided to try it myself.

I installed Scapy in a Python virtual environment and experimented with ping → capture → traceroute → ARP scan.

Here, I’ll share the questions I had and the discoveries that made me say, “Oh, I see!”

What is Scapy?

Scapy is a packet manipulation library available in Python.

With it, you can:

  • Create packets
  • Send packets
  • Capture (read) packets
  • Analyze packets

It’s a very handy tool.

Commonly used classes

  • IP() : IP header
  • TCP() : TCP header
  • UDP() : UDP header
  • rdpcap() : read pcap files
  • wrpcap() : write pcap files

Note

If you want to view packets with a GUI, there’s a tool called Wireshark. Both Scapy and Wireshark can read and write packet capture files (pcap).

Wireshark:

  • GUI-based operations
  • Decodes and displays packets in a human-readable format
  • Great for analysis with clicks and filters

Scapy:

  • Python library
  • Can create, analyze, and send packets programmatically
  • Strong for automation and experiments

I once tried looking at packets with Wireshark, but back then I didn’t understand much and gave up—a bitter memory.

1. What is a Packet?

A packet, as the name suggests, is like a small parcel on the network—a small fragment of communication. Large data is divided into small packets and sent across the network.

Here’s a quick recap of the OSI reference model (L2, L3, L4):

  • Ethernet (L2)

    • Address label of a delivery service inside a LAN (MAC address)
    • Decides which device in the same network to deliver to
    • The MAC gets rewritten when passing through a router
  • IP (L3)

    • Address of the Internet world (IP address)
    • Like deciding the destination country or city
    • Reaches faraway places through routing
  • TCP (L4)

    • Rules for reliable communication
    • Establishes a connection (3-way handshake)
    • Numbers data (sequence numbers)
    • Confirms delivery (ACK)
    • Retransmits if something is missing

Ethernet is LAN-local and can only reach up to the next router.

But inside it, the “parcel label” (IP address) always has the same src/dst info, so the packet can keep being forwarded router by router until it reaches the final destination.

Scapy’s coverage looks like this:

  • L2 (Data Link Layer)

    • Create Ethernet frames
    • Specify MAC addresses
  • L3 (Network Layer)

    • Generate IP packets
    • Edit IP headers
  • L4 (Transport Layer)

    • Work with TCP/UDP/ICMP headers
    • Specify port numbers
  • L5 (Session Layer equivalent)

    • Can sometimes create protocols closer to the application layer
    • But not clearly defined as full L5 support

Ethernet (L2), IP (L3), and TCP/UDP/ICMP (L4) are standardized by IETF/IEEE with clearly defined header formats and fields. Scapy can build and parse packets according to those standards.

On the other hand, the session, presentation, and application layers are often application-specific and diverse, even if partially standardized. Scapy supports some of these, but it’s not its main strength. (Honestly, I haven’t fully explored how far it can go yet.)

As explained above, a single packet often shows only a small fragment of communication. Still, even looking at just one packet revealed some surprisingly interesting findings.

summary()

Let’s try capturing packets with Scapy by running test_summary.py.

from scapy.all import sniff

# Capture only 10 packets
packets = sniff(count=10)

# Display a simple summary
packets.summary()
Enter fullscreen mode Exit fullscreen mode

sniff() reads raw packets directly, so on Linux it requires root privileges.

For this test, I gave permission using sudo.

sudo /home/user/sandbox/network/venv/bin/python /home/user/sandbox/network/test_summary.py
Enter fullscreen mode Exit fullscreen mode
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https S
Ether / IP / TCP 203.0.113.4:https > 192.0.2.25:46924 SA
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https A
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https PA / Raw
Ether / IP / TCP 203.0.113.4:https > 192.0.2.25:46924 A / Raw
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https A
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https PA / Raw
Ether / IP / TCP 203.0.113.4:https > 192.0.2.25:46924 PA / Raw
Ether / IP / TCP 203.0.113.4:https > 192.0.2.25:46924 PA / Raw
Ether / IP / TCP 192.0.2.25:46924 > 203.0.113.4:https A
Enter fullscreen mode Exit fullscreen mode

We were able to confirm a summary of the first 10 packets.

This list shows the flow from the start of the connection (handshake) to the actual data exchange in a concise way.

  • S = SYN
  • SA = SYN+ACK
  • A = ACK
  • P = PSH
  • Raw = payload present

The sequence is: S → SA → A (3-way handshake) → then data (PA / Raw, etc.)

  1. 192.0.2.25:46924 > 203.0.113.4:https S

    Client → Server: SYN

  2. 203.0.113.4:https > 192.0.2.25:46924 SA

    Server → Client: SYN+ACK

  3. 192.0.2.25:46924 > 203.0.113.4:https A

    Client → Server: ACK

    → This completes the 3-way handshake

After that, PA / Raw indicates data transfer (Push + ACK + payload).

A packet with only A is just an ACK response.

The 3-way handshake described in textbooks was clearly confirmed here through the capture.

show()

Let’s take a closer look at the first packet we captured earlier.

packets[0].show()
Enter fullscreen mode Exit fullscreen mode
###[ Ethernet ]###
  dst       = 00:11:22:33:44:55
  src       = 66:77:88:99:aa:bb
  type      = IPv4
###[ IP ]###
     version   = 4
     ihl       = 5
     tos       = 0x0
     len       = 60
     id        = 65307
     flags     = DF
     frag      = 0
     ttl       = 64
     proto     = tcp
     chksum    = 0x6ffc
     src       = 192.0.2.25
     dst       = 203.0.113.4
     \options   \
###[ TCP ]###
        sport     = 46924
        dport     = https
        seq       = 2126599486
        ack       = 0
        dataofs   = 10
        reserved  = 0
        flags     = S
        window    = 64860
        chksum    = 0xcbd2
        urgptr    = 0
        options   = [('MSS', 1380), ('SAckOK', b''), ('Timestamp', (3644677925, 0)), ('NOP', None), ('WScale', 7)]
Enter fullscreen mode Exit fullscreen mode

This is the first SYN packet of the TCP 3-way handshake.

Next, the server replies with SYN+ACK, and then the client sends back ACK to complete the connection.

  • Ether: src/dst are the sender/receiver MAC addresses
  • IP: src/dst are the sender/receiver IP addresses, ttl is time-to-live, DF means no fragmentation
  • TCP:
    • sport/dport: source/destination ports (https = 443)
    • seq: initial sequence number of this packet
    • ack: 0 because this is SYN
    • flags: S (SYN)
    • window: receive window size
    • options: MSS (maximum segment size), SAckOK (SACK allowed), Timestamp, WScale (window scaling)

192.0.2.25:46924 → 203.0.113.4:443 represents client → server communication.

Meaning of TCP fields:

  • flags = S → SYN flag is set. The first “Can I connect?” signal.
  • seq = 2126599486 → Initial sequence number chosen by the client. The server uses this as the basis for communication.
  • ack = 0 → No acknowledgment expected yet.
  • sport = 46924 / dport = https(443) → Client chose random source port 46924 → server’s HTTPS port 443.

In short: Ethernet delivers within the LAN, IP delivers across the Internet, and TCP ensures reliability.

All of these are stacked together inside a single packet.

2. Sending a Ping with Scapy

Sometimes you may wonder, “Is the network connection really working?”

In such cases, the familiar Ping comes in handy.

Let’s try doing a Ping using Scapy.

from scapy.all import IP, ICMP, sr1

# 1. Create a packet
pkt = IP(dst="8.8.8.8")/ICMP()

# 2. Check the contents of the packet
print("=== Sent Packet ===")
pkt.show()

# 3. Send it and wait for a reply
ans = sr1(pkt, timeout=2)

# 4. Check the reply packet
if ans:
    print("\n=== Reply Packet ===")
    ans.show()
else:
    print("No reply")
Enter fullscreen mode Exit fullscreen mode

Output:

=== Sent Packet ===
###[ IP ]###
  version   = 4
  ihl       = None
  tos       = 0x0
  len       = None
  id        = 1
  flags     = 
  frag      = 0
  ttl       = 64
  proto     = icmp
  chksum    = None
  src       = 192.0.2.25
  dst       = 8.8.8.8
  \options   \
###[ ICMP ]###
     type      = echo-request
     code      = 0
     chksum    = None
     id        = 0x0
     seq       = 0x0
     unused    = b''

Begin emission
.
Finished sending 1 packets
*
Received 2 packets, got 1 answers, remaining 0 packets

=== Reply Packet ===
###[ IP ]###
  version   = 4
  ihl       = 5
  tos       = 0x0
  len       = 28
  id        = 0
  flags     = 
  frag      = 0
  ttl       = 114
  proto     = icmp
  chksum    = 0x6984
  src       = 8.8.8.8
  dst       = 192.0.2.25
  \options   \
###[ ICMP ]###
     type      = echo-reply
     code      = 0
     chksum    = 0xffff
     id        = 0x0
     seq       = 0x0
     unused    = b''
Enter fullscreen mode Exit fullscreen mode

Explanation

Sent Packet (echo-request)

  • IP Header:
    • src = 192.0.2.25 (my local PC)
    • dst = 8.8.8.8 (Google DNS)
    • proto = icmp
  • ICMP Header:
    • type = echo-request (Ping “please respond”)
    • id / seq = 0x0 (identification number)

Reply Packet (echo-reply)

  • IP Header:
    • src = 8.8.8.8 (response from the other side)
    • dst = 192.0.2.25 (sent back to my PC)
    • ttl = 114 (went through several routers)
  • ICMP Header:
    • type = echo-reply (Ping “reply”)
    • id / seq = 0x0 (same as the request → proof of proper matching)

In the code, the / operator means “stack layers”. Think of wrapping Ethernet/IP/TCP layers together.

Sending an echo-request and receiving an echo-reply felt impressive.

Also, the reply packet showed ttl = 114. I had read about TTL in theory, but seeing that number in practice made me wonder—why exactly 114?

What is TTL?

Time To Live (TTL)

  • A counter that decreases by 1 every time an IP packet passes through a router
  • When it reaches 0, the packet is discarded (to prevent infinite loops)

In this case, ttl = 114:

  • Google’s server (8.8.8.8) likely sent the packet with an initial value of 128
  • When it reached my PC, it was 114
  • → 128 - 114 = passed through 14 routers (hops)

Important Notes

  • TTL itself is not the number of routers
  • Formula: Initial value − Received TTL = Number of routers passed
  • Typical defaults: Windows = 128, Linux/Unix = 64, Cisco = 255

This way, TTL helps us estimate the number of hops a packet has traveled.

3. Tracing the Route with Traceroute

Next, let’s try traceroute (which Scapy can also do) to see the actual path of the packets.

Scapy provides a built-in traceroute() function, so I tested it with Google DNS and Yahoo Japan.

from scapy.all import traceroute
import socket

# Destination
target = "8.8.8.8"

# Try only once (retry=0)
ans, unans = traceroute([target], maxttl=30, dport=80, retry=0, verbose=0)

print(f"=== Traceroute to {target} ===")
for snd, rcv in ans:
    hop = snd.ttl
    ip = rcv.src
    try:
        host = socket.gethostbyaddr(ip)[0]
    except socket.herror:
        host = "?"
    print(f"{hop:2d}  {ip:15s}  {host}")
Enter fullscreen mode Exit fullscreen mode

Output:

=== Traceroute to 8.8.8.8 ===
 1  172.19.32.1      LAPTOP-XXXX.EXAMPLE.net
 2  10.0.0.1         ?
 4  96.xxx.xxx.xxx  router1.isp.example.net
 5  68.xxx.xxx.xxx  router2.isp.example.net
 6  96.xxx.xxx.xxx  router3.isp.example.net
 8  96.xxx.xxx.xxx  router4.isp.example.net
Enter fullscreen mode Exit fullscreen mode
  • 1: 172.19.32.1 → Local LAN gateway (the first router)
  • 2: 10.0.0.1 → Provider-side private IP router (NAT or CMTS, etc.)
  • 4, 5, 6, 8 → Intermediate ISP routers. Addresses like 96.x.x.x and 68.x.x.x show up (likely Comcast).
  • (3, 7) → Routers that didn’t respond, which is common in traceroute.
  • 8.8.8.8 → Final destination, Google Public DNS

Since the last hop shows 8.8.8.8, we can confirm that the route successfully reached Google DNS.

This route represents:

My PC → Local gateway → ISP routers → Internet backbone → Google DNS.

Traceroute works by gradually increasing TTL one by one and collecting the ICMP Time Exceeded messages from each router along the path.

Traceroute to Yahoo Japan

Earlier, the path stayed entirely within the U.S.

But if the destination is set to www.yahoo.co.jp, you can see the route stretching from the U.S. all the way to Japan.

from scapy.all import traceroute
import socket

# Change destination to Yahoo Japan
target = "www.yahoo.co.jp"

# Try only once (retry=0)
ans, unans = traceroute([target], maxttl=30, dport=80, retry=0, verbose=0)

print(f"=== Traceroute to {target} ===")
# Sort by TTL for readability
for snd, rcv in sorted(ans, key=lambda x: x[0].ttl):
    hop = snd.ttl
    ip = rcv.src
    try:
        host = socket.gethostbyaddr(ip)[0]
    except socket.herror:
        host = "?"
    print(f"{hop:2d}  {ip:15s}  {host}")
Enter fullscreen mode Exit fullscreen mode

Output:

 1  172.19.32.1      LAPTOP-EXAMPLE.mshome.net
 2  10.0.0.1         ?
 4  198.51.100.1     router1.isp.example.net
 5  198.51.100.2     router2.isp.example.net
 6  198.51.100.3     router3.isp.example.net
 7  198.51.100.4     router4.isp.example.net
 8  198.51.100.5     router5.isp.example.net
 9  198.51.100.6     router6.isp.example.net
10  198.51.100.7     router7.isp.example.net
11  198.51.100.8     router8.isp.example.net
12  198.51.100.9     router9.isp.example.net
13  198.51.100.10    router10.isp.example.net
14  198.51.100.11    router11.isp.example.net
15  198.51.100.12    router12.isp.example.net
16  198.51.100.13    router13.isp.example.net
17  198.51.100.14    router14.isp.example.net
18  198.51.100.15    router15.isp.example.net
19  198.51.100.16    router16.isp.example.net
20  198.51.100.17    router17.isp.example.net
21  198.51.100.18    router18.isp.example.net
22  198.51.100.19    router19.isp.example.net
23  198.51.100.20    router20.isp.example.net
26 106.187.13.25     ?
27 27.86.xx.xxx      ?
28 27.86.xx.xxx      ?
36 182.22.xx.xxx     ? 
37 182.22.xx.xxx     ?
38 182.22.xx.xxx     ?

(Same IPs continue below…)
(xxx indicates masked digits)
Enter fullscreen mode Exit fullscreen mode

Path Analysis

  • Up to 23: Inside the ISP. (In the actual capture it went Chicago → Denver → San Jose → West Coast.)
  • 26: 106.187.13.25, 27–28: 27.86.*: Very likely where it enters Japan (carrier/IX).
  • From 36 onward: 182.22.xx.xxx: The same device is replying (effects of multiple probes or ICMP limits). Reachability itself was achieved earlier.

whois 182.22.xx.xxx shows:

inetnum:      182.22.0.0 - 182.22.127.255
netname:      YAHOO
descr:        LY Corporation
descr:        1-3 Kioicho, Chiyoda-ku, Tokyo, Japan
country:      JP
Enter fullscreen mode Exit fullscreen mode

The 182.22.xx.xxx addresses that repeatedly appeared at the end of the traceroute belong to the range 182.22.0.0 - 182.22.127.255, which is registered to LY Corporation (formerly Yahoo Japan).

In other words, we can confirm the path reached:

USA → Comcast backbone → submarine cable → Japan (LY network).

4. Code to check ports on your own PC

Note: Scanning ports on external sites can violate terms of service or be considered an attack. It’s safer to run these tests against your own server or a local environment.

from scapy.all import IP, TCP, sr

ip = "127.0.0.1"  # My own PC (localhost)
ports = [22, 53, 80, 443, 3306]  # Ports to test

# Send SYN packets and wait for replies
ans, unans = sr(IP(dst=ip)/TCP(dport=ports, flags="S"), timeout=2, verbose=0)

# Analyze responses
for s, r in ans:
    if r.haslayer(TCP):
        if r[TCP].flags == "SA":
            print(f"Port {s[TCP].dport} is OPEN")
        elif r[TCP].flags == "RA":
            print(f"Port {s[TCP].dport} is CLOSED")
Enter fullscreen mode Exit fullscreen mode

Decision Rules

  • Reply with SYN+ACK → Port is OPEN
  • Reply with RST → Port is CLOSED
  • No response → Blocked by firewall

On my PC, all tested ports were closed.

But after starting a server with python -m http.server 8080 and rescanning, port 8080 showed as OPEN, which made perfect sense.

5. ARP Scan for LAN Discovery

ARP (Address Resolution Protocol) is the mechanism for asking within a LAN:

“Which MAC address has this IP address?”

It works at Layer 2 (Data Link Layer).

To figure out the range of the home LAN (subnet), first check your PC’s current IP address.

In my case (using WSL), running ip addr showed:

eth0172.19.35.58/20.

So the network range is 172.19.32.0/20.

from scapy.all import ARP, Ether, srp
target = "172.19.32.0/20"https://zenn.dev/dashboard
pkt = Ether(dst="ff:ff:ff:ff:ff:ff")/ARP(pdst=target)
ans, _ = srp(pkt, timeout=2, verbose=0)
for _, r in ans:
    print(f"IP: {r.psrc}, MAC: {r.hwsrc}")
Enter fullscreen mode Exit fullscreen mode
=== Devices in LAN === IP: 172.19.32.1, MAC: 00:15:5d:4a:02:13
Enter fullscreen mode Exit fullscreen mode

Only one device showed up. “Huh? There should be more devices on my LAN…”

The reason is WSL: from WSL you can only see the virtual gateway.

WSL’s eth0 (172.19.35.58/20) is actually connected to a Hyper-V virtual switch.

So, from inside WSL it looks like there’s only one peer on the same network—the gateway (172.19.32.1).

WSL’s eth0 is a virtual NIC created by Windows. Devices on the real network beyond it cannot be ARP-scanned from WSL.

On a real Linux/Windows host, arp -a should show the other devices.

Summary

By extracting packets with Scapy, the network became more concrete.

Seeing raw data helped the model click for me: Ethernet = local addressing, IP = global addressing, TCP = reliability rules—each layer with its role.

References

Top comments (0)