逐字节拆解 tcpdump:从恐惧到理解
写在前面
很多人对 tcpdump 有恐惧心理------满屏十六进制和无结构文本,本能觉得"我读不懂"。但 tcpdump 本质上只是一个安静的监听器,不拦截、不修改、不丢包,纯粹旁路观察。
恐惧的根源不是工具难,是输出太"原始"。解决方法不是背命令,是逐字节拆开看。
下面是一次完整 HTTP 请求的 10 行 tcpdump 输出,从三次握手到数据传输到连接关闭,每一行、每一个字段全部拆解。
实验环境
arduino
本机:192.168.31.218(无线网卡 wlp3s0)
目标:httpbin.org → 44.216.249.42:80
命令:curl http://httpbin.org/get
抓包命令
bash
sudo tcpdump -i wlp3s0 -c 10 -nn 'tcp[tcpflags] & (tcp-syn|tcp-ack) != 0'
-i wlp3s0:监听无线网卡-c 10:只抓 10 个包-nn:不反解 IP 和端口(永远加这个选项,反解会拖慢输出且容易误导)- BPF 过滤器:只看带 SYN 或 ACK 标志的包,排除 SSH 流量
完整输出
yaml
13:58:04.102727 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [S], seq 3629117031, win 64240, options [mss 1460,sackOK,TS val 2761634164 ecr 0,nop,wscale 7], length 0
13:58:04.342796 IP 44.216.249.42.80 > 192.168.31.218.58846: Flags [S.], seq 1688432090, ack 3629117032, win 26883, options [mss 1402,nop,nop,sackOK,nop,wscale 8], length 0
13:58:04.342883 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [.], ack 1, win 502, options [nop,nop,TS val 2761634408 ecr 1688432022], length 0
13:58:04.343049 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [P.], seq 1:79, ack 1, win 502, options [nop,nop,TS val 2761634409 ecr 1688432022], length 78: HTTP: GET /get HTTP/1.1
13:58:04.612405 IP 44.216.249.42.80 > 192.168.31.218.58846: Flags [.], ack 79, win 106, length 0
13:58:04.612406 IP 44.216.249.42.80 > 192.168.31.218.58846: Flags [P.], seq 1:231, ack 79, win 106, length 230: HTTP: HTTP/1.1 200 OK
13:58:04.612408 IP 44.216.249.42.80 > 192.168.31.218.58846: Flags [P.], seq 231:486, ack 79, win 106, length 255: HTTP
13:58:04.612500 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [.], ack 231, win 501, length 0
13:58:04.612536 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [.], ack 486, win 501, length 0
13:58:04.612917 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [F.], seq 79, ack 486, win 501, length 0
输出结构速查
每一行的通用结构:
bash
时间戳 协议 源IP.源端口 > 目标IP.目标端口: Flags [标志], seq, ack, win, options, length
| 字段 | 含义 |
|---|---|
| 时间戳 | 精确到微秒,内核记录 |
| 协议 | IP(IPv4)或 IP6(IPv6) |
| 源 > 目标 | 数据包的流向 |
| Flags | TCP 标志位:S=SYN, .=ACK, P=PSH, F=FIN, R=RST |
| seq | 序列号(tcpdump 默认显示相对值) |
| ack | 确认号(确认对方已收到的数据) |
| win | 接收窗口大小 |
| options | TCP 选项(握手时协商参数) |
| length | TCP 载荷长度 = 应用层数据量,不含任何头部 |
第 1 行:SYN------连接请求
ini
13:58:04.102727 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [S], seq 3629117031, win 64240, options [mss 1460,sackOK,TS val 2761634164 ecr 0,nop,wscale 7], length 0
本机向 httpbin.org 发起连接。58846 是内核随机分配的临时端口,80 是 HTTP 默认端口。
Flags [S] = SYN,"我想建立连接"。这是三次握手的第一步。
seq 3629117031 = 初始序列号(ISN),由内核随机生成,不从 0 或 1 开始------这是安全设计,防止攻击者猜到序列号伪造包。tcpdump 会把这个值记为基准 0,后续所有 seq/ack 都减去基准来显示相对值。
win 64240 = 接收窗口,"我的缓冲区能容纳 64240 字节"。
options 里是连接参数的"合同",只在 SYN 包里协商,一旦建立连接就不能改:
| 选项 | 含义 |
|---|---|
mss 1460 |
单个 TCP 段最大载荷 1460 字节(以太网 MTU 1500 - 20 IP头 - 20 TCP头) |
sackOK |
支持选择性确认,丢了只重传丢失的段,不用全部重传 |
TS val 2761634164 ecr 0 |
时间戳,ecr=0 因为是第一个包,还没收到过对方的时间戳 |
nop |
填充字节,让后面的选项对齐到 4 字节边界 |
wscale 7 |
窗口缩放因子,实际窗口 = win × 2^7 = 约 8.2MB |
length 0 = SYN 不带应用数据,纯粹是握手信号。
第 2 行:SYN-ACK------服务器回应
ini
13:58:04.342796 IP 44.216.249.42.80 > 192.168.31.218.58846: Flags [S.], seq 1688432090, ack 3629117032, win 26883, options [mss 1402,nop,nop,sackOK,nop,wscale 8], length 0
方向反转:服务器 → 你。和时间戳差 240ms,这就是这段链路的 RTT。
Flags [S.] = SYN + ACK 同时置位。一个包完成两件事:确认你的 SYN + 发起自己方向的连接。这就是为什么三次握手能少一步------SYN 和 ACK 合并成了一个包。
seq 1688432090 = 服务器的 ISN,和你客户端的 ISN 没有任何数学关系,独立随机生成。TCP 是全双工的,两个方向各有独立的序列号空间。
ack 3629117032 = 你的 ISN + 1。SYN 占 1 个序列号(即使 length 0),所以确认号 = 收到的 seq + 1。
mss 1402 比你的 1460 小 58 字节------AWS 内部用了 VXLAN 封装,多了几十字节外层头。实际传输取双方 MSS 的较小值,所以这个连接的单段上限是 1402。
wscale 8 比你的 7 大,双方可以不同,各自声明各自的。实际窗口 = 26883 × 2^8 ≈ 6.9MB。
第 3 行:ACK------握手完成
ini
13:58:04.342883 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [.], ack 1, win 502, options [nop,nop,TS val 2761634408 ecr 1688432022], length 0
和第 2 行相差 87 微秒------内核网络栈几乎瞬间完成处理。
Flags [.] = 纯 ACK。三次握手到此完成,连接建立。
ack 1 是相对值。tcpdump 为两个方向各维护一个基准:seq 跟自己方向的 ISN 比,ack 跟对方方向的 ISN 比。所以这里 ack 1 = 服务器 ISN + 1 的相对表示。
win 502 看起来比第 1 行的 64240 小很多,但这是缩放后的值:502 × 2^7 = 64256,和 64240 接近。握手完成后 wscale 生效,tcpdump 已经帮你做了缩放。
第三次握手的 ACK 其实可以带数据------RFC 793 明确允许。如果 curl 提前准备好了请求,完全可以 ACK + PSH + 数据合并成一个包,省掉一次网络传输。只是通常应用层来不及那么快,所以先发纯 ACK。
第 4 行:HTTP 请求发出
bash
13:58:04.343049 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [P.], seq 1:79, ack 1, win 502, options [nop,nop,TS val 2761634409 ecr 1688432022], length 78: HTTP: GET /get HTTP/1.1
握手完成 166 微秒后,应用层就把 HTTP 请求塞进去了。
Flags [P.] = PSH + ACK。PSH 不是"我有数据"的意思(有数据自然会有 length),而是告诉接收方"数据到了,别攒着,立刻交给应用层"。
seq 1:79 是冒号表示法,左闭右开区间:字节 1 到 78,共 78 字节。和 length 78 吻合。这种表示法一眼就能看出这个包带了多少数据------排障时看 seq 和 ack 的差值就能判断数据有没有完整到达对端。
ack 1 和第 3 行一样------服务器还没发新数据,ack 不变。ack 只追踪对方发了多少数据,跟自己是第几个包无关。
HTTP: GET /get HTTP/1.1 是 tcpdump 做的应用层解码,自动识别常见协议。78 字节不只是请求行,还包含所有 HTTP 头(Host、User-Agent 等)。
第 5 行:服务器确认收到请求
ini
13:58:04.612405 IP 44.216.249.42.80 > 192.168.31.218.58846: Flags [.], ack 79, win 106, length 0
和第 4 行相差 269ms,接近 RTT。
ack 79 = 1 + 78,确认你的 78 字节请求全部收到。ack 的跳变是排障的"进度条"------ack 没变说明对方没收到新数据,ack 跳到预期值说明数据全部到达。
没有 options,纯 ACK 包省略了时间戳选项以减少开销。
第 6 行:HTTP 响应头
yaml
13:58:04.612406 IP 44.216.249.42.80 > 192.168.31.218.58846: Flags [P.], seq 1:231, ack 79, win 106, length 230: HTTP: HTTP/1.1 200 OK
和第 5 行相差 1 微秒。ACK 和响应数据几乎同时发出------内核先处理了 ACK,然后 HTTP 进程把响应塞进发送缓冲区,内核立刻再发一个 PSH 包。
seq 1:231 = 230 字节,这是服务器方向的第一批数据。230 字节包含完整的 HTTP 状态行 + 所有响应头(Content-Type、Content-Length、Date 等),直到 \r\n\r\n 头部结束。
第 7 行:HTTP 响应体
yaml
13:58:04.612408 IP 44.216.249.42.80 > 192.168.31.218.58846: Flags [P.], seq 231:486, ack 79, win 106, length 255: HTTP
和第 6 行相差 2 微秒。三个包(ACK + 响应头 + 响应体)在 3 微秒内连续发出。
seq 231:486 紧接第 6 行的 1:231,字节流连续不断。TCP 不关心 HTTP 的"消息边界",它只管按字节流传输。一个 HTTP 响应被拆成两个段是内核发送缓冲区和 MSS 的关系,TCP 完全不关心拆成几段,只要字节序号连续就行。
HTTP 没有显示状态行,因为 tcpdump 只在每个 HTTP 消息的起始段显示请求行或状态行,后续段只标记协议名。这 255 字节是响应体的 JSON 数据。
第 6 行 + 第 7 行 = 230 + 255 = 485 字节,这才是完整的 HTTP 响应。
第 8 行:确认响应头
ini
13:58:04.612500 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [.], ack 231, win 501, length 0
ack 231 = 1 + 230,确认响应头的 230 字节。
为什么没一起确认第 7 行的 255 字节(ack 486)?因为第 7 行的包还在路上。时间线:
bash
服务器发出 seq 1:231 ──→ 你收到,回 ack 231 ← 第8行
服务器发出 seq 231:486 ──→ 还在路上
win 501 比第 4 行的 502 小了 1(实际窗口少了 128 字节),缓冲区被还没读走的数据占了少量空间。
第 9 行:确认响应体
ini
13:58:04.612536 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [.], ack 486, win 501, length 0
ack 486 = 231 + 255,确认响应体的 255 字节。整个 485 字节 HTTP 响应全部确认完毕。
到这里出现了一个问题:第 8 行和第 9 行是连续两个纯 ACK,能不能合并成一个 ack 486?
理论上可以,这就是延迟 ACK 机制------Linux 默认每收到 2 个包才回一个 ACK。但这里没合并,因为第 6 行带 PSH 标志,内核看到 PSH 会立即回 ACK 不等延迟定时器。所以 PSH 不仅影响接收端交付时机,也影响 ACK 发送时机。
第 10 行:FIN------关闭连接
bash
13:58:04.612917 IP 192.168.31.218.58846 > 44.216.249.42.80: Flags [F.], seq 79, ack 486, win 501, length 0
curl 读完响应后关闭连接。Flags [F.] = FIN + ACK。
FIN 和 SYN 一样占用一个序列号,即使 length 0。seq 79 说明自第 4 行发了 78 字节请求后再没发过数据。
这里只抓到 10 个包就停了,完整的四次挥手后续没抓到。但四次挥手不是死的------核心原则是 ACK 不能等,FIN 可以等:收到 FIN 后必须先回 ACK(对方在等确认),FIN 看时机------如果自己也没数据了,ACK+FIN 合并成一个包,就是三次挥手。几次取决于时序,不是固定模板。
全流程一图流
ini
你 (192.168.31.218) 服务器 (44.216.249.42)
第1行 |── SYN, seq=0 ──────────────────────>|
第2行 |<── SYN-ACK, seq=0, ack=1 ──────────| 三次握手
第3行 |── ACK, ack=1 ─────────────────────>|
第4行 |── PSH, seq=1:79, ack=1 ───────────>| 你发 HTTP 请求 (78B)
第5行 |<── ACK, ack=79 ────────────────────| 服务器确认收到
第6行 |<── PSH, seq=1:231, ack=79 ─────────| 响应头 (230B)
第7行 |<── PSH, seq=231:486, ack=79 ────────| 响应体 (255B)
第8行 |── ACK, ack=231 ───────────────────>| 确认响应头
第9行 |── ACK, ack=486 ───────────────────>| 确认响应体
第10行 |── FIN, seq=79, ack=486 ───────────>| 你关闭连接
贯穿全文的核心原则
1. ACK 不能等,数据看时机
这八个字贯穿 TCP 所有阶段------握手(SYN-ACK 把 SYN 和 ACK 合并)、传输(PSH 触发立即 ACK)、挥手(ACK 先走,FIN 看时机)。凑巧就合并,凑不巧就分开,没有固定的"几次"。
2. seq 跟自己比,ack 跟对方比
tcpdump 为两个方向各维护一个独立的基准。seq 减的是自己方向的 ISN,ack 减的是对方方向的 ISN。两个 1 来源不同,只是碰巧都显示为 1。
3. TCP 是流,不是消息
TCP 不知道"HTTP 请求""HTTP 响应"的概念,它只看到连续的字节流。485 字节的响应拆成两段还是一段,TCP 不关心,只要序号连续。应用层协议必须自己定义消息边界(HTTP 用 Content-Length 和 \r\n\r\n)。
4. length 只算 TCP 载荷
length = 应用层数据量,不含 TCP 头、IP 头、以太网帧头。一个 length 0 的纯 ACK 包在网线上实际跑了约 66 字节。
排障速查表
| 你看到的 | 意味着 |
|---|---|
只有 [S] 没有 [S.] |
对方没回应(防火墙丢弃、服务没启动、网络不通) |
[S] 回了 [R.] |
对方明确拒绝(端口没开) |
| 握手成功但数据传输卡住 | 不是连接问题,是应用层问题 |
| ack 不涨 | 对方没收到新数据(丢包或延迟) |
| ack 跳到预期值 | 数据全部到达 |
| ack 只跳了一半 | 部分数据到了,有丢包 |
大量 [R] |
可能是端口扫描 |
| MSS 不对称 | 一方走了隧道/封装,实际传输取较小值 |
写在后面
tcpdump 不可怕。可怕的是"满屏乱码"的错觉。逐字节拆开后,每一行都是固定结构:时间戳 / 源 > 目标 / 标志 / 序列号 / 确认号 / 窗口 / 选项 / 长度。没有神秘的东西,只有你没拆开的东西。
抓包 + Wireshark 是排障的标准流程:命令行抓(tcpdump -w file.pcap),GUI 看。但读懂 tcpdump 原始输出是必须会的能力------生产服务器上没有 GUI,你只有终端。
下一次面对满屏输出时,不要慌,先找 Flags,再找 seq 和 ack,数据传输的节奏自然就浮现出来了。