破解2万美元历史原型机 | Yifan Lu
几个月前,一位联系人向我提供了一个难以抗拒的机会:我可以实验一台极其罕见的原型开发套件PlayStation Vita。我唯一需要做的就是设法提取启动代码。过去七年来,我从内核漏洞利用到使用AES故障注入提取硬件密钥,破解了Vita的每一个细节。在这漫长的旅程中,我熟悉了Vita的每一个型号和版本,因此拥有最早的 prototype 似乎是不可避免的。
DEM-3000L实际上比最近以2万美元售出的DEM-3000H更为罕见。虽然我无法独立证实这一点,但我的消息来源声称,DEM-3000H分发给早期游戏开发者,而DEM-3000L是索尼内部用于开发系统固件的。这个特定的DEM-3000L的历史是,最初在中国的一个垃圾填埋场并排发现了两个。它们有严重的水损坏(据说它们“在湖底”),并被小心修复。两个中的一个(显示屏损坏的那个)最终到了我手中。
为何提取启动代码?
我被无数次问到:为什么要提取启动代码?尤其是在如此独一无二的系统上?我的第一个答案是,因为提取启动代码很困难,我从不拒绝在Twitter上发布神秘哈希值来炫耀的机会。我的另一个答案是,在保存方面,在这个系统进一步恶化之前,尽可能多地提取数据具有历史价值。Vita是一个独特的硬件,因为从硬件到操作系统再到可执行格式,一切都是定制设计的。我们一直在痴迷地记录这款手持游戏机的每一个微小细节,尽管它从未成为索尼想要的商业成功,但任何哪怕只增加一点点额外知识的东西对我来说都是值得的。
回顾Vita故障注入
如果你关注Vita破解场景,你可能记得我通过电压故障注入进入硬件黑客领域。我甚至写了一篇论文,以学术(读作:无聊)的细节记录了整个过程。对于大多数人来说,以下是需要从零售Vita提取启动代码的摘要。回想一下,故障攻击是一种滥用晶体管对操作环境极端变化的响应方式,以导致硬件在计算中犯“错误”,例如跳过或损坏正在执行的指令。
第一次尝试
最初,我们对bootrom的工作原理知之甚少。我们知道它 somehow 从eMMC加载引导加载程序(格式称为SLSK),然后解析引导加载程序头,检查完整性/签名,最后跳转到引导加载程序(确保bootrom代码不再可访问)。其他所有记录的软件故障攻击要么假设攻击者已经可以完全访问正在执行的代码,要么完全理解数据处理方式,因此可以设计高度针对性的故障来绕过某个检查。对我们来说,情况更为复杂。例如,我们不能针对签名检查进行故障攻击,因为我们不知道签名检查何时发生。由于索尼实现eMMC驱动程序的方式存在错误,启动过程中99%的时间都在等待eMMC操作完成,这使得搜索空间对于盲目故障攻击并希望击中正确点实际上不可行。我们没有用于解密签名的公钥,也没有用于解密头的加密密钥(加上我们甚至不知道签名在文件中的位置,因为它大多是加密数据)。
为了在更通用的环境中重述这个问题,我们必须克服两个挑战才能利用故障攻击来利用软件。
- 我们需要一个可观察的成功度量标准。你如何知道你的故障是否成功改变了程序行为?如果你针对的是签名检查绕过,那么很简单:等待你的代码被执行。但如果失败了呢?我们只能选择那些故障的成功可以独立于整体攻击成功来衡量的故障目标。如果你有一个成功条件和十几个失败条件,那么经过几天使用不同偏移和宽度的故障攻击后,你最终对你的攻击可行性一无所知,因为任何失败都可能是由于任何原因。
- 我们需要能够缩小故障参数搜索空间。在撬棍电压故障中,影响最大的两个参数是偏移(故障触发的时间)和宽度(故障持续的时间)。如果搜索空间太大,实验的周转时间将是数天。理论上这并不算太糟,但实际上当你看到几天过去了,你甚至不知道你是否接近成功时,你会很快失去士气。
我们最终得出的见解是,似乎存在一个允许的SLSK最大大小。如果大小太大,启动代码拒绝加载任何内容并死机。那个大小检查是一个很好的目标,因为如果我们监控eMMC流量,我们可以通过看到引导加载程序被读取来观察成功,尽管大小太大了。由于我们知道大小检查只能在包含大小字段的eMMC块被读取后的某个时间发生,我们可以设置故障在观察到该块被读取后的某个时间偏移触发。此外,如果存在大小检查,那通常意味着存在一个具有最大大小的缓冲区,如果我们绕过检查,我们可以溢出这个缓冲区。
这个计划奏效了,我们发现我们设法溢出的缓冲区实际上与当前正在执行的代码相邻,因此很快实现了代码执行。
但这就是好消息的结束。我们还发现,没有传统意义上的启动“rom”。相反,发生的情况是,在复位时,某个未知的硬件代理预加载SRAM的开头与启动代码。然后启动代码将自身复制到SRAM的末尾并跳转到那里。在那个时候,SRAM的开头成为SLSK加载的暂存空间,这就是为什么溢出缓冲区允许我们覆盖当前运行的代码。没有证据表明初始启动代码的源 ever 被映射到内存中,所以通过软件获取它是没有希望的。因为我们的代码执行手段需要覆盖这个启动“ram”,我们覆盖了许多我们感兴趣的代码。更糟糕的是,代码设计为一使用就擦除SRAM中的任何秘密,所以我们也不能转储那些,即使它们没有被覆盖。
第二次尝试
不过,并非所有希望都破灭了,因为我们在逆向工程得到的代码后很快发现了第二个目标。正如现在在wiki上记录的那样,索尼决定重用SRAM的开头作为加载SLSK的暂存缓冲区。之前我们溢出了这个缓冲区以覆盖相邻的代码。现在我们注意到MeP处理器的异常向量设置在这个相同的SRAM位置,并且无法关闭异常。通常,索尼是安全的,因为除了硬件信号之外,触发异常的唯一方式是指令(软件中断、调试断点等)和除以零。启动代码中两次除法都是用非常数非零除数完成的,而特殊指令根本没有找到。
但是通过故障攻击,很容易损坏指令以触发这些异常之一(保留指令尤其容易通过故障触发)。在我们的第二次攻击中,我们用跳转到我们有效负载的异常向量替换了SLSK引导加载程序的第一个块。我们在块加载后触发故障并导致异常。这出乎意料地工作得很好,比我们原始的大小字段攻击好得多,因为它不依赖于对单个指令的精确故障。相反,我们可以“喷洒并祈祷”随机故障,知道任何随机指令损坏都可能触发保留指令异常。
大部分启动代码都是通过这种方式转储的,但由于eMMC的块大小是512字节,我们仍然必须覆盖前512字节,这破坏了大部分初始启动代码。我们能做得更好吗?
第三次尝试
事实证明,Vita的安全启动中有一个非常深奥的功能,我们仍然不完全理解其目的。second_loader.enp 是负责启动ARM(主应用)核心的SLSK。一旦ARM启动,它定位并从eMMC读取secure_kernel.enp,然后与F00D(启动处理器)进行握手以重置核心,这会重新复制并重新启动启动代码。复位后,F00D启动代码从内存加载secure_kernel.enp,而不是直接从eMMC加载second_loader.enp。secure_kernel.enp向ARM处理器提供加密服务。
尽管这个迂回的启动过程很神秘,我们不需要理解它为什么存在就可以利用它。回想一下,在这个替代启动代码路径中,SLSK直接从内存复制,而不是从eMMC读取(以512字节块)。它复制SLSK文件的64字节,验证头,如果头检查通过则加载文件的其余部分。如果检查失败,它不会擦除数据,所以实际上我们仍然能够覆盖异常向量。这产生了以下复杂过程来获取剩余的448字节:
- 使用HENkaku Ensō CFW正常启动Vita以获得内核代码执行。
- 使用单独的漏洞利用将权限提升到ARM TrustZone。
- 使用第三个漏洞利用攻击F00D,以便与TrustZone执行复位握手。这个握手需要F00D代码执行和ARM TrustZone代码执行才能完成。
- 在TrustZone中加载异常向量有效负载,并通过写入UART控制台的一系列字符触发故障注入器。
- 经过某个时间偏移后,故障发生并在F00D中导致异常,从而运行有效负载。
因此,通过3个软件漏洞和1个硬件故障注入器,我们最终可以获取16320/16384字节的启动代码。剩下的64字节呢?事实证明,它只是(原始的)异常表,是重复15次的相同条目(指向死机)。我们还猜测复位向量(0x0)指向系统中唯一的启动代码😀。
原型机故障注入
之前我买了一盒Vita主板进行实验。我能够进行PCB修改并做一些很快杀死主板的事情(例如意外将CORE_VDD短接到任何其他电压源)。对于这个罕见的原型单元,我们必须更加小心,因此无法复制完全相同的设置。首先,零售Vita的主SoC和eMMC并排放置,因此两者之间的电线相对较长,所以索尼必须路由几个电阻以确保信号完整性。我们探测eMMC的方式(既用于从错误的有效负载刷新中恢复,也用于触发故障)需要将电线焊接到这些微小的电阻上。在DEM上,eMMC直接位于SoC下方,在板的另一侧,电线穿过板而不是横跨,使得信号无法探测。其次,有一个普遍的观点认为,移除板上的去耦电容器将提高故障成功率。在这一点上,我还没有看到任何令人信服的证据表明移除去耦电容器实际上对撬棍电压故障有影响,这对我来说感觉像是货物崇拜。因此,我没有尝试寻找和移除去耦电容器。最后,为了替换eMMC数据包触发,我决定重新利用板载调试LED,因为它易于通过软件控制,并且如果意外破坏,似乎影响最小。
缩小参数搜索
如上所述,两个主要挑战之一是弄清楚何时触发故障。我们可以在复位握手之前切换LED作为触发,但SLSK加载过程需要10ms-22ms。更糟糕的是,当加载因任何原因失败时,它将睡眠0ms到4ms之间的随机时间。22ms似乎不多,但假设每个步进宽度是27ns(选择因为外部时钟是37MHz),我们有814,000个可能的偏移乘以我们想要尝试的故障宽度数量。在DEM上,由于所有调试日志记录,每次启动大约需要45秒,因此暴力检查是不切实际的(对于单个宽度尝试所有可能的偏移需要超过一年)。
我们做出合理的假设,即未来预测过去,并且DEM启动代码类似于零售启动代码。使用xyz的Ghidra插件,我们可以查看DRAM SLSK加载路径的反汇编。
SLSK地址从ARM读取(并保存在uVar1中)。它首先复制64字节头并检查它。如果检查通过,它复制剩余的SLSK文件,大小从头计算。我们想找出头检查发生的精确时间,因为我们将有效负载放入SLSK头中。为此,我们查看copy_words的反汇编(反汇编比反编译更好,因为这里时间很重要)。
对于那些不熟悉MeP汇编的人,repeat指令将重复所有指令,直到第二个操作数中的地址加上额外的两条指令(延迟槽),重复次数在第一个操作数的寄存器中指定。基本上,这段代码循环并从用户指定的内存复制4字节到SRAM。
因为我们知道每次读取4字节,我们设计了以下实验:在ARM端,我们取一个有效的SLSK并损坏偏移x处的字。然后我们触发重启握手并等待y个周期。等待后,我们在偏移x处“恢复”原始字。如果我们等待时间过长,那么copy_word操作将读入x处的损坏字,然后无法加载SLSK,我们看到返回代码2。如果我们等待时间不够长,那么copy_word将读入原始字,并且因为SLSK有效,我们看到返回代码1。现在对于每个偏移x,我们改变周期计数y,直到找到仍然返回状态2的最低y。这给了我们等待偏移x处的字被复制进来的周期数的上限。然后我们对每个x都这样做。
现在我们做第二个实验,这是一个轻微的修改。我们从一个有效的SLSK开始,触发重启握手,并等待v个周期。等待后,我们损坏偏移u处的字。这次,如果我们等待时间过长,那么copy_word将不会看到损坏的字并返回成功状态1。如果我们等待时间不够长,copy_word将看到损坏的字并返回错误状态2。这给了我们从复位握手到偏移u处的字被复制进来的周期偏移的下限。我们对每个u都这样做。
这是前500字节的图:
我们首先看到的是下限和上限相互跟踪得很好。这很重要,因为它表明我们的测量方差很小,并让我们对结果有信心。其次,我们看到关系 mostly 是线性的,在64字节处有一个小的跳跃。这与零售启动代码的预期行为相匹配:首先读入64字节,检查头,然后如果检查通过,复制剩余的字节。最后,我们看到“跳跃”发生在0xB4800到0xB5600,这成为我们感兴趣的时间窗口,如果我们期望故障跳过头部大小字段检查。将周期计数转换为实际时间后,我们将搜索空间从814,000个可能的偏移减少到400个可能的偏移。
观察成功
第二个挑战是衡量成功。我们必须在不必探测eMMC的情况下完成。一个关键的发现是当我们意识到任何总线错误(即使是那些由启动处理器引起的)都会导致ARM核心中断,这(仅在DEM单元上)导致打印出错误消息。由于启动代码不进行任何地址检查,如果我们将有效的SLSK放在内存边缘,那么当读取到达末尾时,它将触发打印出总线错误消息。
换句话说,假设我们制作一个有效的SLSK头,指定大小为0x2000,并将这个头放在0x1F85F000。该内存区域结束于0x1F85FFFF,因此当启动代码尝试读入0x2000字节时,在0x1000字节后它将越界并触发打印出总线错误消息。
我们如何使用这个来衡量故障是否成功?我们通过试错发现,SLSK头中指定的最大大小是0x1C000字节。任何超过这个大小的都会失败头检查。所以,如果我们将大小设置为0x1D000并将头放在0x1F844000,那么当故障失败时,“剩余”数据不会被读入,什么也不会发生。如果故障成功,那么启动代码将尝试读取0x1D000字节,但在0x1C000字节后,它将到达内存区域的末尾,我们将看到总线错误被打印出来。
由于我们希望一旦SLSK头被读入就触发异常,并且我们知道头大小字段在不久后检查,任何时候我们成功故障大小字段检查也为我们提供了关于何时可以触发异常故障的信息。实际上,我们使用一个故障来为未来的故障获取信息。
注入有效负载
在最后一步,我们创建了转储启动代码的有效负载。通过实验,我们发现一旦代码在失败时到达while (1)死循环,异常故障的机会几乎为零。(技术解释与关键路径的时序以及较短路径如何较不容易受到故障行为的影响有关。)为了最大化头读取和死循环之间的时间,我们创建了一个通过SLSK头检查的异常向量有效负载,以便它可以到达读取“SLSK其余部分”的下一阶段。然后我们尝试在这发生时引起异常故障。
我们这样做的方式实际上很简单。我们取了一个现有的SLSK头并尝试将其“反汇编”为MeP代码。我们注意到,如果我们将有效的头解释为“代码”,从偏移0x8(保留指令异常向量所在处)到偏移0x20的指令是良性的,本质上是空操作。头检查函数不检查从偏移0x20到0x40的数据(在真实的SLSK头中,这包含解密代码的SHA-256哈希),我们可以使用这个空间作为我们的有效负载。32字节足以跳转到我们控制的某个内存区域,在那里我们复制出启动代码。
图片说明(从上到下,顺时针):DEM拆卸的前壳,示波器探头查看故障,ChipWhisperer Lite故障注入器,托管单个上拉电阻用于LED GPIO触发的面包板(忽略未使用/不相关的芯片),Morgana,受攻击的DEM单元。
将所有内容放在一起:
- 和以前一样,我们使用ARM内核漏洞利用、ARM TrustZone漏洞利用和F00D漏洞利用在所有三个上下文中获得执行。
- 我们的异常向量有效负载和最终的启动代码转储程序代码被放置在内存中。
- 我们通过UART消息武装硬件故障注入器。下一个GPIO切换将触发故障。武装故障注入器防止在错误的时间意外故障(例如,当启动过程控制LED灯时)。
- F00D复位序列从ARM TrustZone和F00D触发。我们传入我们的异常向量有效负载的地址(伪装成SLSK头)。
- 我们精确旋转0xB4800个周期(由“缩小参数搜索”确定)。
- LED GPIO引脚被切换,这信号给硬件故障注入器在某个偏移后触发故障。
- 我们等待最多10秒钟,等待转储通过UART出现。如果没有,我们选择一组不同的故障参数(从“观察成功”的结果中选择),重启DEM并重新开始。
偏移240-250,宽度59,故障时钟37MHz可以一致地触发有效负载。请注意,这些参数高度特定于我们的特定设置。
资源
如果你想了解更多关于硬件故障注入的知识,ChipWhisperer Wiki是一个很好的起点。如果你获得ChipWhisperer Lite硬件,你可以按照那里的教程学习更多。我们的工作都是在ChipWhisperer Lite上完成的,以及一些分子修改,这些修改启用了诸如eMMC数据包触发、多个故障单元、带额外GPIO输入的边沿触发、额外时钟分频器选项等功能。你还可以找到一些特定于Vita的ChipWhisperer脚本,用于故障注入和DFA。最后,如果你想了解更多关于Vita的信息,HENkaku wiki是必去之地。
本文总结的DEM原型故障注入在Twitch上直播了整整两周。如果你想看到从开始到完成的整个过程,它们都被记录在vods中。但请注意,浪费在走错路和犯错误上的时间比实际进展要多——但这就是真实黑客工作的方式。感谢所有收看并提供帮助和精神支持的人,以及xyz提供触发复位握手的漏洞利用。