macOS出现运行49.7天“魔咒”:TCP连接失效,网络服务将全面瘫痪!

张开发
2026/4/12 8:47:36 15 分钟阅读

分享文章

macOS出现运行49.7天“魔咒”:TCP连接失效,网络服务将全面瘫痪!
每一台 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 责编 | 苏宓出品 | CSDNIDCSDNnews背景你需要了解的几个概念在深入这个 bug 之前先简单了解几个关键概念。什么是 TIME_WAIT当一个 TCP 连接关闭时它并不会立刻消失。发起关闭的一方会进入一个叫做 TIME_WAIT 的状态。在这个状态下连接实际上已经“死亡”——不会再有数据传输——但操作系统仍然会把它保留一段时间。为什么要这么做主要有两个原因1. 处理延迟到达的数据包。互联网并不保证数据包按顺序到达。某个旧连接中的数据包可能仍在网络中辗转。如果操作系统立刻复用同一个源端口和目标地址来建立新连接这些“迟到”的数据包就可能被误认为属于新连接从而导致数据混乱。2. 确保连接可靠关闭。TCP 的四次挥手以主动关闭方发送的最后一个 ACK 结束。如果这个 ACK 丢失对端会重新发送 FIN 包。TIME_WAIT 的存在就是为了让系统在这段时间内还能正确处理这种重传。TIME_WAIT 的持续时间被定义为 2 × MSL最大报文生存时间。当这个时间过去之后操作系统才会真正释放该连接占用的资源——包括它所使用的临时端口。什么是 MSLMSLMaximum Segment Lifetime最大报文生存时间指的是一个 TCP 报文在网络中被丢弃前理论上能够存活的最长时间。1981 年发布的 TCP 原始规范 RFC 793 将 MSL 设定为 2 分钟因此 TIME_WAIT 的时长就是 4 分钟。但在实际系统中现代操作系统通常使用更短的时间LinuxMSL 为 30 秒对应 TIME_WAIT 为 60 秒macOS / XNU kernelMSL 为 15 秒对应 TIME_WAIT 为 30 秒Windows默认 MSL 为 120 秒对应 TIME_WAIT 为 240 秒在 macOS 上一个关闭的 TCP 连接只会在 TIME_WAIT 状态停留 30 秒就会被清理掉。这个速度其实很快——前提是清理机制本身没有出问题。什么是 32 位无符号整数回绕wraparound在 C 语言中一个 uint32_t 能表示的范围是 0 到 4,294,967,2952³² − 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的启动时间如下$ sysctl kern.boottimeMachine A: { sec 1770762587 } Tue Feb 10 14:29:47 2026Machine B: { sec 1770762608 } Tue Feb 10 14:30:08 2026两台机器当时都已经运行了 49 天 16 小时。精确的溢出时间如下Machine A2026-04-01 08:32:34 PDT剩余约 36 分钟Machine B2026-04-01 08:32:55 PDT剩余约 36 分钟也就是说我们大约还有半小时来准备实验——时间刚刚好。实验设计跨越溢出窗口批量制造 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 表现完全健康[07:58:09] PHASEwait | remain2065s | TIME_WAIT0 | ESTABLISHED35[07:58:39] PHASEwait | remain2035s | TIME_WAIT5 | ESTABLISHED38[07:59:09] PHASEwait | remain2005s | TIME_WAIT2 | ESTABLISHED36[07:59:19] PHASEwait | remain1995s | TIME_WAIT0 | ESTABLISHED36...[08:27:28] PHASEwait | remain306s | TIME_WAIT0 | ESTABLISHED41系统自身的后台连接会产生少量 TIME_WAIT0–13并且会在几秒内过期。这是完全正常的行为。冲击阶段溢出前的动态平衡08:27:38脚本开始创建连接。不到 30 秒TIME_WAIT 从 0 上升到约 200并随后进入平台期[08:27:38] PHASEblast | remain296s | TIME_WAIT5 ← 开始冲击[08:27:44] PHASEblast | remain290s | TIME_WAIT48[08:27:51] PHASEblast | remain283s | TIME_WAIT90[08:27:59] PHASEblast | remain275s | TIME_WAIT146[08:28:08] PHASEblast | remain266s | TIME_WAIT197 ← 稳态[08:28:37] PHASEblast | remain237s | TIME_WAIT196[08:29:37] PHASEblast | remain177s | TIME_WAIT200[08:30:37] PHASEblast | remain117s | TIME_WAIT198[08:31:37] PHASEblast | remain57s | TIME_WAIT192脚本每 2 秒创建约 15 个连接约每分钟 450 个而每个 TIME_WAIT 只会存活 30 秒就被回收。大约 30 秒后系统进入动态平衡TIME_WAIT 稳定在约 200 左右理论值为 7.5 次/秒 × 30 秒 225略低是因为部分连接失败。创建与回收保持完美平衡这就是溢出前的健康状态。溢出瞬间[08:32:30] PHASEblast | remain4s | TIME_WAIT368[08:32:32] PHASEblast | remain2s | TIME_WAIT383[08:32:34] PHASEblast | remain0s | TIME_WAIT399[08:32:36] PHASEblast | remain-2s | TIME_WAIT412[08:32:39] PHASEblast | remain-5s | TIME_WAIT428脚本使用墙上时间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 溢出后溢出前正常TIME_WAIT 出现 → 约 30 秒后过期 → 回到 0观测数据0, 5, 7, 2, 0, 0, 3, 3, 0, 0 ……低幅波动溢出后异常TIME_WAIT 出现 → 永不过期 → 持续累积观测数据399, 412, 428, 443, 458, 473, 487, 502 ……单调增长根本原因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.hextern uint32_t tcp_now; /* 用于 RFC 1323 时间戳 */#define TCP_RETRANSHZ 1000 /* TCP 时间戳的精度1 毫秒 */这是一个 32 位无符号整数以毫秒为单位递增跟踪自开机以来的时间。每当 TCP 子系统需要获取当前时间戳时会调用 calculate_tcp_clock()基于 XNU 内核源码分析void calculate_tcp_clock(void){ uint32_t current_tcp_now; struct timeval now; microuptime(now); current_tcp_now (uint32_t)now.tv_sec * 1000 now.tv_usec / TCP_RETRANSHZ_TO_USEC; uint32_t tmp os_atomic_load(tcp_now, relaxed); if (tmp current_tcp_now) { os_atomic_cmpxchg(tcp_now, tmp, current_tcp_now, relaxed); }}关键在于这一行(uint32_t)now.tv_sec * 1000。当系统运行了 4,294,967 秒约 49.7 天后这个乘法结果超过了 uint32_t 的最大值 4,294,967,295。强制转换为 uint32_t 会导致无符号整数回绕——数值从接近最大值直接跳回接近零。为什么 tcp_now 在溢出后会冻结漏洞出现在这一段保护逻辑中if (tmp current_tcp_now) { os_atomic_cmpxchg(tcp_now, tmp, current_tcp_now, relaxed);}设计意图很简单“tcp_now 必须只向前移动。”在正常情况下这段逻辑工作正常。但在溢出瞬间溢出前tmp 4,294,960,000接近 uint32 最大值溢出后current_tcp_now 5,000回绕到接近零比较4,294,960,000 5,000 → false旧值 tmp接近最大大于 new 值 current_tcp_now回绕到接近零cmpxchg 永远不会执行。结果tcp_now 锁定在溢出前的值再也不会更新。内核的 TCP 时钟彻底停止。TIME_WAIT 过期检查失效机制当 TCP 连接进入 TIME_WAIT 状态时内核会记录一个绝对过期时间。在bsd/netinet/tcp_timer.c文件中add_to_time_wait_locked()函数实现了该逻辑static void add_to_time_wait_locked(struct tcpcb *tp, uint32_t delay){ uint32_t timer tcp_now delay; // absolute expiration time tp-t_timer[TCPT_2MSL] timer; TAILQ_INSERT_TAIL(tcp_tw_tailq, tp, t_twentry);}此处延迟时长计算公式为延迟 2 × TCPTV_MSL 2 × 15000 30000毫秒。内核的垃圾回收函数tcp_gc()会周期性扫描 TIME_WAIT 队列TAILQ_FOREACH_SAFE(tw_tp, tcp_tw_tailq, t_twentry, tw_ntp) { if (TSTMP_GEQ(tcp_now, tw_tp-t_timer[TCPT_2MSL])) { tcp_close(tw_tp); // expired — reclaim }}TSTMP_GEQ宏定义在bsd/netinet/tcp_seq.h文件中#define TSTMP_GEQ(a, b) ((int)((a)-(b)) 0)这是一种标准的有符号模运算比较方式专门用于处理序列号回绕问题。正常情况下tcp_now持续递增当tcp_now大于等于过期时间时该宏会返回 true连接会被内核清理。但当tcp_now被冻结时tcp_now 4,294,960,000 (frozen at pre-overflow value)timer 4,294,960,000 30,000 4,294,990,000 (exceeds uint32 max → wraps to a small number)TSTMP_GEQ(4294960000, 4294990000) (int)(4294960000 - 4294990000) (int)(-30000) -30000 0 ? → false!计算结果一直是 false连接永远不会被回收。完整因果链System uptime reaches 49 days 17 hours 2 minutes 47 seconds ↓microuptime() returns a millisecond value exceeding 2³² ↓(uint32_t) cast causes the value to wrap around to near 0 ↓calculate_tcp_clock(): if (tmp current_tcp_now) evaluates to false ↓tcp_now stops updating — frozen at its last pre-overflow value ↓TSTMP_GEQ(tcp_now, timer) is permanently false for new TIME_WAIT entries ↓TIME_WAIT connections never expire — they accumulate indefinitely ↓Ephemeral ports gradually exhaust ↓New TCP connections fail at SYN_SENT (no ports available) ↓Application-layer timeouts, services become unreachable连锁反应从时钟冻结到 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 实现上的缺陷。社区中一致的故障现象报告苹果社区论坛和开源项目中有多份报告描述的症状与该漏洞完全吻合苹果社区帖子 #250867747macOS Catalina 下“无法建立新的 TCP 连接”。新连接进入 SYN_SENT 后立即关闭已有连接不受影响只有重启才能恢复。苹果社区帖子 #252991075“Mac Pro TCP/IP 停止工作”。TCP 完全失效但 pingICMP仍然正常。Podman 问题 #12495在 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计算溢出时间boot_sec$(sysctl kern.boottime | grep -o sec [0-9]* | head -1 | awk {print $3})now_sec$(date %s)remain$(( 4294967 - (now_sec - boot_sec) ))echo Time until overflow: $((remain/3600))h $((remain%3600/60))m $((remain%60))s步骤 2在溢出前后监控 TIME_WAIT 数量while true; do tw$(netstat -an | grep -c TIME_WAIT) echo $(date) TIME_WAIT$tw sleep 5done步骤 3在溢出窗口内生成连接for i in $(seq 1 10); do curl -s -o /dev/null --connect-timeout 2 --max-time 2 https://1.1.1.1 done步骤 4观察现象停止生成连接等待 2 分钟。如果 TIME_WAIT 数量没有下降说明漏洞已复现。9.5 小时后亲眼见证系统瘫痪溢出后我们没有重启而是让两台机器继续运行观察漏洞自然恶化的全过程。溢出后 9.5 小时的系统状态PDT 18:02Machine A (uptime: 50 days, 2:33): TIME_WAIT: 4,888 SYN_SENT: 3,044 ESTABLISHED: 37 FIN_WAIT_1: 9 LAST_ACK: 3 Load: 1.62 Machine B (uptime: 50 days, 2:33): TIME_WAIT: 8,217 SYN_SENT: 3,315 ESTABLISHED: 38 FIN_WAIT_1: 9 LAST_ACK: 23 CLOSING: 2 Load: 49.74TIME_WAIT 累积趋势没有任何一个 TIME_WAIT 连接被回收。数量只增不减。SYN_SENT 堆积新建连接大量失败溢出 9.5 小时后两台机器都积累了 3000 以上的 SYN_SENT 连接这是 TCP 端口耗尽的典型表现出站连接卡在三次握手的第一步无法申请到端口临时端口被永不释放的 TIME_WAIT 占用只剩下 37–38 个 ESTABLISHED 连接已有的长连接仍然正常但新建连接几乎无法建立机器 B 的系统负载飙升到 49.74因为内核在不断扫描不断膨胀的 TIME_WAIT 队列消耗大量 CPU这与我们预测的恶化过程完全一致TIME_WAIT frozen (confirmed) → Ports gradually exhaust (TIME_WAIT: 4,888–8,217) → SYN_SENT pileup (3,000, new connections failing) → System load spikes (49.74) → TCP effectively paralyzed, only ICMP works → Only recovery: reboot结论一个 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 冻结的临时修复方案。在此之前请在时钟溢出前安排重启。推荐阅读马斯克最新对话AI 毁灭人类的概率有 20%但它将创造一个没有钱的“全民高收入”时代华人辍学博士揪出Claude Code 51万行源码泄露官方请求下架超8000个GitHub代码库并回应这次是人为失误无人被解雇仅花16小时、成本不到7元把Mac爆改成“触摸屏”不用AI、不改硬件他们用一个镜子解决一切【活动分享】48 小时与 50 位大厂技术决策者共探 AI 落地真路径。由 CSDN奇点智能研究院联合举办的「全球机器学习技术大会」正式升级为「奇点智能技术大会」。2026 奇点智能技术大会将于 4 月 17-18 日在上海环球港凯悦酒店正式召开大会聚焦大模型技术演进、智能体系统工程、OpenClaw 生态实践及 AI 行业落地等十二大专题板块特邀来自BAT、京东、微软、小红书、美团等头部企业的 50 位技术决策者分享实战案例。旨在帮助技术管理者与一线 AI 落地人员规避选型风险、降低试错成本、获取可复用的工程方法论真正实现 AI 技术的规模化落地与商业价值转化。这不仅是一场技术的盛宴更是决策者把握 2026 AI 拐点的战略机会。

更多文章