Skip to main content
Networking · lesson 3 of 8
Networking beginner Lesson 3 of 8

Also part of: Defensive Security

Watching the TCP Handshake with tcpdump

Capture a real TCP connection with tcpdump in a Docker lab: SYN, SYN-ACK, ACK, the data, the FIN close, and the RST that makes Connection refused.

By SkillByExample · Updated

networking tcp tcpdump packet-capture docker

The previous lesson showed that a refused connection fails instantly and a dead address makes you wait. This lesson shows the packets behind both. You’ll run tcpdump in a container on the same private network as a small server, connect to it, and read every packet of the conversation. Tested with Docker Engine 29.8.2, Python 3.12.15 (python:3.12-slim), Alpine 3.22.6, tcpdump 4.99.5 and libpcap 1.10.7.

The lab

The server is the echo server from the previous lesson: it reads one message and sends it back in upper case.

# echo_server.py
import socket

srv = socket.create_server(("0.0.0.0", 9000))
print("listening on", srv.getsockname(), flush=True)
while True:
    conn, addr = srv.accept()
    with conn:
        data = conn.recv(1024)
        print("from", addr, "got", data, flush=True)
        conn.sendall(data.upper())

The capture container is Alpine with tcpdump installed. Busybox nc is already in the image, so the same container can be the client too.

FROM alpine:3.22
RUN apk add --no-cache tcpdump
docker build -t tcpdump-lab .
docker network create --internal --subnet 172.29.0.0/24 hs-lab
docker run -d --name server --network hs-lab --ip 172.29.0.10 -v "$PWD":/work -w /work python:3.12-slim python echo_server.py

The network is --internal, so it has no route off your machine. The capture can only ever see this lab.

Capturing needs a capability

Reading raw packets needs the NET_RAW capability. Docker grants NET_RAW to every container by default, so a plain docker run can capture. To see what the capability does, drop it:

$ docker run --rm --network hs-lab --cap-drop NET_RAW tcpdump-lab tcpdump -n -i eth0 -c 1
tcpdump: eth0: You don't have permission to perform this capture on that device
(Attempt to create packet socket failed - CAP_NET_RAW may be required)

The message says exactly what’s missing. The commands below still pass --cap-add NET_RAW. Docker already grants it, so the flag changes nothing here: it makes the requirement explicit, and keeps the command working on a host or runtime that drops it by default. NET_RAW is all a capture needs. tcpdump asks for promiscuous mode through the packet socket itself, so NET_ADMIN isn’t needed, and every capture in this lesson ran without it. Give the capture container that one capability. Don’t reach for --privileged or --network host. Either one would hand the container your machine’s real interfaces, and a lab capture has no business seeing those.

An open port: the handshake

Start tcpdump in the background, listening only for TCP on port 9000. -n keeps addresses numeric and -t drops timestamps. Then send one line from the same container.

docker run -d --name cap --network hs-lab --ip 172.29.0.20 --cap-add NET_RAW tcpdump-lab tcpdump -n -t -i eth0 tcp port 9000
$ docker exec cap sh -c "echo hello | nc -w 1 server 9000"
HELLO
$ docker stop cap
$ docker logs cap
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 172.29.0.20.34211 > 172.29.0.10.9000: Flags [S], seq 1155177314, win 64240, options [mss 1460,sackOK,TS val 859095070 ecr 0,nop,wscale 7], length 0
IP 172.29.0.10.9000 > 172.29.0.20.34211: Flags [S.], seq 3228799188, ack 1155177315, win 65160, options [mss 1460,sackOK,TS val 3517028145 ecr 859095070,nop,wscale 7], length 0
IP 172.29.0.20.34211 > 172.29.0.10.9000: Flags [.], ack 1, win 502, options [nop,nop,TS val 859095070 ecr 3517028145], length 0
IP 172.29.0.20.34211 > 172.29.0.10.9000: Flags [P.], seq 1:7, ack 1, win 502, options [nop,nop,TS val 859095070 ecr 3517028145], length 6
IP 172.29.0.10.9000 > 172.29.0.20.34211: Flags [.], ack 7, win 510, options [nop,nop,TS val 3517028145 ecr 859095070], length 0
IP 172.29.0.20.34211 > 172.29.0.10.9000: Flags [F.], seq 7, ack 1, win 502, options [nop,nop,TS val 859095070 ecr 3517028145], length 0
IP 172.29.0.10.9000 > 172.29.0.20.34211: Flags [P.], seq 1:7, ack 8, win 510, options [nop,nop,TS val 3517028146 ecr 859095070], length 6
IP 172.29.0.20.34211 > 172.29.0.10.9000: Flags [.], ack 7, win 502, options [nop,nop,TS val 859095071 ecr 3517028146], length 0
IP 172.29.0.10.9000 > 172.29.0.20.34211: Flags [F.], seq 7, ack 8, win 510, options [nop,nop,TS val 3517028146 ecr 859095071], length 0
IP 172.29.0.20.34211 > 172.29.0.10.9000: Flags [.], ack 8, win 502, options [nop,nop,TS val 859095071 ecr 3517028146], length 0

