ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSH MAC校验失败排查:从链路MTU到双端抓包

SSH MAC校验失败排查:从链路MTU到双端抓包

下午三点多,一条跑了十几分钟的传输命令停在了六成进度上,终端里蹦出这么一行:ssh_dispatch_run_fatal: Connection to x.x.x.x port 22: message authentication code incorrect。手动重连,能连上;再跑一次,还是断,只是断的位置换了个百分比。换台笔记本连同一台机器,一口气跑完。于是很容易得出一个结论:网络抖了。可这句话经不起推敲——网络抖动、丢包、延迟升高,只会让连接变慢或者超时断开,很少会把连接"撕"成一条完整性校验失败的报错。这条报错说的是另一件事:收到的数据包,用双方约定的密钥和序号算出来的校验值,跟包里带的对不上。翻译成人话,就是"这封信的封条被换过了"。

这篇内容围绕这条具体的报错展开,把它拆成可执行、可复现的排查路径。适合的人包括:手上正被这条报错卡住的运维和开发、经常用 scp/rsync/sftp 搬运大文件的人、在虚拟化或复杂链路上维护远程登录服务的人,以及只见过这条报错但没搞懂它含义的读者。不论你是第一次遇到,还是已经被它折腾过几轮,下面这套从语义到链路、从主机到抓包的顺序,都能直接拿去用。

1. 先把这条报错翻译成人话

1.1 每个SSH数据包上都贴着一张封条

SSH 协议在密钥交换完成之后,进入传输层,此后每一个数据包都长这个样子:包长度、填充长度、真正的载荷、随机填充,最后跟一段完整性校验值。这段校验值就是 MAC(message authentication code),它不是登录密码,也不是密钥,而是对"这一段数据"算出来的一个摘要。算法上通常是 HMAC,把会话密钥和"序号 + 未加密的整包内容"一起搅进去,得出固定长度的一串字节。

这里的关键在序号。SSH 为每个方向的包维护一个 32 位计数器,从 0 开始,每发一个包加一,收方也各自维护一份。这个序号不上网传输,双方靠"你发第一个我就知道是 0,你发第二个我就知道是 1"来隐式同步。于是校验失败就有了几种非常具体的可能:发出去的内容在路上被改了;双方的序号错位了(中间丢了包、包重复了、包乱序了);密钥不一致(重协商环节出了问题);某一端的实现有 bug,算出来的值本身就是错的。

理解了这一点,就能明白为什么"重启一下"经常看似有效——重启会重新走一遍密钥交换,序号归零,链路状态刷新,如果问题出在序号错位或者重协商上,重启确实能撑一阵。但这只是把问题往后推,根因还在原地。

1.2 密钥交换都过了,说明嫌疑已经转移

很多人第一反应是"密钥不对"。其实未必。密钥交换阶段用的是非对称运算,双方各自算出会话密钥;如果两边算出来的密钥不一样,第一个加密包就解不开,报错会变成包长度离谱、解密失败、或者直接Bad packet length。而message authentication code incorrect通常意味着前面的流程是通的,双方对密钥的理解一致,只是在后续某个数据包上校验对不上。

如果双方已经改用 AEAD 类算法(GCM、ChaCha20-Poly1305 这类自带认证标签的),MAC 校验失败往往和"密文被改"或"序号错位"直接挂钩;如果用的是aes128-ctr加独立 HMAC 的组合,则可能是载荷被改,也可能是 HMAC 本身算错。注意不同组合下报错的表现会有细微差异,这点在后面的分类试验里很有用。

还有一个容易被忽略的历史细节:SSH 规范里,序号在密钥重协商之后不重置,继续往下累加。早年有个别实现把重协商当成了"重新开始",把序号清零,结果对端按连续序号去校验,必然对不上。这类问题在今天的主流实现里已经很少见,但如果你遇到"每次传到固定大小就断"这种规律性极强的现象,重协商路径是必须怀疑的对象。

1.3 报错出现的时机,决定你先查什么

