Lenovo ThinkPad P51s固件SMM驱动逆向及漏洞分析
导语:在本文中,将首先介绍SMM和UEFI的基础概念,随后对SMM驱动程序漏洞进行详细分析。
一、前言
从去年夏天开始,我尝试对自己的Lenove ThinkPad P51s计算机固件进行逆向分析。之所以对这个型号的固件感兴趣,其原因在于独立BIOS供应商(专门从事固件开发的公司)似乎是Phoenix Technologies而不是AMI,因为此前已经研究过大多数固件。尽管大多数固件都使用EDKII,但供应商的不同意味着其中的很多代码都将有所不同。
我们首先开始分析SMM驱动程序,很快就发现了一个漏洞——在一个SWSMI处理程序中调用SMRAM。该漏洞已经由Lenove在8月完成修复,目前我无法在网上找到关于该漏洞的任何信息。
在本文中,将首先介绍SMM和UEFI的基础概念,随后对这一漏洞进行详细分析,这里面的漏洞利用涉及到在此前另一篇文章中发表的技术——SMM中的Code Check(mate)。
以下内容已经公开发表,幻灯片可以参考这里。
二、SMM和UEFI
UEFI是描述开发固件(特别是BIOS)标准接口集的规范。该固件是启动期间在CPU上执行的第一批内容,负责初始化硬件并进行设置,从而让操作系统可以启动。该固件存储在计算机SPI Flash中。攻击者如果成功攻击这个硬件,就可以在硬盘以外的其他位置实现持久性。
系统管理模式(SMM)是一种Intel CPU模式,因为该模式与内存或系统管理程序相比具有更高的特权,因此通常被称为Ring 2。SMM拥有自己的存储空间,称为SMRAM,可以防止其他模式对其进行访问。SMM可以被看作是一个“安全的世界”,与ARM上的Trust Zone类似。但是,其最初的目标并非是提供安全功能,而是处理计算机的特定要求,例如高级电源管理(APM,已由ACPI替代)。如今,它还用于保护对包含UEFI代码的SPI Flash的写访问。
Inter手册中“处理器工作模式之间的转换”:
从上面的示意图中可以看出,可以从任何“普通模式”访问SMM。SMM还支持16位、32位和64位,这使其成为所有其他模式的一个备份。当触发系统管理中断(SMI)时,将会在普通模式和SMM之间进行转换。发生这种情况时,处理器会切换到SMM:它首先将CPU的当前状态保存到一个称为“已保存状态(Saved State)”的存储区(后续可以从该位置恢复到已保存的状态),然后更改包括指令指针在内的上下文,以执行SMRAM中的代码。
基本SMRAM映射,SMBASE可能不会随着SMRAM的开始而被使用:
SMRAM是由UEFI固件保留的物理RAM区域,提供给SMM使用。SMRR可以保护其不被“普通”访问,也可以保护其不备DMA访问。SMBASE是一个必须在特定范围内的地址,用于确定切换到SMM时已保存状态必须存储的位置,以及应将指令指针设置在哪个位置。为避免两个内核同时切换而造成已保存状态的相互重写,每个内核只有一个SMBASE,并且对SMRAM内部的位置没有限制。
目前,存在几种不同的SMI,但特别需要强调的是其中一种SoftWare SMI(SWSMI),往往会被攻击者利用。在ioport 0xb2上写入值后,代码通常会搜索与写入ioport的值相对应的SWSMI处理程序。这些处理程序通常是以64位编写的。
最后,由UEFI固件初始化在SMM中运行的代码(在SMRAM中设置)。特别是,通常在UEFI引导的驱动程序执行环境(DXE)阶段设置SWSMI处理程序。DXE阶段由数百个驱动程序组成,这些驱动程序用于从硬件初始化到网络栈实现的所有过程。
这些驱动程序提供了位于普通模式下的一组服务(特别是EFI_BOOT_SERVICES和EFI_RUNTIME_SERVICES),这些服务提供了一组基本功能,例如分配和访问非易失性(Non-Volatile)变量。
EFI_BOOT_SERVICES还允许注册和访问协议。协议允许驱动程序共享功能,并根据GUID进行标识。实际上,由于在UEFI引导过程中所有的内存访问都是在物理内存中进行的,因此协议只会将GUID与指针相关联。这些协议中,有一些是公开的,并且有文档记录(一些是记录在UEFI规范中,一些是在edk2中),而另外一些则针对每个构造函数。在DXE阶段结束时,固件将锁定SMRAM,阻止对其进行访问,然后尝试启动引导加载程序以过渡到操作系统。
三、漏洞分析
3.1 初始逆向工程
当我开始进行固件逆向时,我首先需要确定驱动程序是使用哪个协议来注册SWSMI处理程序的。经过分析,发现他们使用了edk2(MdePkg/Include/Protocol/SmmSwDispatch2.h)中定义并记录的经典EFI_SMM_SW_DISPATCH2_PROTOCOL。在确定该协议之后,我开始对使用该协议的所有驱动程序进行了简单的二进制搜索,然后开始进行逆向。
其中一个驱动程序名为SmmOEMInt15,它是一个非常小的驱动程序,仅包含21个函数,其中包括一个注册SWSMI的函数:
// [...] res = gSmst->SmmLocateProtocol(&UnkProtocolGuid, 0i64, &unk_protocol); // (1) // [...] swsmi_number = 0xFFFFFFFF; if ((*unk_protocol)(&swsmi_oemint15_guid, &swsmi_number) < 0) // (2) return EFI_UNSUPPORTED; RegisterContext.SwSmiInputValue = swsmi_number; // (3) if (EFI_ERROR(EfiSmmSwDispatch2ProtocolInterface->Register( // (4) EfiSmmSwDispatch2ProtocolInterface, swsmi_handler_unk_func, &RegisterContext, &DispatchHandle)) return EFI_UNSUPPORTED; return EFI_SUCCESS;
上面的代码片段执行了以下操作:
1. 使用GUID ff052503-1af9-4aeb-83c4-c2d4ceb10ca3(UnkProtocolGuid)检索未记录的协议(unk_protocol),这里利用了EFI_SMM_SYTEM_TABLE2(gSmst),其中包含一些SMM服务。
2. 使用新的未知GUID调用第一个函数eee19e05-079a-4d17-8f46-cf811260db26(&swsmi_oemint15_guid),并将其用于检索数字(swsmi_number)。
3. 然后,会在后续使用的上下文中设置在上一步检索到的swsmi_number,以便注册SWSMI处理程序,该值必须卸载IOPort 0xb2上。
4. 最后,EFI_SMM_SW_DISPATCH2_PROTOCOL用于将函数swsmi_handler_unk_func注册为SWSMI处理程序。
这段代码中存在的第一个问题在于,使用了未知协议来获取SWSMI编号。一些(但不是全部)注册SWSMI的驱动程序使用了该协议,因此在执行任何测试之前必须将其进行逆向。
3.2 SystemSwSmiAllocatorSmm
通过搜索未记录协议的GUID(ff052503-1af9-4aeb-83c4-c2d4ceb10ca3),可以轻松找到实现该协议的驱动程序——SystemSwSmiAllocatorSmm。该驱动程序也非常简单,其中函数更少。
该驱动程序的第一步是在正常环境下分配多个缓冲区,其中的一个非常值得关注,因为它已经在GUID 7E791691-5752-4392-B888-EFF9C74F5D77中注册为配置表。所有驱动程序都可以访问配置表,并将配置表关联到GUID,它们通常用于将数据从一个驱动程序传递到另一个驱动程序,而协议用于传递功能,实际上它们都将GUID与指针进行关联。
一旦完成了这些初始步骤和初始化过程,驱动程序就会注册我们想要进行分析的协议,我根据驱动程序的名称将其命名为SystemSwsmiAllocatorProtocol。该协议包含3个函数:get_swsmi_num_and_add2list、get_swsmi_num_from_guid、add_swsmi_to_list_no_check(这些名称同样也是我们进行命名的)。
基本上,该驱动程序允许将SWSMI编号与GUID相关联。可以请求驱动程序找到下一个可用的SWSMI编号(使用第一个函数)或提供编号(使用第三个函数)。第二个函数仅允许从GUID获取SWSMI编号。
这些关联存储在链表中,该链表由开始时注册的配置表引用。这会允许SMM外部的应用程序获得其希望使用的功能的正确SWSMI编号。这样做的目的是为了避免在其他驱动程序与注册SWSMI处理程序的其他组件之间产生SWSMI编号的冲突。
有了所有这些信息,就可以非常轻松地动态检索SWSMI编号。利用UEFI Shell中的chipsec,我们可以将SWSMI编号与GUID匹配起来:
1. 根据GUID 7E791691-5752-4392-B888-EFF9C74F5D77检索配置表ct_swsmi_allocator。
2. 在ct_swsmi_allocator + 0x38处的指针位于双链表的开头(这是一种保护措施,该元素后面没有实际数据)。可以在这个链表上进行迭代,直到再次到达头部为止。
3. 对于列表中的每个元素,都存在一些值得注意的数据:
(1)在elt-0x8是魔术值0x4E415353;
(2)SWSMI编号位于elt+0x10,使用qword形式;
(3)GUID位于elt+0x18。
一旦掌握了GUID和SWSMI编号之间的相关性,就可以触发SWSMI处理程序的代码,接下来我们进行尝试。
3.3 漏洞详细分析
SmmOEMInt15中SWSMI处理程序的第一个操作是从已保存状态中检索RSI寄存器的值。这个操作是通过使用EFI_MM_CPU_PROTOCOL(以前称为EFI_SMM_CPU_PROTOCOL)来实现的,该文件在手册中有所体现,并且属于edk2的一部分(MdePkg/Include/Protocol/MmCpu.h)。该协议将在已保存状态中搜索CPU保存的值,以获取寄存器并返回。对于SWSMI处理程序而言,这是一个非常有趣的开始,因为这个值是实际的用户输入。
更有趣的是,这个值还用作结构上的指针,并且该结构的前两个字节作为调用不同处理程序的开关的枚举。我开始迅速逆向这个处理程序,但实际上没有完成完整的逆向工作,因为在查看处理程序0x3E00时发现了一段非常值得关注的代码。
该处理程序要做的第一件事,是根据结构中的两个字段计算一个值,然后在调用内部函数之前将其设置为全局变量(受控):
base_ptr = 0x10 * rsi_val->local_used; // local_used off. 0x1C (2 bytes) controlled = (base_ptr + rsi_val->for_global); // for_global off. 0x10 (2 bytes) v14 = handler_internal_3E00(base_ptr);
handler_internal_3E00函数以两个非常有趣的基本块开始。
启动handler_internal_3E00函数:
它做的第一件事是检查*(controlled+2)处的值是否为0,如果为0,则会进行一些奇怪的处理后(实际上是在地址0x4处写入0x FFFEFFFE,我们在物理内存中没有任何保护,这不会造成崩溃)调用EFI_BOOT_SERVICES.LocateHandleBuffer函数。
如果从SMM调用这个函数,这里的问题在于,EFI_BOOT_SERVICES是位于普通世界中的服务表。攻击者可以简单地更改EFI_BOOT_SERVICES表中的地址,并获得任意调用。这种类型的漏洞通常称为SMRAM调用,它们基本上等效于从内核调用用户级的代码。
四、漏洞利用
在以前(大概是2017年到2018年期间),SMRAM的调用非常容易利用——在触发SWSMI之前更改代码(或函数指针)。但是,从当时起,SMM_CODE_CHK_EN缓解措施已经开始普遍使用,并且确实已经在我的Lenovo P51s上激活。
SMM_CODE_CHK_EN是SMM中类似于SMEP的功能:如果在SMM中执行来自SMRAM外部的代码(由SMRR定义),则计算机基本上都会崩溃。实际上,SMM_CODE_CHK_EN是在引导过程中由固件初始化的MSR。它可以被锁定,一旦被锁定,就不能被禁用。因为它是一种类似于SMEP的功能,因此一般的内核绕过将可以在这里利用,但是利用内核绕过存在一些缺点:
(1)固件与内核有所不同,内核绕过的一些技巧可能无法移植到这里使用;
(2)从通常情况看,SMM是一个比较大的黑箱,并且数据通信受到限制;
(3)尽管没有ASLR,但地址将取决于计算机和固件版本。
由于上述这些原因,常规的漏洞利用方法可能无法在一个易受攻击的固件上正常利用。
此时,如果我们尝试使用SMRAM调用来触发0x3E00处理程序的代码,则会发生以下情况:
1. 我们使用正确的编号、RSI和内存中的正确值触发SWSMI,以实现调用。
2. CPU会将当前状态保存到SMRAM中的某个位置。
3. 一些代码将被执行(包括切换到64位),并且会调用我们的SWSMI处理程序。
4. 0x3E00处理程序将搜索内存中的EFI_BOOT_SERVICES.LocateHandleBuffer函数指针。
5. 调用该函数。
6. 随后,将发生崩溃。因为SMM_CODE_CHK_EN已经被激活,因此在正常情况下对代码的调用将永远不会执行,因此未经修改的原始代码甚至无法工作。
现在我们已经知道,我们要尝试的目标是能够以稳定的方式在SMM中执行我们的代码,并希望可以轻松地在具有相同漏洞的两个不同固件之间移植。为此,我利用了我在之前另外一篇文章中详细介绍的技术——SMM中的Code Check(mate)。
我们的基本思路是利用已保存状态,该状态是在CPU切换到SMM时设置的。已保存状态始终位于SMBASE + 0xFC00的位置,并且包含许多通用寄存器,从而(在最理想情况下)让我们可以控制0x80字节的字符:
typedef struct _ssa_normal_reg { UINT64 r15; // start at SMBASE + 0xFF1C UINT64 r14; // 0xFF24 UINT64 r13; // 0xFF2C UINT64 r12; // 0xFF34 UINT64 r11; // 0xFF3C UINT64 r10; // 0xFF44 UINT64 r9; // 0xFF4C UINT64 r8; // 0xFF54 UINT64 rax; // 0xFF5C UINT64 rcx; // 0xFF64 UINT64 rdx; // 0xFF6C UINT64 rbx; // 0xFF74 UINT64 rsp; // 0xFF7C UINT64 rbp; // 0xFF84 UINT64 rsi; // 0xFF8C UINT64 rdi; // 0xFF94 } ssa_normal_reg_t;
由于所有地方都使用了物理地址,并且没有启用任何内存保护,因此已保存状态的内容将是可以执行的。而且,0x80字节足以让我们放置Shellcode,这会让我们获得完整的控制权。
在这里,我们绕过CodeChk的思路是:
1. 首先,我们用Shellcode中寄存器所在的地址,重写EFI_BOOT_SERVICES结构中LocateHandleBuffer的地址。
2. 然后,我们用存储在寄存器中的Shellcode触发SWSMI。我们仍然必须遵守调用处理程序中所必须的所有条件,但这会为我们的Shellcode留出足够的空间。
3. 然后,CPU将状态保存在SMRAM中,从而实现Shellcode的映射。
4. 我们的SWSMI处理程序将被调用,其自身将调用0x3E00处理程序。
5. 获取EFI_BOOT_SERVICES.LocateHandleBuffer的函数指针,检索位于已保存状态的地址。
6. 我们的Shellcode将被调用,并且由于已保存状态位于SMRAM内部,因此不会触发SMM_CODE_CHK_EN。
这个思路非常简单,它让我们可以在SMRAM内映射Shellcode,而不必依赖于固件内的代码。但遗憾的是,这里还存在一个小问题——我们不知道SMBASE用于计算已保存状态的基址。
在一段时间以来,如何获得SMBASE的值一直是利用SMM漏洞的经典问题。通常,有三种主要的检索技术:猜测、暴力破解、读取包含该值的MSR IA32_SMBASE。前两种技术很有可能导致计算机崩溃,因此我们不得不开始研究从SMM中读取IA32_SMBASE寄存器的可能性,这会导致存在鸡与蛋的问题。因此,我开始寻找一种更好的技术,以一种更加可靠的方式获得SMBASE,而无需控制硬件。
SMBASE在PiSmmCpuDxeSMM驱动程序中初始化,该驱动程序是开源的,并且在edk2中可用。在初始化SMBASE时,首先需要计算要保留的大小。因为每个CPU都需要一个SMBASE,因此不足以保留0x10000,但是为了优化RAM空间,驱动程序避免了每个CPU保留那么多的内存。在驱动程序中计算TileSize以确定SMBASE的偏移量,而实际上在驱动程序中进行动态计算,发现其总是偏移0x2000字节。现在,我们知道了SMBASE与其他之间的相对位置,并且知道将会保留0x10000 + TileSize * (number_of_cpu - 1)字节的内存。
为了预留内存空间,驱动程序在SmmAllocatePages函数上使用包装器,并且未指定将内存映射到的特定地址。默认情况下,SmmAllocatePages将首先尝试查找空闲列表,并且将使用最高的可用地址。在启动时,不会释放这么大的内存空间,这意味着我们可以放心地忽略空闲列表。关于SmmAllocatePages的最后一个有趣的地方,是在于它也用于映射SMM驱动程序,并且当完成SMBASE的分配时,我们知道分配的最后一个驱动程序是PiSmmCpuDxeSMM驱动程序。在这里,我们已经掌握了一些内存布局。
SMBASE周围的内存布局:
这时,我们仍然没有得到SMBASE,但是我们已经了解了其周围的一些布局,并且碰巧PiSmmCpuDxeSMM在普通世界中注册了一个协议:
Status = SystemTable->BootServices->InstallMultipleProtocolInterfaces ( &gSmmCpuPrivate->SmmCpuHandle, &gEfiSmmConfigurationProtocolGuid, &gSmmCpuPrivate->SmmConfiguration, NULL );
gSmmCpuPrivate->SmmConfiguration位于PiSmmCpuDxeSMM驱动程序内部,由于已经在EFI_BOOT_SERVICES中注册,因此该指针及其关联的GUID(gEfiSmmConfigurationProtocolGuid)将存储在普通环境中。使用EFI_BOOT_SERVICES.LocateProtocol,我们可以检索此指针。看起来很奇怪,这实际上是“故意”制作的。普通世界的驱动程序在引导阶段会使用此协议,而当它们使用该协议时,SMRAM尚未锁定。但是,可以通过在SMRAM的同时卸载此协议来避免此类漏洞。由于该驱动程序是edk2的一部分,因此大多数固件都将其集成在一起,并且该技术基本上可以在不同的构造函数之间移植。
利用这个泄漏,我们可以计算PiSmmCpuDxeSMM的基址(基址=泄漏地址-偏移量),利用它来推导出SMBASE地址(基址-0x10000 - tilesize * (numcpu - 1)),然后从该计算中获取已保存的状态地址。我在利用该技术时遇到的一个问题时CPU(numcpu)的实际数量与真实情况不符,因此我花了一些时间来弄清楚这个错误。实际上,可以使用EfiPiMpServicesProtocol来获取用于计算的实际数字,该数字可以从普通世界中访问。
至此,我们就具备了漏洞利用所需的一切条件。
完整漏洞利用步骤:
首先,我们需要获取已保存状态的地址:
1. 使用EFI_BOOT_SERVICES.LocateProtocol函数来检索EfiSmmConfigurationProtocol。
2. 通过该协议,我们得到了PiSmmCpuDxeSMM驱动程序中的泄漏。
3. 它允许计算SMBASE,并计算出我们的Shellcode所在的已保存状态的地址。
然后,我们需要触发漏洞利用:
1. 首先,使用刚刚计算出的值重写EFI_BOOT_SERVICES.LocateHandleBuffer函数的地址。
2. 使用存储在寄存器中的Shellcode触发SWSMI。
3. CPU将我们的Shellcode映射到之前计算的地址。
4. 调用SmmOEMInt15的SWSMI,特别是0x3E00处理程序。
5. 尝试获取LocateHandleBuffer地址时,它将检索已将Shellcode映射到的地址。
6. 最后,调用Shellcode,在SMM中实现代码执行。
五、总结
该漏洞已经在2019年8月在Lenove P51s中进行了静默修复。修复的方式非常简单——删除命令0x3E00的处理程序。但是,就像我们在研究过程中所看到的那样,该处理程序的原始代码会导致计算机崩溃,之所以将其删除,可能是因为它不再起作用,或者不再使用该功能。这个漏洞充分说明了应该进行SMM_CODE_CHK_EN加固的重要性,并不断推动BIOS开发人员删除对SMRAM的调用,这种类型的漏洞正在不断消失。
最后,应该特别说明的是,这个漏洞还不足以在SPI Flash上获得持久性。Lenovo P51s固件上使用了Intel Boot Guard(IBG),这是一种最新的机制,允许在引导时执行代码签名和固件代码的完整性检查。如今,SMM漏洞只是攻击者进行漏洞利用的第一步,要获得持久性往往还需要另一个漏洞来绕过IBG。
发表评论