10 packets captured
10 packets received by filter
0 packets dropped by kernel

Ten packets to move six bytes each way. Read them in groups. In the flags, S is SYN, F is FIN, P is PUSH, and a trailing . means ACK.

  1. The handshake, lines 1 to 3. The client picks a random starting sequence number, 1155177314, and sends [S]. The server answers [S.] (SYN-ACK) with its own random number, and acknowledges the client’s plus one. The client’s [.] acknowledges that. Only now does connect() return. From here on tcpdump prints sequence numbers relative to those random starts, which is why they drop to 1.
  2. The data, lines 4 and 5. seq 1:7, length 6 is hello\n, bytes 1 to 6. The server’s ack 7 means “I have everything before byte 7”.
  3. The close, lines 6 to 10. Busybox nc hit the end of its input and sent [F.] before the reply arrived. A FIN closes one direction only, so the server could still send HELLO\n (line 7). Then the server’s with conn: block closed its side with its own [F.], and each FIN got its ACK.

The handshake is three packets of setup before a single byte of your data. That’s the cost connection pooling and HTTP keep-alive exist to avoid.

If you script these steps, give tcpdump a second or two before docker stop. Without -l, libpcap hands packets over in batches, and a capture stopped straight after nc can print 0 packets captured even though the filter received all ten. -l switches tcpdump to line-buffered, immediate delivery, which is why the timeout capture in Practice uses it.

A closed port: the reset

Restart the capture on port 9001, where nothing listens:

docker rm cap
docker run -d --name cap --network hs-lab --ip 172.29.0.20 --cap-add NET_RAW tcpdump-lab tcpdump -n -t -i eth0 tcp port 9001
$ docker exec cap nc -w 1 server 9001; echo "exit=$?"
exit=1
$ docker stop cap
$ docker logs cap
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 172.29.0.20.37017 > 172.29.0.10.9001: Flags [S], seq 3609362374, win 64240, options [mss 1460,sackOK,TS val 859100480 ecr 0,nop,wscale 7], length 0
IP 172.29.0.10.9001 > 172.29.0.20.37017: Flags [R.], seq 0, ack 3609362375, win 0, length 0

2 packets captured
2 packets received by filter
0 packets dropped by kernel

Busybox nc says nothing and exits with 1, so the capture is the clearer witness. The SYN went out, and the server’s kernel answered with [R.]: a reset that acknowledges the SYN, with a zero window. That single packet is Connection refused. It proves the host is up and reachable, and that no socket is listening on 9001. A firewall that silently drops the SYN would leave you with only the first line, repeated as the client retries, and the firewall lesson shows exactly that. Clean up with docker rm -f cap server and docker network rm hs-lab.

Practice

  1. Capture a timeout. Previously, connecting to the empty address 172.29.0.99 timed out. Capture everything to or from that host while nc tries, and explain why there’s no TCP in the capture.
Solution
docker run -d --name cap --network hs-lab --ip 172.29.0.20 --cap-add NET_RAW tcpdump-lab tcpdump -l -n -i eth0 host 172.29.0.99
$ docker exec cap nc -w 2 172.29.0.99 9000; echo "exit=$?"
exit=1
$ docker stop cap
$ docker logs cap
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
19:42:26.961552 ARP, Request who-has 172.29.0.99 tell 172.29.0.20, length 28
19:42:26.961566 ARP, Request who-has 172.29.0.99 tell 172.29.0.20, length 28
19:42:28.006554 ARP, Request who-has 172.29.0.99 tell 172.29.0.20, length 28
19:42:28.006589 ARP, Request who-has 172.29.0.99 tell 172.29.0.20, length 28
19:42:29.046465 ARP, Request who-has 172.29.0.99 tell 172.29.0.20, length 28
19:42:29.046494 ARP, Request who-has 172.29.0.99 tell 172.29.0.20, length 28

6 packets captured
6 packets received by filter
0 packets dropped by kernel

The SYN was never sent. 172.29.0.99 is on the local subnet, so the kernel first has to learn its hardware address by asking with ARP, and nobody answers. TCP can’t send a packet to a machine it can’t address. That’s the silence a timeout is made of. -l line-buffers the output so the packets appear in the log as they arrive. This time -t is left off so the timestamps show the timing.

Read the timestamps and there are three requests, one per second, not six. Each one appears twice, microseconds apart, because the capture sees the broadcast going out and then sees the bridge flood it back in. Add -Q out to capture only outgoing packets and there are three lines. Three tries one second apart is the kernel’s default (mcast_solicit=3, retrans_time_ms=1000 under /proc/sys/net/ipv4/neigh/eth0/), and it matches the sockets lesson, where the connect failed with No route to host after about three seconds. Here nc -w 2 gave up at two seconds, just as the third request went out.

  1. See what a capture can read. Capture only the data segments, with -A to print their payload as ASCII, and send password=hunter2 through the echo server.