同样是这一行字,出现在不同时刻,指向的方向差别很大。我在排查时第一件事永远不是敲命令,而是问清楚或者自己确认:它到底断在什么时候。

出现的时机最可能的方向第一个动作
认证刚过、会话刚建立就断算法协商不匹配、某一端实现有缺陷定点替换算法组合
空闲几分钟后回来敲命令就断中间设备会话表过期、后端被切换开保活机制再观察
大流量传输到一定量必断链路 MTU、分片重组、中间设备改写内容探测路径 MTU、降 MTU 复测
断的时机完全随机、小流量也断网卡 offload、驱动、内存等主机侧问题抓包对照、关闭 offload 复测

这张表不能替代排查,但它能省掉大量无头苍蝇式的试错。我见过最典型的浪费,是一个同事在"空闲断连"的场景里反复改加密算法,折腾了一下午,实际上只要把客户端的保活打开就没事了。

2. 五分钟分类试验:不装任何工具先缩范围

2.1 定点替换算法,把软件层嫌疑排掉

开始之前先看清楚两端到底有哪些算法可用。客户端上直接问它自己就行:

ssh -Q cipher ssh -Q mac ssh -Q kex

拿到清单之后,做一次"只换一个变量"的复测。第一步换 MAC,优先换成带 ETM 后缀的版本:

ssh -vvv -o MACs=hmac-sha2-256-etm@openssh.com user@x.x.x.x

选 ETM 的理由很实在:这类算法把完整性校验放在解密之前,先验证"这坨密文有没有被动过",再去解密,能更早发现被篡改的内容,也绕开了一部分"先解密再校验"实现里的边界问题。如果换成 ETM 之后不再断开,基本可以判断问题出在原来那个 MAC 算法与当前链路的配合上。

第二步换加密算法,把 AEAD 类换成传统流式加密,观察行为是否有变化:

ssh -c aes128-ctr -o MACs=hmac-sha2-256 user@x.x.x.x

这一步的意义在于分离变量。AEAD 算法把认证标签嵌在密文里,而传统组合是"加密后再单独算 MAC",两者在链路上的表现不同。如果 AEAD 断、传统组合不断,问题大概率在硬件加密卸载或者网卡对特定包的处​理上;反过来则说明链路对带认证标签的包有意见。

2.2 关掉压缩,这个变量比想象中重要

压缩这个开关经常被忽略,但它在排查里价值很高。开启压缩后,发送方先把载荷压缩,再加密、再算 MAC;接收方按相反顺序还原。如果中间链路对数据做了改写,压缩流的解压会先出问题,或者解压出来的内容算 MAC 对不上。更麻烦的是,某些链路上的设备会对"看起来像压缩数据"的流量做特殊处理。

ssh -o Compression=no user@x.x.x.x

把压缩关掉再跑一次同样的传输。如果关掉就好了,说明压缩路径上有问题——可能是某一端的压缩实现版本差异,也可能是中间设备在插手。日常使用中,现在带宽普遍够用,压缩带来的收益有限,反而增加了排查复杂度,我个人在长距离、大文件的场景里是默认关掉的。

顺手还值得试一个零成本参数:

ssh -o IPQoS=none user@x.x.x.x

这个参数控制 SSH 给数据包打的优先级标记。绝大多数链路对它无感,但少数设备会因为不认识的标记把包丢掉或者改写。试一下不费事,中了就省下几个小时。

2.3 用日志把时间线留住,别靠回忆

复现的时候一定要留日志,否则"刚才好像断在 40%"这种描述毫无价值。

ssh -E /tmp/ssh-debug.log -vvv -o Compression=no user@x.x.x.x

-vvv的输出里,有两行必须看:debug2: MACs stoc: ... ctod: ...,它告诉你两个方向最终协商出的 MAC 算法;还有debug2: ciphers stoc: ... ctod: ...,同理。这两行确认了"我以为在用 A,实际在用 B"这类乌龙。日志里还会记录重协商发生的位置,搜rekey就能看到;如果报错紧跟在一次重协商之后,方向就很明确了。

服务端也要看一眼生效配置,别只看配置文件:

sshd -T | grep -E 'macs|ciphers|kexalgorithms|compression'

sshd -T输出的是真正生效的值,能避免"改了配置但没重载"或者"被更高优先级的配置文件覆盖"这类问题。同时把服务端日志翻出来,登录失败和协议层错误通常都会留下痕迹:

journalctl -u sshd --since "30 min ago" | tail -50

如果服务端日志里也出现了校验失败的字样,说明接收侧确实收到了坏包,责任在链路或发送侧;如果服务端日志干干净净,客户端却报错,那就要怀疑客户端收到的包被改,或者客户端自身解析出了问题。

3. 链路侧嫌疑:MTU、分片与中间转发

3.1 MTU为什么会伪装成MAC错误

这是最常见也最容易被误判的一类。路径上任何一段的 MTU 比两端接口小,大包就会被丢弃或者分片。正常情况下,路由器会回一个"包太大"的通知,让发送方把包切小;但如果这个通知被过滤掉了(这种情况在真实网络里一点也不罕见),发送方会一直按大包发,一直丢,最后表现为卡顿、超时,或者在某些设备上被强行重组。

重组这一步恰恰是问题的温床。部分设备在做大包重组时,缓冲区不够、超时设置过短、或者重组逻辑本身有缺陷,重出来的内容就跟原始数据对不上。数据内容变了,MAC 自然对不上,于是你在客户端看到的就是那句message authentication code incorrect。看起来像加密问题,实际上是搬运过程把货摔了。

判断方法很简单,先确认路径最大能过多大的包。Linux 上用带"禁止分片"标志的 ping 来试:

ping -M do -s 1472 -c 3 x.x.x.x ping -M do -s 1400 -c 3 x.x.x.x ping -M do -s 1200 -c 3 x.x.x.x

-s是 ICMP 载荷长度,加上 8 字节 ICMP 头和 20 字节 IP 头正好 1500,所以 1472 对应标准以太网 MTU。如果 1472 不通、1400 通,说明路径实际 MTU 在两者之间,逐档往下试就能定位。常见的几个数值可以背下来:PPPoE 拨号链路通常是 1492,对应载荷 1464;GRE 封装扣 24 字节;IPsec 类封装扣得更多。

查到结果后跟本机接口 MTU 对比:

ip link show

如果接口是 1500 而路径只支持 1400,那大包必然出问题。最省事的验证方式是把本机 MTU 临时调小,再跑一次同样的传输:

sudo ip link set dev eth0 mtu 1400 # 复测 sudo ip link set dev eth0 mtu 1500 # 记得改回来

改小之后不再断,基本可以锁定。真正的修复要在链路上做:把沿途设备的 MTU 统一,或者在网关上加 MSS 钳制,让 TCP 握手时就把最大报文段压到路径能承受的大小。SSH 本身没有 MTU 参数,这一层只能在网络侧解决。

3.2 中间设备改写数据的几种典型手法

有些链路上会部署做"传输优化"的设备,它们会把 TCP 连接从中间截断,两端各自维护一段,中间再拼起来。这类设备在正常配置下工作良好,但一旦会话表过期、策略变更、或者两台设备之间的状态不同步,拼出来的数据就可能错位。表现出来就是长连接传到一半突然校验失败,而短连接、小请求一切正常。

识别这类问题的诀窍是看"规律性"。如果每次都是传到差不多同一个体量断,而且换客户端、换账号都一样,八成是链路上有个东西在按自己的节奏处理流量。这时候改算法、改客户端版本都没用,得去找网络侧确认路径上有什么设备。

还有一种情况是网络地址转换设备在会话表项老化后,把后续的包分配给了另一个出口或者另一台后端。对 SSH 来说,对端突然换了一个"人",密钥对不上,第一个包就会校验失败。这类故障的典型特征是"空闲一段时间后必断",而且断得非常干脆,不会有任何征兆。

3.3 多路径与聚合链路带来的乱序

