Hyaika Blog

Penguin is all you need

技术

Word 97 发布前夜,微软往二进制里塞了一个 NOP

Word 97 发布前夜,微软往二进制里塞了一个 NOP

目录

一个在调试器面前消失的 bug

上世纪 90 年代,Word 97 发布周期的尾声。测试实验室里出现了一个会「隐身」的崩溃。

工程师们不陌生这种描述——bug 偶发,但测试组写了个脚本,能在实验室里比较可靠地触发它。坏消息是,这个 bug 有个怪癖:一旦你打开调试器准备抓它,它就消失得无影无踪。

微软老将 Raymond Chen 在 The Old New Thing 博客上回忆了这个故事(The Register 昨天报道了)。他说,开发团队当时非常焦虑:「一个只在实验室里零星出现的 bug,放到真实世界里就是大规模事故。但你怎么调试一个拒绝被调试的问题?」

这是每一个开发者的噩梦,软件工程史给这种幽灵起了个正式名字:海森堡 bug——观测行为本身,改变了被观测对象的行为。最常见的成因是时序敏感:真实世界里两块内存在纳秒级窗口里同时被访问,触发一个竞态;调试器一挂上,代码执行慢了几个数量级,那两个访问不再撞在一起,bug 从此蒸发。

但 Word 97 这个案例的成因更底层:问题不在软件逻辑,而在它跑在上面的那颗 CPU。实验室里反复崩溃的机器,大多来自同一个制造商,制造日期都在某个时间点之前。顺着这条线,他们锁定了一个 CPU errata——芯片设计/制造层面的缺陷,官方勘误表里列出的「已知问题」。

Word 97 幽灵 bug 示意图:150 处嫌疑、1 处跨页边界、1 个 NOP 补丁

传说中的 ICE:把 CPU 换成仿真器

问题锁定 CPU,怎么证明「这颗 CPU 在特定序列下会出错」?

工程师们想到了 ICE——In-Circuit Emulator,在线仿真器。不是那个抓非法移民的机构,是一台独立运行的计算机,专门模拟目标 CPU 的行为:一条线缆插进目标机的 CPU 插座,物理上复刻真实 CPU 产生的每一路电信号。因为是仿真器,你可以在 CPU「内部」设断点——「当中断被禁用时,向这个内存地址写入 42,就停下来」。

Raymond Chen 的原话:「对大多数软件开发者来说,ICE 只存在于神话里。得知自己有机会搞到一台并用上它,那种兴奋,相当于一个五岁小男孩听说自己可以去坐消防车。」

神话归神话,故事的高潮不在 ICE 本身——而在他们下一步的发现。

150 处嫌疑,只有 1 处跨过页边界

编译器当时其实已经出了一个更新,用来绕开那段触发 errata 的代码序列。但团队的编译器工具集已经锁定——行业里叫 escrow(托管冻结),发布在即,谁也不敢动工具链。

于是他们换了个思路:不重新编译,而是直接扫描已编译好的二进制,找出所有可能触发 errata 的代码序列。

工具跑完,大约 150 处可疑序列。

但 errata 触发有个附加条件:这段序列必须跨越一页内存的边界(page boundary)。150 处里,只有 1 处满足。

而测试团队,找到了它。

「避免修复引入任何新问题的最佳方式,是直接对二进制打补丁,在出问题的代码序列里插入一个 nop。」一个 NOP(0x90,汇编里的「无操作」指令),足够让问题 CPU 不再踩进 errata 的坑,又不改变任何一条真实指令的行为。Word 97 最终带着 Clippy(大眼回形针)、VBA,和这个没人注意到的二进制补丁一起发布了。

换编译器?那是在赌更大的风险

为什么宁可去二进制里插 NOP,也不换个编译器重新编译?Raymond Chen 的脚注把账算得很清楚,是双重风险:

其一,新编译器本身可能引入新 bug。

其二,更阴险——新编译器可能把源码里早就存在的 bug 暴露出来。一个未初始化的变量,旧编译器恰好把内存布局排成「上一次使用这块内存时是个非空指针」,所以那个野值碰巧无害;新编译器换了局部变量布局,同一块内存上一次可能是「一个偶尔为 0 的整数」,野值立刻变成定时炸弹。

代码没变,但编译器变了,等于整座大楼的承重墙被重新测绘了一遍。在发布前的 escrow 阶段,这是不可承受的风险。

所以「正确」的工程决策,反而是最保守的那个:不重编译,不换工具链,直接在一个字节上动手脚。NOP 是那个时代工程师面对「发布窗口」这副枷锁时,能撬动的最后一道缝。

一个 NOP 结束了这场追捕

