深度解析塑造未来的技术文章。

Switch 2 越狱:击穿安全启动的漏洞链

Switch 2 越狱串联了 SmileBASIC 整数溢出、fs 路径溢出和加载器竞争条件,揭示漏洞链与安全启动的启示。

一台掌机连接远程控制硬件,机械拉杆在工作台上按下它的电源键。
现代主机破解装置通常将 KVM-over-IP 与一个蓝牙拉杆组合使用,每次崩溃后,拉杆会实际按下电源键。

一台全新的主机出厂时配备了加固微内核、每个返回地址上的指针认证(PAC)、仅执行内存、控制流完整性(CFI)以及签名启动链。两周后,一名业余爱好者就已经能以文件系统服务的身份运行任意原生代码,而这是整台机器上最受信任的进程。这就是首个公开的 Switch 2 越狱故事,任何构建安全系统的工程师都应该为此感到一丝不安。并不是因为防护机制失效了,它们大多数都按设计正常工作,而是因为这个漏洞根本不需要正面突破这些防护。它绕了过去,靠的是一个又一个的逻辑缺陷。

我在基础设施安全的防守一侧工作多年,这里的模式我在生产事故复盘中见过的次数多到我不想再数:一串单独看并不惊人的漏洞,每一个都只能换来刚好够用的权限,去够到下一个环节。最初的立足点甚至不在 Switch 2 本身,而是 Switch 1 游戏中的一个内存破坏漏洞,即运行在向后兼容模式下的 SmileBASIC 解释器。从那里开始,攻击链一路向上穿过文件系统服务,利用了程序加载器中的一个竞争条件,最终获得了以任意系统模块身份运行原生代码的能力。每一环都值得仔细看看,因为每一环都对应着我在服务端系统中也见过团队犯的错误。

为什么浏览器这扇门关上了,而游戏这扇门没关

WebKit 向来是主机越狱的第一个目标,研究者也确实通过强制门户和一个 n-day 漏洞拿到了立足点,但很快就走进了死胡同。Switch 2 的浏览器栈外面包裹着三层防护,它们组合起来的效果非常狠:仅执行内存(XOM)意味着即使有任意读写,你也无法定位有用的代码 gadget;指针认证(PAC)意味着没有签名预言机,你就无法伪造代码指针;CFI 则意味着即便是合法签名的指针,在大多数调用点也通不过类型检查。突破其中任何一层都是一个研究项目,而从 JavaScript 沙箱出发把三者串起来全部突破,足够写进一辈子的职业生涯了。

于是攻击转向了机器上最薄弱的“租户”:第三方 Switch 1 游戏。这个思路冷静而正确。游戏二进制文件由工作室构建,而不是由任天堂的安全团队构建,因此它们很少会用完整的防护栈编译;而 Switch 1 根本没有 PAC,所以运行在其兼容环境中的任何东西都会继承这份软弱。更妙的是,Switch 1 的信任根在多年前就已经被攻破,这意味着它的游戏代码可以被离线解密和审计。SmileBASIC 是一个庞大的 C++ BASIC 解释器,此前在 3DS 上就已经被利用过,它自然成了显而易见的软目标。

这里有一个超出主机范畴的教训:你的安全水平,取决于你同意运行的、加固程度最低的那段代码。向后兼容是你继承来的攻击面,而不是你可以自主选择的。Xbox 的安全模型在 2000 年代就是吃一堑长一智:未加固游戏中的存档漏洞,正是撬开一个严密得多的内核的楔子,我们在原版 Xbox 破解对硬件安全模型的启示一文中讲过这个故事。二十年过去了,同样的招数依然好用。

打开第一扇门的整数溢出

SmileBASIC 的这个漏洞是边界检查中的一个教科书式整数溢出,正是每份安全代码审查清单都要求排查的那类问题,却仍然不断地被发布出去。解释器的数组删除命令是这样校验参数的:

// intent: ensure the removed range is inside the array
if (position + count <= array.length) {
memmove(backing + position * 8,          // start of the gap
backing + (position + count) * 8, // elements after the gap
(length - position) * 8);          // bytes to copy
}

在一个四元素数组上调用 REMOVE R, &H60000000, &H3FFFFFFF,position + count 会回绕为 0x9FFFFFFF。作为有符号 32 位值,它是一个极大的负数,轻松就“小于”数组长度。乘以 8 的运算同样会回绕,最终执行的是 memmove(backing, backing - 8, 32)。命令之后会打印一条内存不足的错误,但复制操作已经发生了。更妙的是:每个 SmileBASIC 数组前面的 8 个字节,保存的正是指向数组自身的指针。删除之后,R[0] 会被解释为一个字符串,其长度字段恰好就是那个指针,也就是一个长得离谱、可以通过它读写的字符串。任意读写就这样被解释器自己的内存布局双手奉上了。