双线接入、链路聚合、等价多路径转发,这些设计在提升吞吐的同时也带来了一个副作用:如果流量被按包打散到不同路径,到达顺序就会乱。SSH 跑在 TCP 上,TCP 本身会重排,理论上不该影响上层;问题在于中间设备如果处理不当,或者路径之间的延迟差异大到触发超时重传,就可能出现重复包、包丢失导致的序号错位。

排查上可以看两个指标:

ss -tin dst x.x.x.x nstat -az | grep -iE 'TcpExtTCPOFOQueue|TcpRetransSegs|TcpExtTCPLostRetransmit'

ss -tin的输出里能看到当前连接的往返时间、重传次数、乱序计数。如果重传和乱序数字明显偏高,而链路本身带宽充裕,那就要怀疑路径不一致。修复思路是在负载均衡或者路由策略上保证"同一个连接始终走同一条路径",按五元组做哈希是最常见的做法。

4. 主机侧嫌疑:网卡、虚拟化与硬件

4.1 offload开关的对照测试

网卡为了省 CPU,会把校验和计算、分段、聚合这些活儿交给硬件做。这在绝大多数场景下是好事,但在虚拟化环境、老驱动、或者某些特定网卡上,就可能出问题:硬件算出来的校验和跟软件预期不一致,或者聚合出来的大包在离开虚拟机时没被正确处理。表现出来就是"发出去的数据在接收端校验不过"。

先看当前状态:

ethtool -k eth0 | grep -E 'checksum|segmentation|gso|gro|tso' ip -s link show eth0 ethtool -S eth0 | grep -iE 'err|drop|discard'

如果ip -s link里的接收错误、丢弃计数在持续增长,说明物理层或者驱动层已经在丢东西了,先解决这个再谈上层。做对照测试时,可以临时关掉一组 offload:

sudo ethtool -K eth0 tx off rx off tso off gso off gro off # 复测 sudo ethtool -K eth0 tx on rx on tso on gso on gro on # 恢复

关掉之后问题消失,说明确实和 offload 有关。但注意,关 offload 只是诊断手段,不是解决方案:长期关着会明显增加 CPU 占用,在高速链路上反而可能引入新的丢包。正确的做法是升级驱动和固件,或者调整虚拟化平台的网卡型号。顺手提一句,抓包时看到的"校验和错误"很多也是 offload 造成的假象——包还没出网卡,校验和字段是空的,抓包工具如实报告"错误",其实发出去的时候硬件会填好。判断时一定要在接收端抓,或者临时关掉发送端的 tx offload 再抓。

4.2 虚拟网卡与宿主机内存

虚拟化环境里这条链路更长:虚拟机网卡、虚拟交换机、宿主机物理网卡、外部交换机。任何一环出问题都可能表现为上层校验失败。虚拟网卡型号是个高频坑点,某些半虚拟化型号在特定内核版本下对大包处理不稳,换成另一种型号往往就好了。这类改动通常需要关机调整,成本不低,所以建议放在排除法靠后的位置。

内存问题概率低但破坏力极大。内存位翻转会让缓冲区里的数据在无人察觉的情况下变样,算出来的 MAC 当然对不上。判断线索是:报错位置完全随机、换算法换 MTU 都没规律、同一台机器上其他服务也偶发奇怪的错误。这时候翻一下内核日志:

dmesg | grep -iE 'edac|machine check|mce|memory error'

有硬件纠错相关的记录,就安排一次完整的内存检测。我在一个内部项目里遇到过一次,一台机器的校验错误平均每天出现两三次,位置随机,查了两周网络无果,最后是内存条的问题。

4.3 加密指令集、微码与版本差异

现代 CPU 基本都有 AES 专用指令,OpenSSL 会自动选用。但指令集和微码版本不匹配、虚拟机热迁移后 CPU 特性集变化、或者某些老型号的硬件加密实现有已知缺陷时,加密结果就可能出岔子。验证方式很直接,在两端分别跑一下加解密基准:

openssl speed -evp aes-128-gcm openssl speed -evp chacha20-poly1305

如果某一端跑起来报错、结果异常、或者速度离谱地慢,说明这一端的加密路径有问题,优先考虑更新微码和加密库。

