Chapter 32 — Wireshark: reading a conversation
In one sentence
Wireshark is tcpdump with a screen: it decodes every protocol, reassembles conversations, flags problems automatically, and draws graphs.
When to use which
Use tcpdump when |
Use Wireshark when |
|---|---|
| You're on a server via SSH | You're on a desktop |
| You want a quick look | You want to analyse |
| You're capturing to a file | You're reading a file |
| Scripting/automation | Following a conversation, seeing statistics |
The standard workflow: capture with tcpdump -w on the server, copy the file,
open it in Wireshark on your laptop.
The five things to do first with any capture
1. Statistics → Conversations → TCP tab.
Sort by bytes. This immediately tells you who is talking the most. Half of all
network investigations end here.
2. Statistics → Protocol Hierarchy.
What's actually on this wire, by percentage. Instantly spots "why is 60% of this
DNS?"
3. Right-click a packet → Follow → TCP Stream.
Reassembles the entire conversation into readable text. For HTTP, you see the
whole request and response. This is Wireshark's best feature.
4. Analyze → Expert Information.
Wireshark's automatic problem detector. It lists retransmissions, duplicate ACKs,
zero windows, resets, and malformed packets — categorised by severity. Start
here when investigating a problem.
5. View → Time Display Format → Seconds Since Previous Displayed Packet.
Turns the time column into "how long was the gap?" — which makes delays leap out.
The display filters that find problems
Type these in the filter bar (note: display filters use a different syntax from tcpdump's capture filters — this trips everyone up):
tcp.analysis.retransmission # packets sent again
tcp.analysis.duplicate_ack # receiver saying "I'm still missing something"
tcp.analysis.zero_window # receiver's buffer is FULL (app too slow)
tcp.analysis.lost_segment # a gap Wireshark noticed
tcp.analysis.flags # ALL of the above at once -- start here
tcp.flags.reset == 1 # who reset the connection, and when
http.response.code >= 400 # HTTP errors
dns.flags.rcode != 0 # failed DNS lookups
tcp.stream eq 3 # isolate one conversation
tcp.time_delta > 0.2 # gaps longer than 200 ms
ip.addr == 192.168.1.50 # one host
Reading the fingerprints
| What you see | What it means |
|---|---|
| Many retransmissions + duplicate ACKs | Real packet loss on the path |
| Retransmissions but no loss reported by the network | Possible reordering (check "out-of-order" too) |
ZeroWindow from one side |
That side's application is too slow to read. Not a network problem. |
| A long gap, then a PSH from the server, nothing missing | Server-side application latency — it was thinking |
| SYN, SYN, SYN with no reply | Firewall dropping, or host down |
| Handshake fine, then one big packet retransmitted forever | MTU black hole (Chapter 15) |
| RST immediately after SYN-ACK | Server accepted then aborted — often an overloaded backlog |
| ACKs arriving ~40 ms after data, tiny packets | Nagle × delayed ACK (Chapter 26) |
| Window never grows past ~64 KB on a long link | Window scaling stripped (Chapter 24) |
The I/O Graph (your most powerful view)
Statistics → I/O Graph. Add these as separate lines on the same time axis:
- Throughput (bits/s)
tcp.analysis.retransmission(COUNT)tcp.analysis.bytes_in_flight(AVG) — a proxy for the congestion window
Now you can literally see slow start ramping, the sawtooth on loss, and a timeout (everything stops for 200 ms, then restarts from near zero).
Practice (40 min)
A. Capture and analyse your own web browsing:
- Start a capture in Wireshark on your main interface.
- Load a website.
- Stop the capture.
- Do the five steps above in order.
- Find the DNS query, the TCP handshake, the TLS handshake, and the data.
Follow → TCP Streamon the DNS query and on an HTTP request.
B. Deliberately create problems and see their fingerprints (Linux):
# 1. Packet loss
sudo tc qdisc add dev eth0 root netem loss 5%
# ... capture a download ... then look at Expert Information
sudo tc qdisc del dev eth0 root
# 2. High latency
sudo tc qdisc add dev eth0 root netem delay 200ms
# ... capture ... notice how slowly the window grows
sudo tc qdisc del dev eth0 root
# 3. Both, like a bad mobile connection
sudo tc qdisc add dev eth0 root netem delay 150ms 50ms loss 2%
sudo tc qdisc del dev eth0 root # ALWAYS undo
This exercise is worth an entire textbook chapter. You are creating the exact conditions you'll later have to diagnose, and seeing what they look like.
C. Practise on sample captures. The Wireshark wiki has a large library of example captures (search "Wireshark SampleCaptures"). Open a few and try to work out what happened before reading the description.
Build it in Go (60 min) — decode a capture file yourself
Wireshark's dissectors look like magic until you write one. A .pcap file is
a 24-byte header followed by records, and each record is a 16-byte header plus
the raw bytes of one frame. Decoding a frame means peeling headers off in the
order of Chapter 3: link layer → IP → TCP/UDP. This decoder handles Ethernet
(with VLAN tags), Linux "cooked" captures from -i any (v1 and v2), macOS
loopback, IPv4, IPv6, TCP, UDP, and ICMP, and it flags likely
retransmissions.
// pcapread: decode a tcpdump capture file yourself -- pcap header, Ethernet,
// IPv4/IPv6, TCP, UDP -- and print a tcpdump-like line per packet, plus a
// summary that flags handshakes, resets, and likely retransmissions.
//
// sudo tcpdump -i any -w web.pcap -c 200 'tcp port 443' # Linux "any" = SLL headers
// sudo tcpdump -i en0 -w web.pcap -c 200 'tcp port 443' # Ethernet headers
// go run ./pcapread web.pcap
package main
import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"net/netip"
"os"
"strings"
"time"
)
const (
linkEthernet = 1 // DLT_EN10MB
linkRaw = 101 // DLT_RAW: starts at the IP header
linkSLL = 113 // DLT_LINUX_SLL: tcpdump -i any on older Linux
linkSLL2 = 276 // DLT_LINUX_SLL2: tcpdump -i any on current Linux
linkNull = 0 // DLT_NULL: macOS loopback (lo0)
)
type flowKey struct {
src, dst netip.AddrPort
}
func main() {
if len(os.Args) != 2 {
fmt.Println("usage: pcapread file.pcap")
os.Exit(2)
}
f, err := os.Open(os.Args[1])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer f.Close()
r := bufio.NewReader(f)
// Global header: 24 bytes. The magic number tells us byte order and
// whether timestamps are micro- or nanoseconds.
var gh [24]byte
if _, err := io.ReadFull(r, gh[:]); err != nil {
fmt.Println("not a pcap file:", err)
os.Exit(1)
}
var bo binary.ByteOrder = binary.LittleEndian
nano := false
switch binary.LittleEndian.Uint32(gh[:]) {
case 0xa1b2c3d4:
case 0xa1b23c4d:
nano = true
case 0xd4c3b2a1:
bo = binary.BigEndian
case 0x4d3cb2a1:
bo, nano = binary.BigEndian, true
default:
fmt.Println("unknown magic (pcapng? convert with: editcap -F pcap in.pcapng out.pcap)")
os.Exit(1)
}
link := bo.Uint32(gh[20:])
fmt.Printf("pcap v%d.%d snaplen=%d linktype=%d\n\n", bo.Uint16(gh[4:]), bo.Uint16(gh[6:]), bo.Uint32(gh[16:]), link)
seen := map[flowKey]map[uint32]bool{} // per-direction sequence numbers carrying data
var count, syns, rsts, retrans int
var first time.Time
for {
var rh [16]byte
if _, err := io.ReadFull(r, rh[:]); err != nil {
break // io.EOF: done
}
sec, frac := bo.Uint32(rh[0:]), bo.Uint32(rh[4:])
capLen, origLen := bo.Uint32(rh[8:]), bo.Uint32(rh[12:])
data := make([]byte, capLen)
if _, err := io.ReadFull(r, data); err != nil {
fmt.Println("truncated record")
break
}
ns := int64(frac) * 1000
if nano {
ns = int64(frac)
}
ts := time.Unix(int64(sec), ns)
if first.IsZero() {
first = ts
}
count++
line, info, err := decode(link, data)
if err != nil {
fmt.Printf("%9.6f [%d bytes] %v\n", ts.Sub(first).Seconds(), origLen, err)
continue
}
if info.tcp {
if info.flags&0x02 != 0 {
syns++
}
if info.flags&0x04 != 0 {
rsts++
}
if info.payload > 0 { // same direction, same seq, carrying data again = retransmission
k := flowKey{info.src, info.dst}
if seen[k] == nil {
seen[k] = map[uint32]bool{}
}
if seen[k][info.seq] {
retrans++
line += " <-- RETRANSMISSION?"
}
seen[k][info.seq] = true
}
}
fmt.Printf("%9.6f %s\n", ts.Sub(first).Seconds(), line)
}
fmt.Printf("\n%d packets, %d SYN, %d RST, %d likely retransmissions\n", count, syns, rsts, retrans)
}
type pktInfo struct {
src, dst netip.AddrPort
tcp bool
flags byte
seq uint32
payload int
}
// decode peels the layers off one packet, outermost first.
func decode(link uint32, b []byte) (string, pktInfo, error) {
var etherType uint16
switch link {
case linkEthernet: // dst MAC(6) src MAC(6) EtherType(2)
if len(b) < 14 {
return "", pktInfo{}, errors.New("short ethernet")
}
etherType, b = binary.BigEndian.Uint16(b[12:]), b[14:]
if etherType == 0x8100 && len(b) >= 4 { // 802.1Q VLAN tag
etherType, b = binary.BigEndian.Uint16(b[2:]), b[4:]
}
case linkSLL: // 16-byte "cooked" header, protocol in the last 2 bytes
if len(b) < 16 {
return "", pktInfo{}, errors.New("short SLL")
}
etherType, b = binary.BigEndian.Uint16(b[14:]), b[16:]
case linkSLL2: // 20-byte "cooked v2" header, protocol in the FIRST 2 bytes
if len(b) < 20 {
return "", pktInfo{}, errors.New("short SLL2")
}
etherType, b = binary.BigEndian.Uint16(b[0:]), b[20:]
case linkNull: // 4-byte host-order address family
if len(b) < 4 {
return "", pktInfo{}, errors.New("short null header")
}
etherType, b = 0x0800, b[4:]
if b[0]>>4 == 6 {
etherType = 0x86DD
}
case linkRaw:
etherType = 0x0800
if len(b) > 0 && b[0]>>4 == 6 {
etherType = 0x86DD
}
default:
return "", pktInfo{}, fmt.Errorf("linktype %d not supported", link)
}
var src, dst netip.Addr
var proto byte
var ttl byte
switch etherType {
case 0x0800: // IPv4: header length is in 32-bit words
if len(b) < 20 {
return "", pktInfo{}, errors.New("short IPv4")
}
ihl := int(b[0]&0x0F) * 4
total := int(binary.BigEndian.Uint16(b[2:]))
ttl, proto = b[8], b[9]
src, dst = netip.AddrFrom4([4]byte(b[12:16])), netip.AddrFrom4([4]byte(b[16:20]))
if total < len(b) {
b = b[:total] // drop Ethernet padding
}
b = b[ihl:]
case 0x86DD: // IPv6: fixed 40-byte header
if len(b) < 40 {
return "", pktInfo{}, errors.New("short IPv6")
}
proto, ttl = b[6], b[7]
src, dst = netip.AddrFrom16([16]byte(b[8:24])), netip.AddrFrom16([16]byte(b[24:40]))
b = b[40:]
case 0x0806:
return "ARP", pktInfo{}, nil
default:
return fmt.Sprintf("ethertype 0x%04x", etherType), pktInfo{}, nil
}
switch proto {
case 6: // TCP
if len(b) < 20 {
return "", pktInfo{}, errors.New("short TCP")
}
sp, dp := binary.BigEndian.Uint16(b[0:]), binary.BigEndian.Uint16(b[2:])
seq, ack := binary.BigEndian.Uint32(b[4:]), binary.BigEndian.Uint32(b[8:])
off := int(b[12]>>4) * 4
flags := b[13]
win := binary.BigEndian.Uint16(b[14:])
payload := max(0, len(b)-off)
info := pktInfo{netip.AddrPortFrom(src, sp), netip.AddrPortFrom(dst, dp), true, flags, seq, payload}
return fmt.Sprintf("TCP %s > %s [%s] seq %d ack %d win %d len %d ttl %d",
info.src, info.dst, tcpFlags(flags), seq, ack, win, payload, ttl), info, nil
case 17: // UDP
if len(b) < 8 {
return "", pktInfo{}, errors.New("short UDP")
}
sp, dp := binary.BigEndian.Uint16(b[0:]), binary.BigEndian.Uint16(b[2:])
return fmt.Sprintf("UDP %s > %s len %d", netip.AddrPortFrom(src, sp), netip.AddrPortFrom(dst, dp), len(b)-8), pktInfo{}, nil
case 1, 58:
return fmt.Sprintf("ICMP %s > %s type %d", src, dst, b[0]), pktInfo{}, nil
}
return fmt.Sprintf("IP proto %d %s > %s", proto, src, dst), pktInfo{}, nil
}
// tcpFlags renders flags the way tcpdump does: S=SYN F=FIN R=RST P=PSH .=ACK
func tcpFlags(f byte) string {
var s strings.Builder
for _, x := range []struct {
bit byte
c string
}{{0x02, "S"}, {0x01, "F"}, {0x04, "R"}, {0x08, "P"}, {0x10, "."}} {
if f&x.bit != 0 {
s.WriteString(x.c)
}
}
return s.String()
}
Capture something and decode it (this is a real capture of curl https://example.com
on Linux):
$ sudo tcpdump -U -i any -w web.pcap 'tcp port 443' & curl -s -o /dev/null https://example.com; kill %1
$ go run ./pcapread web.pcap
pcap v2.4 snaplen=262144 linktype=276
0.000000 TCP 172.17.0.2:46276 > 104.20.23.154:443 [S] seq 299092458 ack 0 win 65495 len 0 ttl 64
0.023721 TCP 104.20.23.154:443 > 172.17.0.2:46276 [S.] seq 2742249108 ack 299092459 win 65408 len 0 ttl 63
0.023741 TCP 172.17.0.2:46276 > 104.20.23.154:443 [.] seq 299092459 ack 2742249109 win 64 len 0 ttl 64
0.025817 TCP 172.17.0.2:46276 > 104.20.23.154:443 [P.] seq 299092459 ack 2742249109 win 64 len 1571 ttl 64
0.026065 TCP 104.20.23.154:443 > 172.17.0.2:46276 [.] seq 2742249109 ack 299094030 win 4083 len 0 ttl 63
...
21 packets, 2 SYN, 0 RST, 0 likely retransmissions
Read it the way this chapter taught you:
- Lines 1–3 are the three-way handshake from Chapter 21. Check the
+1: the SYN-ACK acknowledges299092459= the client's ISN + 1. - RTT = 23.7 ms, the gap between the SYN and the SYN-ACK. You just measured latency from a file.
- Line 4 has
len 1571: the TLS ClientHello, which is bigger than you might expect because it carries key shares and extensions. Line 5's ACK number (299094030=299092459 + 1571) confirms the server received exactly that many bytes. win 64is not 64 bytes. It's the raw header field. Both sides negotiated window scaling in the SYNs (Chapter 24), so the real window is64 << wscale. Decoding the scale needs the SYN's TCP options, which is exercise 2.ttl 63on packets from the server: it set TTL 64, and one router (here the Docker bridge's NAT) decremented it on the way.
Exercises:
- Capture a download with loss injected
(
sudo tc qdisc add dev eth0 root netem loss 15%). In testing, the decoder flagged the retransmitted segment:... [P.] seq 1861632025 ... len 9904 ttl 63 <-- RETRANSMISSION?. Check it against Wireshark'stcp.analysis.retransmissionfilter. - Parse the TCP options in SYN packets (kind 2 = MSS, 3 = window scale, 4 = SACK permitted, 8 = timestamps) and print the real window size.
- Add a per-connection summary keyed by the 4-tuple: bytes in each direction, duration, handshake RTT. That's Wireshark's Statistics → Conversations view.
- Pull the SNI out of the ClientHello (the first payload byte is
0x16for a TLS handshake; the SNI is extension type 0). This is how firewalls and L4 proxies route TLS without decrypting it. The Go plan's SNI router (Day 55) builds on exactly this.
Common confusions
- "Capture filter vs display filter?" Capture filters (
tcp port 443) decide what gets recorded — they use tcpdump syntax. Display filters (tcp.port == 443) decide what gets shown — different syntax, much more powerful. Capture broadly, filter narrowly. - "I can't read HTTPS." Correct — it's encrypted. You can see the handshake,
addresses, sizes, and timing, but not content. (You can decrypt your own
browser's traffic by setting
SSLKEYLOGFILE— search for it; a great exercise.) - "Wireshark says checksum errors everywhere." Checksum offload again. Disable the checksum validation in preferences, or ignore it.
Check yourself
- Name the five things to do first with a new capture.
- What does
tcp.analysis.zero_windowtell you about where the problem is? - What's the difference between a capture filter and a display filter?
- Which menu shows you Wireshark's automatic problem detection?
Further reading
- Book (the standard): Practical Packet Analysis — Chris Sanders. Written for beginners, uses real captures, and is genuinely enjoyable. If you buy one book for Part 8, buy this.
- Book (deeper): Wireshark Network Analysis — Laura Chappell. The official certification study guide; encyclopaedic.
- Free: the Wireshark User Guide and the Wireshark Wiki's SampleCaptures page.
- Video: Chris Greer's YouTube channel — packet analysis walkthroughs, very practical. Also the official Wireshark channel (SharkFest talks).
- Blog: Julia Evans, "How I use Wireshark" (jvns.ca).