Switch 2 被纯软件越狱了:不开壳,也不用芯片
ns2jb 针对 23.0.1 固件,用 WebKit 的 n-day 拿到用户态执行,再靠 SmileBASIC 的整数溢出和 fs 的路径处理漏洞,拿到任意 sysmodule 的原生代码执行。整个过程约两周,找漏洞的活大多由 Codex 承担,需要联网触发。
中文
复制

你可以从 ns2jb 仓库获取该漏洞利用的副本。
ns2jb 是针对 Switch 2 的纯软件、需连线的越狱。它允许以任意普通 sysmodule 的身份运行任意原生代码,也能以内置的 fs sysmodule 身份运行。借此我们可以运行自制程序,对 Switch 2 游戏做逆向、做 mod、把存档从 Switch 上备份出来,并通过 MMIO 访问 Switch 2 的大部分外设。据我所知,这是 Switch 2 上的首个此类漏洞利用。
为了展示这次越狱的能力,Codex 对 Switch 2 独占游戏 Mario Kart World 做了 mod。感谢 Dabney House 的朋友们帮我挑选要加入的角色,并帮忙试玩。


准备工作
我做的第一件事,是在 Switch 1 和 Switch 2 周围搭一套测试装置,好让 Codex 有可能在我完全不插手的情况下破解 Switch 2:

补充:很多人问我到底有没有真的用上这段提示词。没有,那只是个玩笑式的配文。我给 userland 代码执行设了一个 /goal,但没成功。越狱本身花了大约两周的实打实功夫。不过 Mario Kart World 完全是一天之内 vibecode 出来的,纯粹图个乐。
JetKVM 让 Codex 能通过 USB 向 Switch 发送手柄输入,并通过 HDMI 接收视频输出。我们还需要一种按下电源键的办法。Switch 2 崩溃时,得按一下按钮才能重启。
这就是那个从 Amazon 买来的蓝牙灯开关派上用场的地方。拨杆落下时,它会压住电源键。电源键被完全按下后,拨杆剩余的力矩会把机构抬起来。
获取代码执行
WebKit 一直是主机越狱的热门目标,因此 Codex 选择它作为初始代码执行的突破口,并不令人意外。
通过浏览器实现代码执行
它声称能实现“原生控制流劫持”,利用的是一个通过自定义 Wi-Fi 强制门户加载的 WebKit n-day。

我们希望在微内核服务(在 Switch 上称为“sysmodules”)中实现代码执行,从而运行自制程序、给游戏打 mod、下载存档等等。可惜,用一个 JavaScript 函数对象替换另一个的做法无法推广到已有函数之外,我们需要一种新方法来发起任意 IPC 调用。这看起来很难。
为什么难?
- WebKit 有只执行内存(XOM),所以即便能任意读写内存,我们也不知道能发起任意 IPC 调用的 gadget 在哪里。
- 就算知道,比如通过解析重定位数据找到它,由于 PAC 的存在,我们也不清楚该如何为它构造签名指针。
- 就算能构造,借助某个签名 gadget,我们也不清楚该如何调用这个指针,因为 CFI 会对大多数调用点做类型检查。
Codex 已经把目标重置为完成这些步骤。这是一次副线押注;它可能成功1,但现实点说:WebKit 的可达性和任天堂 BBP 的奖金,大概早就让很多人抢在我前面盯上这里了2。
趁它跑着,我们来看看别的路子。
通过 smilehax2 实现代码执行
我的想法是瞄准 Switch 1 游戏,原因有两个:
- 它们由开发者构建。我们不太可能需要应付 PAC 和 CFI,尤其是因为 Switch 1 不支持 PAC。
- 既然 Switch 1 的信任根已被攻破,我们可以解密代码并读取它。
也许我们仍能拿到浏览器攻击面的好处,比如用 JavaScript 编排、复用现成的 n-day,同时避开 WebKit 缓解措施的坏处,办法是找一款内嵌浏览器的 Switch 1 游戏?类似于 Electron,但用于游戏,这样我们就能实现类似这个 Discord RCE 的利用。目前我不能公开这条路的发现,但幸运的是,还有别的 Switch 1 游戏。
我特别感兴趣的是 SmileBASIC。因为它以前就被攻破过,在 smilehax 中实现了 3DS 上的用户态代码执行。这是一个庞大的 C++ 代码库;既然 BASIC 解释器里曾经有过可利用的内存破坏,那肯定还能再找到一个。
Codex 在解释器里发现了一个可用的 32 位整数溢出。删除数组元素时,解释器会检查 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
);
假设有一个长度为 4 的字符串数组 R,执行 REMOVE R,&H60000000,&H3FFFFFFF。position + count 回绕成 0x9fffffff。作为有符号 32 位整数,它小于 array.length。乘以 8 的运算同样会回绕,于是最终执行的是 memmove(backing, backing - 8, 32),把前面的 8 个字节拉进了数组。命令确实会报 Out of memory 错误,但这无关紧要,因为拷贝已经发生了。
这之所以有用,是因为在 SmileBASIC 中,任何数组前面的 8 个字节都存着指向该数组自身的指针。执行 REMOVE 之后,对 R[0] 的操作会把数组的底层存储当成字符串来处理。
指针占据了 R[0] 本该是长度字段的位置,于是字符串长度变得极大,我们就能越界读写。读写内存的方式,是向 R[0] 的子串拷贝数据、或从子串拷出数据。
这是一款 Switch 1 游戏,所以 SmileBASIC agent 可以在我的 Switch 1 上搞漏洞利用,WebKit agent 则同时在 Switch 2 上干活。过了一段时间,它拿到了 ROP。

