The last article read a packet as a single block of bytes. This one opens it up: every packet is several messages nested inside one another, and reading it means unwrapping them in order, outside first.
Analogy: The parcel inside the parcel
A captured packet is a parcel wrapped several times over. The outermost wrapping is the Ethernet frame, addressed between two machines on the same local network. Inside it sits an IP packet, addressed between the true source and destination across the internet. Inside that sits a TCP segment or UDP datagram, which names the ports. And inside that, at last, is the payload: the HTTP request, the DNS query, the actual message.
Unwrapping a TCP packet
parse_tcp_packets.py
# parse_tcp_packets.py
import dpkt
import socket
from open_capture import read_packets
for ts, raw in read_packets("capture.pcap"):
try:
eth = dpkt.ethernet.Ethernet(raw)
if not isinstance(eth.data, dpkt.ip.IP):
continue
ip = eth.data
src_ip = socket.inet_ntoa(ip.src)
dst_ip = socket.inet_ntoa(ip.dst)
if not isinstance(ip.data, dpkt.tcp.TCP):
continue
tcp = ip.data
print(f"{src_ip}:{tcp.sport} -> {dst_ip}:{tcp.dport} "
f"payload={len(tcp.data)} bytes")
except (dpkt.dpkt.NeedData, dpkt.dpkt.UnpackError):
continue
Sample output
10.0.0.9:52100 -> 93.184.216.34:443 payload=517 bytes
10.0.0.9:52100 -> 93.184.216.34:443 payload=1460 bytes
10.0.0.9:52100 -> 93.184.216.34:443 payload=0 bytes
Each line is one packet, unwrapped three layers deep: the source and destination addresses from the IP layer, the ports from the TCP layer, and the payload size. A payload of 0 bytes is usually a pure acknowledgement, a packet carrying control information but no data, which is normal and common.
Two habits that keep the script alive
This capture also contained an ARP frame and one deliberately truncated, unreadable frame mixed in among the TCP traffic, the kind of thing every real capture eventually has. Run against it, parse_tcp_packets.py printed exactly the three lines above and nothing else: the ARP frame and the broken one were silently skipped, not crashed on.
isinstance checks
isinstance(eth.data, dpkt.ip.IP) confirms a frame actually carries an IP packet, and isinstance(ip.data, dpkt.tcp.TCP) confirms that IP packet carries TCP, before either is read. A capture contains other traffic, ARP among it, that would raise an error if forced into the wrong shape.
try/except around the whole loop
A real capture almost always holds a few malformed or truncated packets. A script that stops on the first one is useless against a file with thousands. Catching dpkt.dpkt.NeedData and dpkt.dpkt.UnpackError around the loop body lets the script log or skip the bad packet and keep going.
socket.inet_ntoa() converts the raw packed bytes of an address into the familiar dotted form, the same call you have already seen in earlier units for turning bytes into a readable IP address.
Try it yourself: See the hardening earn its keep
Delete the try/except from parse_tcp_packets.py and run it again against a capture that contains even one ARP frame. It will crash on ip.src, since an ARP packet, held in eth.data, has no such attribute. Put the try/except back, and the same capture runs clean. Seeing the crash once is the fastest way to believe the hardening is not boilerplate.
Check your understanding
Question 1: Using the parcel analogy, name the four layers DPKT unwraps from a captured packet, from outside in, and state what each layer addresses.
Ethernet frame, addressed by MAC address between two machines on the same local network; IP packet, addressed by IP address between the true source and destination; TCP segment (or UDP datagram), which names the source and destination ports; and the payload, the actual message such as an HTTP request.
Question 2: Why does parse_tcp_packets.py check isinstance before reading each layer, and why is the try/except around the loop essential for a real capture?
The isinstance checks confirm a frame genuinely carries an IP packet, and that the IP packet genuinely carries TCP, before the code reads fields that only exist on those types; without the check, non-IP or non-TCP traffic such as ARP would raise an error. The try/except is essential because a real capture almost always contains a few malformed or truncated packets, and without it the whole script would stop at the first one instead of processing the thousands of packets that follow.
Question 3: What does a TCP payload of 0 bytes usually mean, and is it something to worry about?
It usually means the packet is a pure acknowledgement, carrying TCP control information but no application data. It is normal and common, not a sign of anything wrong on its own.
Key takeaway: Key takeaway
A packet is unwrapped from the outside in: Ethernet, then IP, then TCP, then the payload. Guard every layer with an isinstance check before reading it, and wrap the whole loop in a try/except, because a real capture will contain traffic that does not fit the shape you are expecting.