版本因素同样不能忽略。SSH 协议历史上有过一些被截断到 96 位的 MAC 算法,安全性弱,实现上也更容易出现边界问题。两端版本差距过大时,协商可能落到一个双方"都能用但都不常用"的组合上,这种组合的测试覆盖往往最少。查看版本:

ssh -V sshd -V

如果一端是很多年前的老版本,升级通常比继续排查更划算。服务端的算法白名单也建议收敛一下,把明显过时的选项去掉,只保留当前主流的几组,减少协商落到冷门组合的概率。

5. 双端抓包:把责任边界画清楚

5.1 抓包的参数与时钟对齐

前面所有手段都是在缩小范围,真正定性还是要靠抓包。原则只有一条:两端同时抓,抓同一段时间。

# 客户端 sudo tcpdump -i eth0 -s 0 -w /tmp/ssh-client.pcap 'host x.x.x.x and port 22' # 服务端 sudo tcpdump -i eth0 -s 0 -w /tmp/ssh-server.pcap 'host y.y.y.y and port 22'

-s 0表示抓完整包,不要截断,否则看不全载荷就没法对比。两端的时钟最好先对一下,误差控制在秒级以内,实在不行就在开始和结束时各记一次本地时间,事后按偏移量对齐。

抓完先看文件大小和包数量,这一步就能发现很多问题:两端包数量差很多,说明中间有丢包;一端字节数明显多,说明有重传;两端包数量一致但内容不同,那就是被改写了。

5.2 四个关键判读信号

拿到抓包之后,按下面四个信号依次看,基本能定性:

判读信号观察方式指向
校验和错误tcpdump -vvv -r file.pcap,找 bad checksum链路改写或 offload 假象
重传与重复包统计重传次数、看是否有完全相同的包链路丢包、多路径乱序
IP 分片抓包里出现 fragmented 标记MTU 不匹配,需降 MTU
两端内容不一致对比同一序号的包内容中间设备改写数据

校验和那一项要特别小心,如前面所说,本机发出的包在抓包时可能还没算校验和。稳妥的判断方法是:只有在接收端抓到的包显示校验和错误,才算真错误;发送端抓到的不作数。

重传和重复包则要看比例。偶尔几次重传完全正常,但如果重传率超过百分之几,而且集中在大流量传输期间,那链路的承载能力或者路径一致性就有问题。

5.3 一条完整的排查链路(虚构项目复盘)

说个具体的。一个内部备份项目,每天晚上跑一次增量同步,用的就是 SSH 通道。现象很规律:跑到 1.8GB 到 2.2GB 之间必断,报错就是那句校验失败。断点位置在飘,但体量范围固定。

第一步,换算法。客户端加上-o MACs=hmac-sha2-512-etm@openssh.com -c aes256-ctr,还是断在 2GB 附近。排除了算法组合的问题。

第二步,关压缩。本来就是关的,跳过。

第三步,探测 MTU。ping -M do -s 1472通,-s 1473不通,说明路径 MTU 就是标准的 1500,看起来没问题。到这里很多人会停止怀疑 MTU,但恰恰是这一步误导了我。

第四步,抓包。两端同时抓,跑了一次小规模的传输(几百 MB,没触发断连)。对比之后发现,客户端发出的包和服务端收到的包,在 TCP 层面完全一致,没有丢包也没有重传。既然传输层没问题,那问题就在"传输层之上"——数据内容被改了。

第五步,回头查链路。跟网络侧确认路径上有什么设备,发现中间有一段链路上有两台做流量处理的设备,其中一台开启了针对大流量的"优化"功能。把这段链路的 MTU 从一端 1500、另一端 9000 的不一致状态统一成 1500,并且关掉那台设备的优化功能之后,问题消失。

复盘的教训是:ping -M do -s 1472通,只能说明"路径允许 1500 的包通过",不能说明"沿途设备处理大包的方式是正确的"。重组、缓冲、改写这些行为发生在更上层,ping 测不出来。遇到规律性极强的中断,一定要把链路上的设备列出来,逐个确认配置。