如果你是以写 C 或 C++ 为生的人,请盯着那个检查看一分钟。position + count <= length 是系统代码里最常见的边界检查写法,而只要求和可能溢出,它就是错的。正确写法是 position <= length && count <= length - position,或者使用带检查的算术运算。我在以前的一份工作中,在一个数据包解析器和一个开源存储引擎里,都发现过这个漏洞的变体。它能通过代码审查,是因为它看起来明显正确。这就是规范与实现之间鸿沟的缩影:规范说的是“校验范围”,实现校验的却是范围旁边的某个东西,而这个差异恰恰可以被利用。我们之前写过规范与实现之间的鸿沟正是 bug 藏身之处;主机解释器只是让这些 bug 被有动机的少年发现,而不是被模糊测试工具发现。

借助路径处理溢出提权到文件系统服务

游戏进程内部的用户态读写本身没什么用,因为 Switch 的微内核设计意味着每个服务都是独立进程,拥有各自的 IPC 接口。真正有价值的目标是 fs,即内置的文件系统系统模块,原因非常有趣,甚至带点递归:fs 正是那个向加载器提供解密后代码的组件。拿下 fs,你就能导出其他每个系统模块的代码进行离线分析。堡垒的军需官亲自把设计图纸递到了你手里。

这个漏洞是路径规范化中的堆溢出。GetEntryType IPC 命令会对其路径参数进行规范化,而包含带反斜杠的父目录组件(/..\)的路径会进入一个辅助函数,该函数分配一个固定 0x301 字节的缓冲区,然后在没有足够长度检查的情况下把路径复制进去。足够长的路径会把缓冲区溢出到相邻的堆对象中。研究者自己在发现这个问题时的评论只有一个词:“lol。”

这个“lol”应该让人刺痛,因为路径规范化正是那种不起眼的管道代码,从来得不到加密代码那样的安全审视。攻击者把这个溢出变成了完整的任意读写:破坏了一个相邻的缓存对象,其脏位会把文件 I/O 重定向到攻击者控制的指针上,这是一个干净利落的堆整形原语。随后,利用程序通过内存扫描在 fs 的表中找到了自己进程的连接对象,并把其中存储的进程 ID 改写为 1,从而授予游戏进程对文件系统的“FullPermission”。注意这里发生了什么:没有任何一个密码学检查被破解。权限模型是靠内存中的数据来强制执行的,而攻击者能写内存。

一座多层堡垒的等距剖面图,一个小人物穿过狭窄的服务走廊,绕过了上锁的大门。
攻击链的每一阶段都是穿过一条没人想到要守卫的走廊,绕过了加固的大门。

通过在飞行中替换文件系统来绕过签名检查

整条攻击链中最巧妙的一环,是在不碰任何一把密钥的情况下绕过代码签名。程序加载器(ldr)在运行任何东西之前会验证两个签名:任天堂对进程权限元数据和开发者密钥的签名,以及开发者对内容归档头哈希的签名。理论上,这个哈希会间接固定可执行文件的每一个字节。但实际上存在一个缺口:fs 的职责是确保它提供的文件系统与它之前报告的哈希一致,而攻击者在 fs 内部拥有任意读写能力。

于是,利用程序会等待加载器请求可执行文件系统、并且 fs 已经交出有效哈希和签名的时机,然后把加载器 IPC 句柄背后的文件系统对象替换成一个精心构造的对象。它提供的是真正签名过的元数据文件,但可执行文件却被换成了任意的替代品。签名检查之所以能通过,是因为它们校验的字节攻击者根本无需修改。时序则是通过改写 fs 为加载器连接所等待的句柄来实现的,把它指向一个由游戏进程控制的事件,从而把竞争条件变成了确定性的中间人拦截。

从头哈希到可执行文件内容,这条密码学链条在纸面上是完备的。而唯一一个只作为运行时假设存在的环节,也就是“提供文件的进程是诚实的”,恰恰就是被打破的那个环节。

这是一个披着风衣的 TOCTOU(检查时间与使用时间)漏洞,也是整篇分析中最具可迁移性的教训。只要组件 A 校验了数据,而组件 B 随后通过一个可变引用基于它采取行动,就存在一个替换窗口。我曾审查过一个内部部署系统,它校验了容器镜像摘要,但在发布时却按标签从镜像仓库拉取,同样的漏洞换了顶帽子。两种情况的修复方式相同:在使用的那一刻再校验,或者把经过验证的不可变对象交给消费者,而不是交出一个需要依赖第三方保持诚实的句柄。假设每个中间环节都可能被攻破的防御性设计并非偏执;在一台攻击者本来就能部分写入的机器上,这只是准确的建模。这正是面向抗灾难系统的防御性工程的核心论点,而这次漏洞就是一个很好的案例研究。