Solution
docker run -d --name cap --network hs-lab --ip 172.29.0.20 --cap-add NET_RAW tcpdump-lab tcpdump -n -t -i eth0 -A 'tcp port 9000 and tcp[tcpflags] & tcp-push != 0'
$ docker exec cap sh -c "echo password=hunter2 | nc -w 1 server 9000"
PASSWORD=HUNTER2
$ docker stop cap
$ docker logs cap
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 172.29.0.20.45433 > 172.29.0.10.9000: Flags [P.], seq 826852770:826852787, ack 3263791214, win 502, options [nop,nop,TS val 859112616 ecr 3517045691], length 17
E..E..@.@..k.......
.y#(1H.....n....X......
35......password=hunter2

IP 172.29.0.10.9000 > 172.29.0.20.45433: Flags [P.], seq 1:18, ack 18, win 509, options [nop,nop,TS val 3517045692 ecr 859112616], length 17
E..Ew.@.@.j....
....#(.y...n1H......X......
....35..PASSWORD=HUNTER2


2 packets captured
2 packets received by filter
0 packets dropped by kernel

The secret is in the clear, after the IP and TCP headers (the unreadable bytes). Anything that can see the traffic can read it, which is the whole case for TLS. The filter matched only segments with PUSH set, so the handshake isn’t shown. That’s also why the first sequence numbers are absolute: tcpdump never saw the SYN it would measure them from.

  1. Save a capture and filter it later. Write the capture to a pcap file with -w, copy it out of the container, and read it back with a filter that shows only the SYN, FIN and RST packets.
Solution
docker run -d --name cap --network hs-lab --ip 172.29.0.20 --cap-add NET_RAW tcpdump-lab tcpdump -n -i eth0 -w /tmp/handshake.pcap tcp port 9000
$ docker exec cap sh -c "echo hello | nc -w 1 server 9000"
HELLO
$ docker stop cap
$ docker cp cap:/tmp/handshake.pcap .
$ docker run --rm -v "$PWD":/cap tcpdump-lab tcpdump -n -t -r /cap/handshake.pcap "tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0"
reading from file /cap/handshake.pcap, link-type EN10MB (Ethernet), snapshot length 262144
IP 172.29.0.20.34403 > 172.29.0.10.9000: Flags [S], seq 2673380090, win 64240, options [mss 1460,sackOK,TS val 859117635 ecr 0,nop,wscale 7], length 0
IP 172.29.0.10.9000 > 172.29.0.20.34403: Flags [S.], seq 3229262812, ack 2673380091, win 65160, options [mss 1460,sackOK,TS val 3517050710 ecr 859117635,nop,wscale 7], length 0
IP 172.29.0.20.34403 > 172.29.0.10.9000: Flags [F.], seq 7, ack 1, win 502, options [nop,nop,TS val 859117636 ecr 3517050711], length 0
IP 172.29.0.10.9000 > 172.29.0.20.34403: Flags [F.], seq 7, ack 8, win 510, options [nop,nop,TS val 3517050711 ecr 859117636], length 0

Stop the capture before you copy the file. tcpdump buffers -w output and only flushes it on exit, so a copy taken while it runs can be empty. A pcap keeps every byte, so you can filter it later in ways you didn’t think of during the capture, or open it in Wireshark. Treat it like the traffic itself: it contains whatever was sent, passwords included.

Frequently Asked Questions

What is the TCP three-way handshake?
Before any data flows, a TCP client sends a SYN packet with its starting sequence number, the server answers with SYN-ACK carrying its own sequence number and acknowledging the client's, and the client replies with an ACK. In tcpdump these show as Flags [S], Flags [S.] and Flags [.]. Only after the third packet can either side send data.
What do the flags [S], [S.], [P.], [F.] and [R.] mean in tcpdump output?
S is SYN, F is FIN, R is RST and P is PUSH, and a dot means the ACK flag is set. So [S.] is SYN-ACK, [P.] is a data segment that acknowledges what it has received, [F.] is a FIN that closes one direction, and [R.] is a reset. A bare [.] is a plain acknowledgement with no other flag.
Why does tcpdump say You don't have permission to perform this capture?
Capturing packets needs a raw packet socket, which needs the CAP_NET_RAW capability. Run tcpdump as root on a host. Docker grants NET_RAW to containers by default, so this error inside a container means the capability was dropped, for example with --cap-drop NET_RAW or --cap-drop ALL; docker run --cap-add NET_RAW restores it, and on a default container it only makes the requirement explicit. Don't use --privileged or the host network for a lab capture, because that grants far more than the capture needs.
What does a closed port look like in tcpdump?
The client sends a SYN and the server's kernel immediately answers with a reset, shown as Flags [R.] with win 0. That reset is what the client reports as Connection refused. A port behind a firewall that drops packets shows only the client's SYN, sent again and again with no reply, which the client reports as a timeout.
Why are the sequence numbers in tcpdump small after the first packets?
Each side picks a random 32-bit initial sequence number, which tcpdump prints in full on the SYN and SYN-ACK. After that it prints sequence and acknowledgement numbers relative to those starting points, so seq 1:7 means the first six bytes of data. Pass -S to tcpdump to print absolute numbers instead.