每一台 Mac,其实都有一个“隐藏的过期时间”。
当系统连续运行 49 天 17 小时 2 分 47 秒 后,Apple XNU kernel 中一个 32 位无符号整数溢出会导致内部的 TCP 时间戳时钟被冻结。一旦这个时钟停止,处于 TIME_WAIT 状态的连接将永远不会过期,临时端口会被慢慢耗尽,最终系统将无法再建立任何新的 TCP 连接。此时,ICMP(例如 ping)仍然可以正常工作,但除此之外,一切网络通信都会“失效”。大多数人唯一知道的解决办法,就是重启机器。
那么这其中究竟发生了什么?
近日,AI Agent 公司 Photon 在监控 iMessage 服务的机器集群时发现这个问题。随后,其在两台机器上现场复现了这个 bug,并最终将根因追溯到 XNU 内核源码中的一处比较逻辑,也为我们解释了 Bug 的来龙去脉。
来源:https://photon.codes/blog/we-found-a-ticking-time-bomb-in-macos-tcp-networking
作者 | photon 责编 | 苏宓
出品 | CSDN(ID:CSDNnews)
背景:你需要了解的几个概念
在深入这个 bug 之前,先简单了解几个关键概念。
什么是 TIME_WAIT?
当一个 TCP 连接关闭时,它并不会立刻消失。
发起关闭的一方会进入一个叫做 TIME_WAIT 的状态。在这个状态下,连接实际上已经“死亡”——不会再有数据传输——但操作系统仍然会把它保留一段时间。
为什么要这么做?主要有两个原因:
1. 处理延迟到达的数据包。互联网并不保证数据包按顺序到达。某个旧连接中的数据包,可能仍在网络中辗转。如果操作系统立刻复用同一个源端口和目标地址来建立新连接,这些“迟到”的数据包就可能被误认为属于新连接,从而导致数据混乱。
2. 确保连接可靠关闭。TCP 的四次挥手以主动关闭方发送的最后一个 ACK 结束。如果这个 ACK 丢失,对端会重新发送 FIN 包。TIME_WAIT 的存在,就是为了让系统在这段时间内还能正确处理这种重传。
TIME_WAIT 的持续时间被定义为 2 × MSL(最大报文生存时间)。当这个时间过去之后,操作系统才会真正释放该连接占用的资源——包括它所使用的临时端口。
什么是 MSL?
MSL(Maximum Segment Lifetime,最大报文生存时间)指的是一个 TCP 报文在网络中被丢弃前,理论上能够存活的最长时间。
1981 年发布的 TCP 原始规范 RFC 793 将 MSL 设定为 2 分钟,因此 TIME_WAIT 的时长就是 4 分钟。
但在实际系统中,现代操作系统通常使用更短的时间:
Linux:MSL 为 30 秒,对应 TIME_WAIT 为 60 秒
macOS / XNU kernel:MSL 为 15 秒,对应 TIME_WAIT 为 30 秒
Windows:默认 MSL 为 120 秒,对应 TIME_WAIT 为 240 秒
在 macOS 上,一个关闭的 TCP 连接只会在 TIME_WAIT 状态停留 30 秒就会被清理掉。这个速度其实很快——前提是清理机制本身没有出问题。
什么是 32 位无符号整数回绕(wraparound)?
在 C 语言中,一个 uint32_t 能表示的范围是 0 到 4,294,967,295(2³² − 1)。当你尝试存储一个超过这个最大值的数字时,它不会报错,而是“回绕”到 0,就像里程表从 999,999 翻回 000,000 一样。
这并不是崩溃,也不是异常,而是 C 语言对无符号整数的定义行为。真正的风险在于:如果代码默认这个计数器只会递增,而没有考虑回绕的情况,就可能埋下隐患。
这类问题其实并不罕见,比如:
Windows 95 / Windows 98 的 49.7 天崩溃问题:内核的 32 位毫秒计数器溢出后,相关组件没有正确处理回绕,导致系统卡死。
2038 年问题:使用 32 位有符号整数记录时间(自 1970 年起的秒数)的 Unix 系统,会在 2038 年 1 月 19 日发生溢出。
GPS 周数翻转:GPS 使用 10 位周计数器,每 1024 周(约 19.7 年)回绕一次,可能导致部分设备显示错误日期。
吃豆人 256 关的“死机画面”:8 位整数溢出使游戏在 255 关之后变得无法继续。
我们在 macOS 中发现的这个 bug,正属于同一类问题。XNU 内核使用一个 uint32_t 来记录 TCP 时间戳,单位是毫秒,自系统启动开始计数。2³² 毫秒正好是 49 天 17 小时 2 分 47.296 秒。一旦超过这个时间,计数器就会回绕归零。
接下来会发生什么,正是这篇文章要讲的重点。
发现过程:一个我们从未察觉的“计时炸弹”
在 Photon,我们运行着一批 Mac 机器,用来监控 iMessage 服务的健康状况。这些机器上跑着多个 iMessage 服务实例,并由一个中央控制器持续发送 ping/pong 消息来测量往返延迟。这些机器是 24/7 持续运行的,只有在绝对必要时才会重启。
2026 年 3 月 30 日——也就是距离上一次统一重启正好 49.7 天——集群中的几台机器,开始悄无声息地无法建立新的 TCP 连接。
奇怪的是,ping 依然正常,已有连接也没有断开,但凡是需要创建新 TCP socket 的操作,全部失败。
这个现象非常典型:XNU kernel 的 TCP 时间戳计数器发生了回绕,而内部的单调性保护阻止它在溢出后继续更新。结果就是——TCP 内部时钟被冻结了。
接下来就是连锁反应:TIME_WAIT 连接不再过期,临时端口不断堆积,却无法被回收。
最终,系统彻底无法再建立新的连接。唯一的恢复方式就是重启——但这不过是重新开始下一轮 49.7 天的倒计时。
在重启这些出问题的机器以恢复服务后,我们检查了整个集群,发现还有几台机器也接近同样的临界点——它们将在 4 月 1 日达到 49.7 天的运行时间。
于是我们决定做一个“现场实验”。
两台机器(Machine A 和 Machine B)的启动时间如下:
两台机器当时都已经运行了 49 天 16 小时。精确的溢出时间如下:
也就是说,我们大约还有半小时来准备实验——时间刚刚好。
实验设计:跨越溢出窗口批量制造 TCP 连接
我们的假设很直接:如果这个 49.7 天的溢出真的会破坏 TIME_WAIT 的回收机制,那么在溢出前后制造一批短连接,应该能看到明显的行为差异:
溢出前:TIME_WAIT 连接会在约 30 秒后正常过期
溢出后:TIME_WAIT 连接将“永久滞留”
为此,我们写了一个测试脚本,分为三个阶段:
首先是监控阶段(溢出前 35 分钟到溢出前 5 分钟):每 10 秒记录一次 TIME_WAIT 连接数量,不主动创建连接。
然后是冲击阶段(溢出前 5 分钟到溢出后 5 分钟):每 2 秒发起约 15 个短生命周期 TCP 连接,请求公共端点(例如 8.8.8.8:443、1.1.1.1:443 等),完成 TLS 握手后立即关闭。
最后是观察阶段:停止创建新连接,继续监控 TIME_WAIT 的数量变化。
这个脚本在 07:58 被部署到两台机器上,并同时启动。
实验结果
溢出前:TIME_WAIT 正常回收
在监控阶段,两台机器的 TIME_WAIT 表现完全健康:
系统自身的后台连接会产生少量 TIME_WAIT(0–13),并且会在几秒内过期。这是完全正常的行为。
冲击阶段:溢出前的动态平衡
08:27:38,脚本开始创建连接。不到 30 秒,TIME_WAIT 从 0 上升到约 200,并随后进入平台期:
脚本每 2 秒创建约 15 个连接(约每分钟 450 个),而每个 TIME_WAIT 只会存活 30 秒就被回收。大约 30 秒后,系统进入动态平衡:TIME_WAIT 稳定在约 200 左右(理论值为 7.5 次/秒 × 30 秒 = 225;略低是因为部分连接失败)。创建与回收保持完美平衡,这就是溢出前的健康状态。
溢出瞬间
脚本使用墙上时间(date +%s)来估算溢出倒计时,而内核的 microuptime 是单调时钟。经过 49.7 天,两者会产生几十秒的偏差。从完整日志来看,TIME_WAIT 实际开始单调上升是在 remain≈28 秒(约 08:32:06)时——这才是回收机制真正停止的时间点。
连接依然以相同速率被创建,但没有任何一个被回收。
溢出后:TIME_WAIT 只增不减
Machine A 的脚本在溢出后约 50 秒停止,Machine B 则继续运行了 5 分钟。两台机器的监控都持续到手动终止。
Machine B 的关键数据(脚本在 08:37:55 停止创建连接):
这就是决定性证据。在 macOS 中,TIME_WAIT 超时时间是 2 × MSL = 30 秒。脚本停止 84 秒后,全部 2,828 个 TIME_WAIT 连接本应已经归零。但现实是,没有任何一个被回收——数量甚至还在增加,因为系统自身的正常连接也开始不断堆积。
Machine A(脚本已停止,08:50 手动检查):
持续单调增长,没有任何恢复迹象。
对比:溢出前 vs 溢出后
根本原因:XNU 内核中 tcp_now 的 32 位溢出
接下来解释为什么会发生这个问题,逐行分析 Apple 内核源码。
漏洞分类
这是 TCP 子系统中的 32 位无符号整数计时器溢出错误,具体来说是 TCP 时间戳计数器的溢出。受影响的计数器 tcp_now 是内核的内部 TCP 时钟。一旦它停止计数,TCP 栈中所有依赖它的定时器都会失效。
tcp_now:注定会溢出的计数器
在 XNU 内核(Apple 开源项目 apple-oss-distributions/xnu)中,tcp_now 定义在 bsd/netinet/tcp_var.h:
这是一个 32 位无符号整数,以毫秒为单位递增,跟踪自开机以来的时间。每当 TCP 子系统需要获取当前时间戳时,会调用 calculate_tcp_clock(基于 XNU 内核源码分析):
关键在于这一行: (uint32_t)now.tv_sec * 1000。当系统运行了 4,294,967 秒(约 49.7 天)后,这个乘法结果超过了 uint32_t 的最大值 4,294,967,295。强制转换为 uint32_t 会导致无符号整数回绕——数值从接近最大值直接跳回接近零。
为什么 tcp_now 在溢出后会冻结
漏洞出现在这一段保护逻辑中:
设计意图很简单:“tcp_now 必须只向前移动。”在正常情况下,这段逻辑工作正常。但在溢出瞬间:
旧值 tmp(接近最大)大于 new 值 current_tcp_now(回绕到接近零),cmpxchg 永远不会执行。结果,tcp_now 锁定在溢出前的值,再也不会更新。
内核的 TCP 时钟彻底停止。
TIME_WAIT 过期检查失效机制
当 TCP 连接进入 TIME_WAIT 状态时,内核会记录一个绝对过期时间。在`bsd/netinet/tcp_timer.c`文件中,`add_to_time_wait_locked`函数实现了该逻辑:
此处延迟时长计算公式为:延迟 = 2 × TCPTV_MSL = 2 × 15000 = 30000毫秒。
内核的垃圾回收函数`tcp_gc`会周期性扫描 TIME_WAIT 队列:
`TSTMP_GEQ`宏定义在`bsd/netinet/tcp_seq.h`文件中:
这是一种标准的有符号模运算比较方式,专门用于处理序列号回绕问题。正常情况下(`tcp_now`持续递增),当`tcp_now`大于等于过期时间时,该宏会返回 true,连接会被内核清理。
但当`tcp_now`被冻结时:
计算结果一直是 false,连接永远不会被回收。
完整因果链
连锁反应:从时钟冻结到 TCP 完全失效
这个漏洞的致命之处在于静默失效:不会触发内核恐慌、无错误日志、无崩溃报告。系统表面看起来完全正常,直到 TCP 服务彻底瘫痪。
故障演进过程:
1. 溢出后数分钟后:TIME_WAIT 连接停止过期。如果业务仅创建少量短连接,数小时内都无法察觉异常;
2. 溢出后数小时:TIME_WAIT 连接堆积至数千个,系统临时端口(macOS 默认范围为 49152~65535,共 16384 个)开始耗尽;
3. 端口耗尽:新的出站连接无法绑定本地端口,卡在 SYN_SENT 状态并失败。已建立的长连接(ESTABLISHED)不受影响,因为已占用端口;
4. 系统负载飙升:内核持续消耗 CPU 资源扫描庞大且永不缩减的 TIME_WAIT 队列,应用不断重试失败连接,进一步加重负载;
5. TCP 彻底失效:仅 ICMP 协议(ping 命令)可用,因为它不依赖 TCP 端口和 TCP 定时器子系统。
唯一恢复方案: 重启系统 → 重置 tcp_now 为 0,重新开始 49.7 天的倒计时。
佐证依据
RFC 7323 与时间戳回绕
RFC 7323(高性能 TCP 扩展)第 5.4 节(时间戳时钟)中提到,以 1 毫秒为精度的 32 位时间戳,大约在 24.8 天(2³¹ ms)后会发生符号位回绕。第 5.5 节(过期时间戳)要求 PAWS 实现必须在连接空闲超过 24 天后,将缓存的时间戳置为无效。
我们观测到的溢出周期是 49.7 天,也就是完整的无符号数回绕周期 2³² ms,正好是 RFC 中符号位回绕周期的两倍。RFC 讨论的是传输过程中对端 TCP 时间戳选项的回绕,而不是本地内核自身的定时器变量,后者属于 XNU 实现上的缺陷。
社区中一致的故障现象报告
苹果社区论坛和开源项目中,有多份报告描述的症状与该漏洞完全吻合:
苹果社区帖子 :macOS Catalina 下“无法建立新的 TCP 连接”。新连接进入 SYN_SENT 后立即关闭,已有连接不受影响,只有重启才能恢复。
Podman 问题 :在 macOS 12 上“podman 虚拟机在运行一段时间后网络连接卡住”。运行数周后,运行在 macOS 上的虚拟机出现 TCP 出站失败,但 ICMP 仍然可用。
这些报告的共同特征:TCP 失效但 ICMP 正常,只有重启才能解决,并且发生在连续运行数周之后。这与 tcp_now 溢出的预测症状完全一致。ICMP 不使用 TCP 定时器子系统,因此不受影响。
影响范围:哪些设备会受影响?
任何同时满足以下两个条件的 macOS 系统:
连续运行时间超过 49 天 17 小时且未重启
存在任何 TCP 网络活动(几乎所有联网的 Mac)
大多数普通 Mac 会因为系统更新在 49 天内重启,因此普通用户很少触发此问题。但以下场景属于高风险:
长期运行的服务器集群(例如我们的 iMessage 监控系统)
macOS CI/CD 构建服务器(Jenkins、GitHub Actions 自托管运行器)
Mac Pro 工作站(长期渲染、编译或仿真任务)
托管机房中的 Mac(远程管理,很少重启)
用作构建集群或测试环境的 Mac mini 集群
复现 Bug 方法
想在你自己的 macOS 设备上验证这个漏洞?只需四步。
步骤 1:计算溢出时间
步骤 2:在溢出前后监控 TIME_WAIT 数量
步骤 3:在溢出窗口内生成连接
步骤 4:观察现象
停止生成连接,等待 2 分钟。如果 TIME_WAIT 数量没有下降,说明漏洞已复现。
9.5 小时后:亲眼见证系统瘫痪
溢出后我们没有重启,而是让两台机器继续运行,观察漏洞自然恶化的全过程。
溢出后 9.5 小时的系统状态(PDT 18:02)
MachineB (uptime: 50days, 2: 33): TIME_WAIT: 8, 217SYN_SENT: 3, 315ESTABLISHED: 38FIN_WAIT_1: 9LAST_ACK: 23CLOSING: 2Load: 49. 74
TIME_WAIT 累积趋势
没有任何一个 TIME_WAIT 连接被回收。数量只增不减。
SYN_SENT 堆积:新建连接大量失败
溢出 9.5 小时后,两台机器都积累了 3000 以上的 SYN_SENT 连接,这是 TCP 端口耗尽的典型表现:
出站连接卡在三次握手的第一步,无法申请到端口
临时端口被永不释放的 TIME_WAIT 占用
只剩下 37–38 个 ESTABLISHED 连接,已有的长连接仍然正常,但新建连接几乎无法建立
机器 B 的系统负载飙升到 49.74,因为内核在不断扫描不断膨胀的 TIME_WAIT 队列,消耗大量 CPU
这与我们预测的恶化过程完全一致:
结论
一个 32 位整数。一段看似无害的 if (tmp < current_tcp_now) 保护机制。49.7 天的等待。就足以埋下一颗定时炸弹。
这类漏洞非常隐蔽,因为它躲过了所有防御环节。它不会在开发测试中被发现,谁会做连续 50 天的测试?它不会在代码审查中被标记,逻辑看起来完全合理。它甚至可能在生产环境中被误诊为网络问题或硬件故障。只有当你刚好盯着一台运行了 49 天的机器,并且刚好知道 2³² 毫秒等于 49.7 天时,整个谜题才会被解开。
我们在多台服务器上复现了该问题,证据确凿:溢出前,TIME_WAIT 正常过期(0–13 个);溢出后,TIME_WAIT 永远不回收(累积到数千个)。tcp_now 被冻结,内核的 TCP 时钟停止。其他一切看起来都正常,直到端口耗尽。
如果你管理长期运行的 macOS 设备,请记住这个时间:
49 天 17 小时 2 分钟 47 秒。
我们正在开发比重启更好的修复方案,一个不需要完整重启、专门解决 tcp_now 冻结的临时修复方案。在此之前,请在时钟溢出前安排重启。
【活动分享】"48 小时,与 50+ 位大厂技术决策者,共探 AI 落地真路径。"由 CSDN&奇点智能研究院联合举办的「全球机器学习技术大会」正式升级为「奇点智能技术大会」。2026 奇点智能技术大会将于 4 月 17-18 日在上海环球港凯悦酒店正式召开,大会聚焦大模型技术演进、智能体系统工程、OpenClaw 生态实践及 AI 行业落地等十二大专题板块,特邀来自BAT、京东、微软、小红书、美团等头部企业的 50+ 位技术决策者分享实战案例。旨在帮助技术管理者与一线 AI 落地人员规避选型风险、降低试错成本、获取可复用的工程方法论,真正实现 AI 技术的规模化落地与商业价值转化。这不仅是一场技术的盛宴,更是决策者把握 2026 AI 拐点的战略机会。