Frontend Labs
⋯

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:

  1. Start a capture in Wireshark on your main interface.
  2. Load a website.
  3. Stop the capture.
  4. Do the five steps above in order.
  5. Find the DNS query, the TCP handshake, the TLS handshake, and the data.
  6. Follow → TCP Stream on 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 acknowledges 299092459 = 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 64 is not 64 bytes. It's the raw header field. Both sides negotiated window scaling in the SYNs (Chapter 24), so the real window is 64 << wscale. Decoding the scale needs the SYN's TCP options, which is exercise 2.
  • ttl 63 on packets from the server: it set TTL 64, and one router (here the Docker bridge's NAT) decremented it on the way.

Exercises:

  1. 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's tcp.analysis.retransmission filter.
  2. Parse the TCP options in SYN packets (kind 2 = MSS, 3 = window scale, 4 = SACK permitted, 8 = timestamps) and print the real window size.
  3. Add a per-connection summary keyed by the 4-tuple: bytes in each direction, duration, handshake RTT. That's Wireshark's Statistics → Conversations view.
  4. Pull the SNI out of the ClientHello (the first payload byte is 0x16 for 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

  1. Name the five things to do first with a new capture.
  2. What does tcp.analysis.zero_window tell you about where the problem is?
  3. What's the difference between a capture filter and a display filter?
  4. 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).

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36