6. 缓解与长期治理

6.1 手上马上能用的几个参数

根因没定位之前,活儿还是要干。下面这几组参数可以先顶上,它们的共同点是只改客户端,不动服务端。

ssh -o ServerAliveInterval=30 \ -o ServerAliveCountMax=3 \ -o TCPKeepAlive=yes \ -o Compression=no \ -o MACs=hmac-sha2-256-etm@openssh.com \ user@x.x.x.x

保活参数的作用是定期发一个空包,让中间设备的会话表项一直保持活跃,避免空闲超时后被"换人"。这对前面说的"空闲后断开"那一类特别有效。间隔值建议取中间设备会话超时的一半左右,30 秒是个比较通用的起点;设得太小会增加无谓流量,设得太大起不到作用。

如果断连之后需要自动恢复,可以在外层套一个守护逻辑,断了自己重连。但要注意,自动重连掩盖了问题本身,只能当临时措施,并且一定要配套日志记录,把每次断连的时间和位置记下来,方便事后分析。

写进~/.ssh/config会更省事,按主机配置,不用每次敲一长串:

Host backup-target HostName x.x.x.x User backupuser Compression no MACs hmac-sha2-256-etm@openssh.com ServerAliveInterval 30 ServerAliveCountMax 3

6.2 传输策略:别让单条连接扛太久

这条经验值千金:长连接 + 大流量,是这类故障的高发组合。与其想办法让一条连接跑得更久,不如让它跑得更短。

文件同步用 rsync,带的参数要能断点续传:

rsync -avz --partial --append-verify \ -e 'ssh -o Compression=no' \ /data/ backup-target:/backup/data/

--partial保留未完成的部分,--append-verify在续传时校验已有部分,避免拼出错误的内容。这两个参数配合起来,即使中途断了,下一次也能从断点附近继续,不会从头再来。

如果文件本身非常大,还有一招是分块。把一个几十 GB 的文件切成若干块,逐块传输,每块独立建立连接。这样即使某一块失败,重传成本也只有一块。切分命令很朴素:

split -b 2G bigfile.bin bigfile.part.

另外,给传输加个速率上限也值得一试。某些中间设备缓冲区不大,突发流量会把它冲垮,反而是匀速慢速传输更可靠:

rsync -av --bwlimit=20000 ...

这个数字单位是 KB/s,20000 就是大约 20MB/s。牺牲一点速度换成功率,在跨长距离、跨多段链路的场景里通常是划算的。

6.3 长期要做的那几件事

临时手段用完之后,根因该治还是要治。我梳理了一份清单,按投入产出比排序:

治理项具体动作收益
统一 MTU/MSS沿途设备 MTU 对齐,网关加 MSS 钳制消除一大类分片引发的怪问题
审查中间设备关掉不必要的流量改写、优化功能恢复端到端的完整性语义
网卡固件与驱动定期跟进厂商版本,记录已知问题减少 offload 类偶发故障
算法白名单服务端只保留主流算法组合避免落到测试覆盖少的组合
监控补位采集 TCP 重传率、接收错误计数、断连次数故障早期就能发现趋势

监控这一项最容易被忽略,但价值最高。把重传率、接收错误、连接异常断开次数这几个指标采起来,配上阈值告警,很多问题在用户报障之前就能看到苗头。我自己维护的几台机器上,重传率超过百分之一就会触发提醒,实际用下来,这条规则的准确率相当高。

还有一件事值得单独说:把每次故障的"断点特征"记下来。是断在固定体量、固定时长、还是完全随机?这三个答案指向完全不同的方向,而这个问题在事后回顾时经常想不起来。我现在的习惯是在排查本上第一行就写清楚"断在第几秒、第多少字节",比任何命令都省时间。

最后分享一个特别小的技巧:临时把客户端的 MTU 调小,是验证链路类问题最快的单步操作,一条命令、一次复测,几十秒就能得出结论。它不会告诉你根因在哪台设备上,但能立刻告诉你"要不要继续往链路方向查"。这个判断本身就值回票价。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进