Infidel 疯狂了
摘要
Andrew Plotkin(zarf)用内存级调试器逆向工程了 Infocom 的经典冒险游戏《Infidel》,并揭开了 ZIL 代码中一个极其恶劣的野指针编译器 bug——正是这个 bug 管理着被丢弃在无尽沙漠中的物品。
暂无内容
查看缓存全文
缓存时间: 2026/10/04 23:37
# Infidel 走火入魔
来源:https://blog.zarfhome.com/2026/10/infidel-goes-wild
*Planetfall* (https://eblong.com/infocom/visi/planetfall/) 是昨天公诸于世的,但过去几周我一直在集中精力攻克 *Infidel*。几天前我碰上了迄今为止在 Infocom 游戏中见过的最恶劣的 bug。这个 bug 极难通过正常游玩发现。我多半是第一个注意到它的人——只是因为我是在用(相当于)内存级调试器玩这款游戏。那么我为什么称它为*恶劣的*bug 呢?大多数人会说这是个"无关紧要的"bug,因为它几乎从不影响游玩体验。但你听着:它是一个*野指针 bug*,会以意料之外的方式乱写内存。作为 C 程序员,我在法律上有义务把内存破坏视为所有可能罪孽中最深重的一种。而且,它还是个编译器 bug!
(警告:本文将深入 ZIL 代码和 Z-machine 实现细节。请注意,我分析的是 *Infidel* 第 22 版、序列号 840522,即 Macintosh 的更新版。原版发布、序列号 830916 存在同样的 bug,只是内存地址略有不同。)
---
让我们先把场景布置好。*Infidel* 的故事发生在偏远的埃及沙漠。你一开始在自己的营地里。尼罗河在西边。东边有九个沙漠地点,呈 3×3 网格排列。(其中之一埋着你正在寻找的金字塔。)如果你闯出那片区域,或者往营地以外的任何方向离开,你就正式在沙漠中迷路了。你愿意走多远就走多远;只会遇到更多的沙漠。(直到中暑找上你为止。)
这是个出了名的手法,最早见于 *Enchanter*:一个名为 `ENDLESS-DESERT` 的单一房间代表所有未绘制的沙漠地点。当你移动时,如果既没有误打误撞回到营地、也没有回到已绘制区域,你就只是循环回到 `ENDLESS-DESERT`。游戏会记录你的经纬度。为了使这一切显得真实,游戏必须不断腾挪物品。如果你在 `ENDLESS-DESERT` 里丢下东西然后移动,那些物品就会被移出场景。它们的经纬度坐标会被记录在一个名为 `DESERT-TABLE` 的数组中。如果你稍后回到那些坐标,游戏就能把这些物品从 `DESERT-TABLE` 挪回 `ENDLESS-DESERT`,你会发现它们正等着你。(大概吧。当你在 `ENDLESS-DESERT` 里丢下一个物品时,有三分之一的几率它会立刻被沙子掩埋、永远遗失。这与此事无关,只是它让 bug 更难被发现,后面我们会看到。)
好吧,到目前为止这个方案看起来相当稳妥。来看看它的实际运作:
```
沙漠
你身处沙漠之中,一片炎热的浩瀚沙海。
> DROP AXE, SHOVEL
镐:已丢下。
铲子:已丢下。
一阵短暂而强烈的风从沙丘上刮来,沙子扑打在你的脸上,遮住了你的视线,久到你跟丢了那把铲子。
> EAST
沙漠
你身处沙漠之中,一片炎热的浩瀚沙海。
> WEST
沙漠
你身处沙漠之中,一片炎热的浩瀚沙海。
这里有一把镐。
```
两个地点其实是同一个 `ENDLESS-DESERT`,但镐维持了不同房间的假象。(如果铲子是在你背转身的时候悄无声息地被沙子掩埋,岂不是更真实?也许那样测试起来效果不佳。)总之,这看起来完全正常。bug 在哪儿呢?
---
我们来看看让这一切得以实现的 `DESERT-TO-TABLE` 例程(https://github.com/historicalsource/infidel/blob/dca8235af85e3d69bd49119cade8fff5ac8eb271/camp.zil#L1751):
```
) N) >) (ELSE )> ) ( 0> .F> > ) (ELSE >)>>)> >>
```
还有一个类似的 `TABLE-TO-DESERT` 例程,用来把东西带回来。我们先聚焦在这个例程上。它有一个必需参数:`SLOC`,即经纬度坐标。(这被编码成单个数字,但先别管这个。)然后 `"AUX"` 标记出四个可选参数,它们同时也充当局部变量;Z-machine 并不区分这两者。`TBL` 是我们要写入的数组地址,其默认值为 `DESERT-TABLE`。`F` 被初始化为 `ENDLESS-DESERT` 内容列表中的第一个对象。`CNT` 和 `N` 被初始化为零。
碰巧的是,在调用这个例程时,游戏只传入了一个参数,也就是 `SLOC`。它依赖于 `TBL=DESERT-TABLE` 的默认值。(如果游戏里有*两个*无尽沙漠,它会想用两张不同的表来调用这个例程。但游戏里没有。)`DESERT-TABLE` 是一个 100 个值(200 字节)的数组,起始地址为 11129。函数体遍历 `ENDLESS-DESERT` 中的所有对象(从 `F` 开始)。每个*可携带*对象(忽略景物和玩家)都会被移除,我们向 `TBL` 数组追加两个值:坐标 `SLOC` 和对象 ID。表中可能已经有条目了(你可以把东西丢在多个地方),所以我们会小心地寻找空槽来填入。遍历到列表末尾就算完成了。如果这段逻辑看起来有点绕,记住我们操作的是一个链表。到这里一切听起来都没问题吧?我也觉得没问题。
"嘿,"我对自己说,"我应该在 State 标签页里显示 `DESERT-TABLE` 的内容!这样人们就能看着物品被挪进挪出了。"于是我搭建好……然后就没用了。`DESERT-TABLE` 数组顽固地保持为空。我*能看见*物品消失又出现——看看上面那把镐就知道了!——但它们去哪儿了?任何看到定长数组的 C 极客都会问:"数组会不会越界?"但 `DESERT-TABLE` 上有注释:
```
;"length should be 2*number of takeable objects"
```
确实,每条记录占两个值,100 个值足以容纳 50 个对象。把游戏里所有可携带对象堆在一起,大概也只有 45 个。总之,数组里的*任何地方*都没有存下东西。
---
是时候从游戏文件里反汇编 `DESERT-TO-TABLE` 函数来看看了。(我说过我们会深入到这种地步……)
```
Routine 109bc, 5 locals (0000, 001e, 0000, 0000, 0000)
109c7: GET_CHILD ENDLESS-DESERT -> .F [TRUE] 109cb
109cb: JZ .F [TRUE] RTRUE
109ce: GET_SIBLING .F -> .N [TRUE] 109d2
109d2: JE .F, G70 [FALSE] 109d9
109d6: JUMP 10a02
109d9: TEST_ATTR .F, #0f [FALSE] 10a02
109dd: LOADW .TBL, .CNT -> -(SP)
109e1: JZ (SP)+ [FALSE] 109fb
109e4: STOREW .TBL, .CNT, .SLOC
109e9: ADD .CNT, #01 -> -(SP)
109ed: STOREW .TBL, (SP)+, .F
109f2: ADD .CNT, #02 -> .CNT
109f6: REMOVE_OBJ .F
109f8: JUMP 10a02
109fb: ADD .CNT, #02 -> .CNT
109ff: JUMP 109dd
10a02: STORE .F, .N
10a05: JUMP 109cb
```
(如果你看的是 1983 年的版本,这个例程位于地址 `1051a`,其余完全相同。)我使用的是 ztools(https://ifarchive.org/indexes/if-archive/infocom/tools/ztools/)里的 `txd` 反汇编工具,但我对输出做了编辑,以显示变量名。注意操作码名称与 ZIL 术语并不匹配。`txd` 出自 1990 年代初。那时我们没有 ZIL 手册,所以只能自己编出一套操作码名称。
第一行把 `F` 的初值设为房间里的第一个对象(``)。第二行测试 `F` 是否为零,这正是函数中 `REPEAT` 循环的条件。循环就从这里继续。注意到有什么缺失了吗?`TBL` 是在哪里被初始化为它的默认值 `DESERT-TABLE`(11129)的?这看起来是个显而易见的漏洞。啊哈,ZIL 会说:这点我们早有准备。Z-machine 函数有若干槽位,可以把局部变量(或可选参数)初始化为常量。这体现在头几行中:
```
Routine 109bc, 5 locals (0000, 001e, 0000, 0000, 0000)
```
`CNT` 被初始化为零,`N` 也是,虽然是隐式的。`F` 并*没有*从槽位中初始化,因为它的值是计算得出的,而非常量。ZIL 会在函数开头生成那行 `FIRST?`(`GET_CHILD`)。`DESERT-TABLE` 是一个常量,所以……
……哎呀。不,它不是常量。`DESERT-TABLE` 是一个全局*变量*。它的值永远不会变化——整个游戏中都将是 11129——但 ZIL 并不知道这一点!`TBL` 被初始化为常量 30(十六进制 `1e`),而这正是 `DESERT-TABLE` 的*索引号*。(出于无聊的原因,全局变量的编号是从 16 到 255。`DESERT-TABLE` 是第 15 个全局变量,所以编号为 30。)显然没有人考虑过这种情况(参数的默认值是一个全局变量的值)。如果他们考虑过,ZIL 要么会显示一个编译器错误("默认值不是常量!"),要么会在函数开头生成一条 `STORE` 指令(就像 `F` 被初始化的那样)。30 简直就是错的。
结果就是,当 `DESERT-TABLE` 开始往内存里写入对象编号时,它写的并不是地址 11129。它覆盖了从地址 30 开始的内存。哦,糟了。
`TABLE-TO-DESERT` 例程以完全相同的方式使用了默认的全局参数。所以它从地址 30 把对象列表*取回*,表面上结果是正确的。但*内存已经被踩坏了。*
---
那么地址上有什么呢?内存的前 64 字节是头部数据,但头部并非全部都被使用。纯属幸运,在 Z-machine 第 3 版中,只有地址 0–29 是有意义的。如果 `DESERT-TABLE` 是第三个全局变量、索引号 18,这个 bug 就会破坏游戏的序列号!那可就明显得多了。所以地址 30–63 是未使用的,但地址 64 是缩写数据的起点。这是 Z-machine 文本压缩方案的一部分:一个由 96 个高频词组成的列表(https://github.com/historicalsource/infidel/blob/master/infidelfreq.xzap),正因为出现频繁,可以把它们抽取出来、用几个比特代替。预测:如果我们往沙漠里丢下九个物品(然后走开),最后一个物品就会覆盖缩写表的开头。要凑齐这件事有点麻烦。别忘了,丢下的物品有三分之一的几率会直接消失。我不得不往那边拖了一大堆东西,试了好几次。但我成功了,游戏接下来打印的文本变成了这样:
```
沙漠
你身处沙漠之中,一片炎热的浩瀚沙海。
一只小蜥蜴从 You 沙中探出头来,问 You 最短的路线去时代广场。你抓了一会儿头,
等 You 想起该推荐哪条地铁线路时,你发现它已经消失了。
```
这当然是热出来的幻觉,但注意"the"(第一个缩写)已经被替换成了"You"(第二个缩写)。这表明早期的字符串数据已经被无意义的内容覆盖了,而且碰巧没有终止符,于是打印例程继续输出到下一个有效字符串。在另一次测试中,我设法让解释器崩溃了。我怀疑它把*表示*第一个缩写的那些比特覆盖在了第一个缩写上,于是打印例程陷入了无限循环。你知道的。人总会干这种事。
---
好了!bug 演示完毕!我们学到了什么?
这个 bug 就这么发布了,而且绝对没有人注意到。当我小时候玩 *Infidel*、探索沙漠时,我的游戏其实一直在破坏内存。其他每一位玩家都是如此,包括 Infocom 的测试人员。极有可能从来没有人尝试在无尽沙漠里丢下超过八个物品。你为什么要那么做呢?那儿什么都没有!那儿不顺路。整套机制存在的唯一目的,就是为那些尚未发现金字塔的玩家提供消遣。这种对细节的关注是 Infocom 的典型风格,但那片区域显然没有被充分测试过。(我非常想翻阅 Infocom 的测试日志,看看有没有留下任何与这个 bug 有关的蛛丝马迹。The Infocom Cabinet(https://archive.org/details/infocomcabinet)里似乎没有任何相关的内容。如果你碰到类似的东西,请告诉我。)
游戏 1984 年的修订版对这个 bug 毫无作为。Infocom 并没有类似 Visible Zorker 的东西。他们可能有可以检视内存的调试工具,但没有人想到把这些工具用在这些例程及其存储上。我还没有调查过后来的游戏,看看是否存在类似的 bug——或者看看编译器是否正确处理了这种情况。我会留意的。
那么该如何修复这个 bug?很简单:去掉 `TBL` 的参数默认值,改为让例程以`` 开头。或者把 `DESERT-TABLE` 作为第二个参数传给例程。两种办法都行。还有一对例程 `PROP-TO-TBL`、`TBL-TO-PROP`,它们对 `PROP-TBL` 表(地址 11329,全局变量编号 22)存在类似的 bug。说实话,我对这些例程有点困惑。它们看起来是在做类似的腾挪工作,处理的是你可以在沙子里挖的洞。(这些洞在沙漠房间中以 `CAPACITY` 属性的形式存储。)但游戏不允许你在 `ENDLESS-DESERT` 里挖掘;它会说"这里的地面太硬了。"所以这些例程似乎毫无用处。它们可能是开发过程中某个被移除功能的遗留物。也许是因为那个功能会破坏序列号吧!如果由我来更新 *Infidel*(区别于 riffing 它(https://ifdb.org/viewgame?id=wvs2vmbigm9unlpd)),我肯定会恢复在沙漠里挖毫无用处的洞的能力。那种无意义的细节,正是交互小说的一大乐趣所在。
相似文章
解决「Zork」之谜
作者调查了作为麻省理工学院未完成程序的行话术语「zork」的矛盾起源,发现了维基百科来源中的不一致之处,并纠正了自己早先的说法。
逆向工程 Claude Web 的 MicroVM:揭秘 Anthropic 的隐藏应用托管平台
这篇博客文章详细介绍了对 Claude Code Web 运行时环境的逆向工程过程,揭示其使用了 Firecracker MicroVM,并发现了 Anthropic 未公开的应用托管平台。
objdump -g 中的任意代码执行
objdump -g 中存在一个安全漏洞,由于 FR30 重定位处理程序缺少边界检查,通过精心构造的 FR30 目标文件可实现任意代码执行,单个漏洞利用即可绕过 ASLR 及其他缓解措施。
使用ImHex逆向工程未知文件格式
本文展示了如何使用ImHex这款免费开源的十六进制编辑器来逆向工程游戏FEZ的存档文件格式,提供了从二进制分析到模式定义的实用指南。
C程序员再犯可读性罪行
2025年国际混淆C代码大赛获奖者揭晓,共有23个参赛作品,其中包括Adrian Cable的Subleq计算机模拟器,该模拟器通过单指令集架构实现软件保存。