借用内核句柄,从内部给 fs 打补丁

最后这一招很小,却很精妙。要给 fs 进程自己的代码打补丁,利用程序需要一个指向它的内核句柄。它的做法是把内存中一个 4 字节的句柄字段覆写为魔数 0xffff8001,内核会把它解释为“当前进程的句柄”。游戏进程调用了一个无害的 API,拿回了它自己的进程句柄,然后把它交给一个以代码加载系统模块权限运行的恶意进程。该进程随后利用合法的内核调用,映射并修补了 fs 的代码。每一个单独的操作都是被授权的,而它们的组合却完整攻陷了设备上最受信任的进程。

如果这听起来很熟悉,那是因为一旦你开始留意,混淆代理人(confused deputy)问题随处可见:允许一个带可写配置的良性二进制文件运行的 sudo 规则,可以把自己传递给更强大服务的云 IAM 角色,以及签署 CI 任务交给它的任何东西的构建系统。我们在Unix 权限提升的历史中讲过这一模式的提权一面,机制在变,但漏洞的形态没变。句柄、能力和令牌的强度,取决于存储它们的内存的完整性。

安全启动的设计者一直在犯的错误

退一步看,整条攻击链的模式是一致的,这也正是我审查过的大多数严重企业入侵背后的模式:经过验证的核心守住了,而它周围的一切却没守住。任天堂的启动 ROM、签名方案和内核防护都没问题。被攻破的是解释器的边界检查、路径规范化的缓冲区、存放在可写内存中的权限字段、两个受信服务之间的 TOCTOU 窗口,以及一张从用户态可写结构中获取内核指针的句柄表。这些都不奇特。它们全都是那种因为每一处单独看都合情合理而得以通过审查的东西。

  • 审计那些枯燥的代码。路径规范化器、长度检查和句柄表才是漏洞链真正的起点,而不是密码学。审查时间应该与攻击者的可达性成正比,而不是与算法的复杂度成正比。
  • 把每一个防护缺口都当作路线图。如果系统中某个进程缺少 PAC/CFI,比如兼容层、第三方二进制文件或遗留解释器,就假设它是入口点,并建模它能够触及的范围。
  • 消除基于位置的信任。一个负责校验数据、并把可变句柄交给下游的组件,就制造了一个替换窗口。应传递不可变、可自我验证的对象,或在使用时重新校验。
  • 假设任何可写内存都是处处可写的内存。存储在攻击者可触及内存中的权限标志、PID 字段和句柄值,属于你的攻击面,而不是你的强制执行机制。

另一个令人不安的结论来自工具层面。这次利用是由一个 AI 智能体开发的,它通过 KVM-over-IP 设备获得了对硬件的物理访问权限,包括视频输入、通过 USB 输入的手柄信号,以及一个在每次崩溃后按下电源键的蓝牙拉杆装置。多个智能体实例并行地、不分昼夜地攻击不同的攻击面,持续了两周。无论你如何看待 AI 辅助的安全研究,封闭硬件上漏洞发现的经济账已经变了。以前需要一位耐心专家的漏洞,现在需要一位耐心专家来指挥三个不知疲倦的助手。如果你的威胁模型假设“没人会费心逆向这个系统模块”,那么这个假设本月就已经失效了。

漏洞链本身就是产品

读越狱分析时,很容易把它当成任天堂失误的清单。不妨换个角度,把它当成一面镜子。我曾帮助防守过的每一个系统,都有一个 SmileBASIC 式的存在(无人加固的遗留组件)、一个路径缓冲区式的存在(早已超出其假设的管道代码),以及一个加载器竞争式的存在(信任了可变中间环节的校验)。攻击者的优势并不在于任何单一步骤的聪明,而在于他们以链条思考,而防守者以组件思考。

所以你团队本周的练习不是“有没有人能伪造我们的签名”,而是:挑出你最信任的内部服务,假设攻击者已经在与之通信的、加固程度最低的进程中拥有了任意写入能力,然后沿着这条链条向前推演。第一次提权落在哪里?这个身份又能让他们够到什么?你很快就会发现你自己的 fs 系统模块。最好由你来发现它,而不是让一个手握 KVM、蓝牙拉杆和两周耐心的人来发现。