Networking · lesson 3 of 8
Beginner
4Intermediate
4Also part of Networking
11- + Linux Networking: Routes, Interfaces, and Bind Addresses from Linux
- + Bash Networking from Bash
- + Docker Networking from Docker
- + FastAPI HTTP Methods and Status Codes from FastAPI
- + FastAPI Headers and Cookies from FastAPI
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + VPC and Networking Basics from AWS
- + GCP VPC Networking Basics from Google Cloud
- + Azure Virtual Network Basics from Azure
- + The TLS Handshake, Run Locally with openssl s_server from Cryptography
- + Load Balancing: Round Robin vs Least Connections from System Design
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
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.
- 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 doesconnect()return. From here on tcpdump prints sequence numbers relative to those random starts, which is why they drop to1. - The data, lines 4 and 5.
seq 1:7, length 6ishello\n, bytes 1 to 6. The server’sack 7means “I have everything before byte 7”. - The close, lines 6 to 10. Busybox
nchit the end of its input and sent[F.]before the reply arrived. A FIN closes one direction only, so the server could still sendHELLO\n(line 7). Then the server’swith 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
- Capture a timeout. Previously, connecting to the empty address
172.29.0.99timed out. Capture everything to or from that host whilenctries, 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.
- See what a capture can read. Capture only the data segments, with
-Ato print their payload as ASCII, and sendpassword=hunter2through 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.
- 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.