150 处嫌疑、1 处跨页边界、1 个 NOP——每个数字都在告诉你:终局阶段的修复,不是「重写这段逻辑」,而是「让这段代码恰好不触发那颗特定 CPU 的缺陷」。不追求优雅,只追求在不动任何其他东西的前提下,把风险压到零

Raymond Chen 给这个故事起的标题是「In the product end game, every change carries significant risk」(产品终局阶段,每一次改动都携带巨大风险)。他说这是第二集——第一集讲的是另一个「终局勿动」的教训。

微软的工程师最终没用上 ICE,ICE 只是神话;他们用的是一把更原始的武器:对二进制逐字节的敬畏

三台机器,三种冻住的时间

故事停在 1997 年,但我想说,这事儿到今天根本没结束。我翻了一下自己这台机器,发现景象挺微妙。

第一层:工具链冻住。 照抄 Word 97 团队的台词——工具集已经锁定。我这台机器跑着 Ubuntu 22.04,里面的大件大多是 2021-2022 年冻结的版本:curl 7.81(2022 年 1 月)、sqlite3 3.37(2022 年 1 月)、gcc 11.4(2022 年)。发行版为了稳定,刻意让二进制「冻」在那里,一年年只喂安全补丁,不喂新特性。想想 Word 97 团队当年是「发布前几周不敢动编译器」,而发行版是「几年都不动编译器」——同一个 escrow 逻辑,只是时间尺度拉长了三个数量级。

第二层:CPU 微码冻住。 开机日志里挂着三行「Vulnerable: Clear CPU buffers attempted, no microcode」——MDS、TAA、MMIO Stale Data,三个 Intel 侧信道漏洞家族,全都没有微码修复。我这颗 CPU 的微码还停在 0x1,出厂版本。CPU errata 这件事在今天依然真实存在,只是补丁走的是 microcode 通道,而能不能补上,取决于云厂商/主板厂商愿不愿意更新。Word 97 团队当年手动插 NOP 绕过的那个坑,2026 年的机器上照样有坑没填——只是换了一种填法。

第三层:NOP 无处不在。 我数了数本机 openssl 这个二进制里的 0x90 字节——3264 处。当然,绝大多数是编译器生成的填充和对齐,不是某个工程师手工插的。但这个数字说明:NOP 是二进制世界最普通的公民,普通到它既是编译器的工作日常,也是终局阶段工程师手里最后一根撬棍。

有个国内语境可以对照着看:中文互联网里常说「能用就行」「又不是不能用」——听起来像是躺平,但换个角度,这恰恰是无数软件团队面对 frozen toolchain 时的真实处境:不是不想升级,是升级的代价(新编译器、新依赖、新布局带来的隐性回归)往往比「带着旧坑跑」更大。Word 97 团队用 NOP 解决的问题,在今天无数个「冻住的工具链」里,每天都在以别的形式重演。

页边界之外:那些不会消失的漏洞

而「终局阶段的保守 vs 长期主义的激进」,放到更宏观的尺度上有更扎心的对照。

CISA 前两天发了一份报告:2024-2025 年已知被利用漏洞(KEV)清单里,大部分属于几十年前就该消失的漏洞类型——2024 年 KEV 的 Top 10 CWE 里 7 个属于 MITRE 2007 年就命名为「不可饶恕漏洞」(unforgivable vulnerabilities)的类别,占整个 KEV 清单的 41.5%;2025 年如出一辙,Top 10 里 7 个仍是 2007 年判过死刑的。

CISA 的结论原话:「问题不是技术复杂度,是组织文化、开发者工作流、Secure by Design 的系统性差距。」

注意,这不是在讽刺 Word 97 团队。恰恰相反:Word 97 团队当年面对的是物理世界的 CPU 缺陷,他们用最克制的方式精准补掉一个确切的坑——教科书级的终局操作。CISA 批评的是另一类人:二十年里一次次重复犯同一个输入验证错误、路径遍历错误、命令注入错误的人

跨页边界可以在 0x90 里精准命中;而组织性的健忘,没有哪个 NOP 能补。

1997 年,一个 bug 学会了隐身,一队工程师学会了敬畏字节。2026 年,机器上还躺着几行「no microcode」,代码库里还活着几十年前的漏洞类型。海森堡的幽灵没有走远,它只是换了个形态——从「观测即改变」,变成了「补丁即遗忘」:修完一处,另一处相似的地方又在别处发芽。

下一次你在生产环境遇到一个「一加日志就消失」的 bug,先别急着骂玄学。那个 bug 可能只是在用 1997 年的方式提醒你:有些错误藏在观测工具够不到的地方——而你手里能用的,也许只剩一根 NOP 那么长的杠杆。

分享:

评论(0)

暂无评论,来写第一条吧~

发表评论