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()
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
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
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.)
192.0.2.25:46924 > 203.0.113.4:https S
Client → Server: SYN203.0.113.4:https > 192.0.2.25:46924 SA
Server → Client: SYN+ACK192.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()
###[ 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)]
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")
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''
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}")
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
- 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}")
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)
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
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")
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:
eth0
→ 172.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}")
=== Devices in LAN === IP: 172.19.32.1, MAC: 00:15:5d:4a:02:13
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.
Top comments (0)