获取 sysmodule 的读写权限
好,现在我们需要攻破一个权限更高的进程。我们关注的是普通 sysmodule和内置 sysmodule。
该挑哪个 sysmodule 下手?我们手里没有它们的代码,所以说不清哪个 sysmodule 拥有哪些权限。
我给几个 Codex 实例布置了拿下 sysmodule 代码执行的目标。大多数都选择盯着 audio 和 ldn。我猜是因为 audio 和 ldn 在 Switch 2 上出过 bug。Codex 也看了 NV 服务,大概是因为 GPU 驱动通常容易出问题?
fs sysmodule
与此同时,我开始研究内置的 fs sysmodule,理由如下:
- Switch 1 的代码显示它能提供解密后的代码。
- 代码量很大。
- 文件系统 API 变化不大,它很可能和 Switch 1 上的
fs模块相似。
第一条理由对我最有吸引力。就算没法从 fs 拿到有用的 jailbreak,至少能解密一部分其他 sysmodule,让 Codex 去里面找 bug。像 ldr 和 ro 这样的 sysmodule 看着很诱人,因为它们有映射可执行内存的权限3,但攻击面不大,尤其是从 SmileBASIC 这边够不着。这些之后再回来看。
路径规范化导致的缓冲区溢出
IFileSystem 句柄有一个 IPC 命令 GetEntryType,接收一个路径字符串,并用 Path::Normalize 做规范化。
如果路径里含有带反斜杠的父目录组件,比如 /..\,就会调用一个辅助函数,该函数在堆上分配 0x301 字节的临时缓冲区。我们的路径会被复制进去,但路径可以比缓冲区大,于是造成缓冲区溢出。lol
我们需要一个合适的受害者分配来溢出进去。Codex 发现 BufferedStorage 对象会为它的文件分配一个缓存条目。如果该条目的 dirty 位被置位,对文件的读写就会沿着一个指向缓存数据的指针进行。我们可以劫持它来实现任意读写4。
接下来,我扫描内存,找到 SmileBASIC 的连接对象,把保存的 PID 设为 1。现在我的进程拥有“FullPermission”,可以执行特权文件系统操作了。我 dump 了内部存储和所有普通 sysmodule 的代码,这样 Codex 可以做离线分析,我也能搞清楚下一步往哪走。
让任意普通 sysmodule 获得代码执行能力
Homebrew 和改机通常依赖执行自己的原生代码。翻看 dump 可以发现,唯一拥有可执行代码映射权限的普通 sysmodule 是 ro。这些 sysmodule 全都启用了 CFI、PAC 和 XOM。该模块的接口看起来也相当精简。与其直接在 ro 里找内存破坏,不如去追一个逻辑漏洞,看起来更有希望。
回到前面提到的第一点:fs 会提供解密后的代码。这对 dump 普通 sysmodule 的代码很有用。那么这个接口本来的用途是什么?加载程序?
游戏和普通 sysmodule 以 Nintendo Content Archive(NCA)的形式分发,其中包含一个名为 ExeFS 的文件系统镜像。ExeFS 里有主可执行文件 main,以及程序元数据 main.npdm。
加载程序时,内置的 ldr sysmodule 请求 fs 打开 ExeFS,fs 返回一个指向该文件系统的 IPC 句柄。fs 还会提供 NCA 头的两项密码学信息:头部的 SHA-256 哈希,以及开发者对该哈希的签名。加载器用这些信息做两次签名校验:
- Nintendo 对
main.npdm进程权限和开发者公钥的签名是否有效? - 开发者对 NCA 头哈希的签名是否有效?
在我看来,这套机制在密码学上是站得住脚的,因为理论上:
- NPDM 签名固定了进程权限和开发者密钥;
- 密钥固定了 NCA 头的签名;
- 签名固定了 NCA 头的哈希;
- 哈希固定了 NCA 头的内容,包括其中的文件系统哈希;
- 这些哈希又固定了可执行内容。
但实际上,我对最后一环存疑,因为保证所提供的文件系统与 NCA 头中的文件系统哈希一致,是 fs 模块的职责,而我们在这个模块里拥有 ARW。
从任意读写到原生代码执行
确实,有了 ARW,我们就可以把加载器 IPC 句柄背后的 ExeFS 对象换成自己构造的临时文件系统。这个文件系统仍需为加载器的第一项校验提供一个有效的开发者 main.npdm,但可执行文件 main 本身可以是任意的。第二项校验会通过,因为等到加载器打开文件系统时,fs 早已提交了原文件系统对应的有效 NCA 头哈希和签名。
我们执行两次写入:
- 第一次,把我们创建的一个临时文件系统对象写入 loader 的 ExeFS 在域表中的条目。这个临时文件系统里包含原始的
main.npdm和我们的替换文件main。 - 第二次,把 loader 的 ExeFS 对象写入我们临时文件系统在域表中的条目。这一步只是为了让引用计数保持一致,避免
fs崩溃。
这两次写入必须发生在 loader 请求 ExeFS 文件系统之后、fs 处理后续文件请求之前。怎么保证这个时序?
fs 进程维护着一份自己的 IPC 连接列表。某个连接上有新的 IPC 消息时,fs 就会处理它。借助任意读写,我们可以把 fs 等待 loader IPC 连接所用的句柄,替换成我们从 SmileBASIC 控制的 event 句柄。这样我们就能控制 fs 何时处理 loader 的消息。
有了这个,只要我们有某个程序的 main.npdm,就能以它的身份执行任意代码。
拿到内置 sysmodule 的原生代码执行
我们有哪些 main.npdm?全部都有。借助 ARW,我们可以通过 loader 的 fsp-srv 连接,复制任意普通 sysmodule 或游戏的 NPDM。
我们将用这个能力,借 ro 的 NPDM 启动自己的代码。前面提到过,ro 的 NPDM 允许它调用 SetProcessMemoryPermission、MapProcessCodeMemory、MapProcessMemory 这类内核 SVC。这里我们用 MapProcessMemory 为 fs 已有的代码创建一个可写视图。读这个视图,也能把 fs 的代码 dump 出来。
不过这些调用需要一个指向目标进程的 KProcess 句柄。要拿到 fs 的句柄,我们用 ARW 把 IEventNotifier 的 4 字节句柄覆写成 0xffff8001,再从 SmileBASIC 调用 GetEventHandle。内核把这个值解释为“当前进程的句柄”,于是 SmileBASIC 就拿到了 fs 的 KProcess 句柄。
然后我们把这个句柄发给自定义的 ro 进程,由它来 patch fs 的代码。
结论
以任意普通 sysmodule 的权限执行任意代码,威力看起来相当大。我们不必先攻破每个设备的驱动,就能研究硬件。我希望这能帮助 homebrew 社区向 CFW 推进一步。
短期内,我认为有几个值得尝试的方向:
- 把
fssysmodule 的漏洞利用移植到 WebKit。既然已经知道要利用哪个 bug,能发起任意 IPC 调用就没那么有价值了。也许只要改掉 WebKit 现有文件系统代码所用的数据,就能在fs中触发 ARW。 - 这个越狱是需连线的(tethered),而且除了
fs之外,我们目前还没有办法攻破内置 sysmodule。fs是内置 sysmodule,因此 SM 会跳过fs的服务 ACL 检查,提供与 smhax 相同的能力。我在想,能不能在其他内置模块里找到 bug? - 有了
fs中的原生代码执行能力,我们就可以直接调用它的加密硬件。比如 NCA4 解密就会用到它。 - 这个漏洞利用也在 Switch 1 上测试过,只是我还没顾上打包。它给不了改机芯片(modchip)那样的能力,但也不需要任何外部硬件。
眼下,我很期待接下来涌现的 Switch 2 自制程序、模拟器和改机。
Footnotes
-
它没有成功。 ↩
-
尝试 WebKit 漏洞利用本身并不坏。一旦我们确认某个 sysmodule 里的 bug 有价值,回头用纯数据攻击的方式在 WebKit 里利用它也许是合理的。因为 WebKit 可以离线访问,任天堂没法像下架 eShop 游戏那样把它撤掉。不过在研究现阶段,为此牺牲迭代速度并不划算。 ↩
-
我在想,任天堂有一天会不会把普通 sysmodule 甚至游戏代码都映射成只有内核或 secure monitor 才能解密的加密块。那样一来,任何用户态进程都观察不到系统上的任何代码,用户态里永远不会出现 RX 映射。就连负责启动新进程的 loader 模块,也得请求更高异常级别来加载它提供的加密块。这会有代价,比如游戏无法在运行时读取自己的代码。但任天堂已经开始移除动态代码功能,比如 JIT sysmodule。我在想 Switch 是不是正朝这个方向走。好在,这一天还没到。 ↩
-
最初,Codex 的读写原语每次试图改动底层缓冲区指针时,都会在 cache 对象上反复触发缓冲区溢出。这会影响稳定性,尤其是在连续跟随多个指针时,因为每一次改动都可能导致系统崩溃。我们可以借用这里用过的类似手法:利用若干次溢出,把第一个 cache 条目的底层缓冲区指针指向另一个 cache 条目的元数据,这样就能替换第二个 cache 条目的指针,而无需再冒更多溢出的风险。 ↩