Windows DSE(Driver Signature Enforcement)即任何驱动程序或第三方程式都要经过微软签名才能确保为正版、安全的程序。代码完整性是 15 年前由 Microsoft 首次推出的威胁防护功能。在基于 x64 的 Windows 版本上,内核模式驱动程序必须在每次加载到内存时进行数字签名和检查,这也被称为驱动强制签名 (DSE),检测是否将未签名的驱动程序或系统文件加载到内核中,或系统文件是否被修改(可能由具有管理权限的恶意软件运行),可以提高操作系统的安全性。
为了克服这些限制,攻击者使用有效的数字证书(无论是颁发给他们的还是他们窃取的)或者他们在运行时禁用 DSE。获得证书的难度很大,但篡改证书则纯粹是一个技术上的挑战。
尽管微软近年来一直在致力于解决驱动强制签名方面存在的问题,并提供了一系列解决方案,但利用众所周知,篡改DSE的方法越来越多,用此方法攻击的案例也明显增加,这促使我们要深入地研究这个问题。
我们会在本文分享我们的研究结果,以及两种DSE篡改方法的细节。
j00ru是谷歌项目Zero的一名安全研究员,他层发表了一篇关于Windows 7中代码完整性实现的介绍。
ntoskrnl.exe与附加的内核库CI.dll(代码完整性)一起工作。在操作系统初始化阶段,内核设置 nt!g_CiEnabled 并使用指向 nt!g_CiCallbacks 结构的指针调用 CI!CiInitialize 例程来初始化CI.dll。它依次设置CI!g_CiOptions 并在返回内核之前填充 CI!CiValidateImageHeader、CI!CiValidateImageData 和 CI!CiQueryInformation 回调的地址。
要使用回调函数,包装器函数存在于ntoskrnl.exe中。他们检查那个nt!g_CiEnabled设置为TRUE,适当的回调不是NULL,然后调用它。
当内核加载驱动程序时,执行通过 nt!MmLoadSystemImage 到 nt!MmCreateSection 并最终到 nt!MiValidateImageHeader 例程。这样,依次调用 nt!SeValidateImageHeader 和 nt!SeValidateImageData 包装器。每个回调都应在成功时返回零,否则返回非零值。
驱动程序加载时的调用堆栈
“Rootkits and Bootkits: Reversing Modern Malware and Next Generation Threats”一书详细说明了 Windows 8 中的实现更改过程:删除 nt!g_CiEnabled 变量,因此 DSE 状态仅由 CI!g_CiOptions 确定,更多回调函数被添加到CI.dll提供的接口中。书中没有描述的另一个变化是,如果验证标头成功,那么验证数据回调将不会被调用。
在Windows 8.1上,回调结构的符号名称被更改为nt!SeCiCallbacks。
除了签名的加密有效性之外,微软强制签名证书只能由支持CA 的交叉证书颁发。这可以防止攻击者简单地在每台计算机上安装自己的CA证书。
从Windows 10Redstone开始(2016年8月发布),驱动程序签名策略有所改变,需要微软自己进行第二次签名。这是通过一个web门户网站完成的,在那里开发人员上传他们的签名二进制文件,并将它们发送给微软。从攻击者的角度来看,这意味着将你的有效载荷传播给防御者,这与他们通常想要的相反。至少有一个记录在案的案例表明,这种新措施没有阻止攻击者。
KPP 或 PatchGuard 于 2005 年首次推出,是 x64 版 Windows 的一项功能,可防止修补内核。“修补内核”是指修改 ntoskrnl.exe 和其他关键系统驱动程序和数据结构(SSDT、IDT、GDT 等)的代码。
它通过定期检查这些受保护区域是否被修改而工作。如果检测到系统被修改,则会触发蓝屏,使系统停止。PatchGuard在每一个新的Windows版本中都会更新,这使得攻击者很难开发出适用于所有版本的通用绕过技术。
ntoskrnl.exe中的回调结构和CI!g_CiOptions变量分别从Windows 8和Windows 8.1开始受PatchGuard保护。
尽管 j00ru 得出结论,重写nt!g_CiEnabled 或 CI!g_CiOptions 等私有符号可能相对困难,但这正是攻击者选择的方向。众所周知,臭名昭著的 Turla APT 开发了这种技术,安全研究人员对其进行了逆向工程并发布了他们的代码。
这些私有符号通过简单的模式匹配来定位,几乎不需要任何更改:
常规 PTE 顺着 SLAT PTE 突出显示差异
在x86架构上,SLAT pte (Page Table Entries)处理权限不同于常规的PTE:读\写权限是分开的,执行权限需要显式设置,并且只能为ring 0(内核模式)授权。hypervisor使用 SLAT 页表来强制 VTL 之间的隔离并使 VTL1 可以访问它们,所以安全内核使用它们来实现VBS特性,而hypervisor本身并不为这些功能本身实现任何代码/逻辑。
HVCI,最初称为 Device Guard,是随着 VBS 的引入而发布的,这是完整性执行的另一层。
加载新驱动程序后,安全内核也会被触发并使用其自己的代码完整性库 SKCI.dll(安全内核代码完整性)实例。在 Secure World (VTL1) 中的当前策略中验证并检查数字签名以得到授权。只有这样才能将可执行和不可写权限应用于相应 GPA 的 SLAT 页表。因此,NT 内核 (VTL0) 不能修改任何以前加载的代码或运行任何新代码,而无需在进程中使用安全内核。
Windows 10 SKCI.dll 中 SkciInialize 及其自身 CiOptions 变量的反汇编
KDP 旨在保护在 Windows 内核(即操作系统代码本身)中运行的驱动程序和软件免受数据驱动的攻击。它最初是在 Windows 10 20H1 中引入的。
使用 KDP,在内核模式下运行的软件可以静态(其自身映像的一部分)或动态(只能初始化一次的池内存)保护只读内存。KDP 仅在 VTL1 中为支持受保护内存区域的 GPA 建立写保护,使用 SLAT 页表供管理程序强制执行。这样,在 NT 内核 (VTL0) 中运行的任何软件都不能拥有更改内存所需的权限。
KDP并不强制如何转换受保护区域的GVA范围映射,根据开发人员的介绍,KDP目前只定期验证受保护的内存区域是否转换为适当的GPA。
从 Windows 11 开始,CI.dll 选择使用静态 KDP (MmProtectDriverSection API) 来强化自身,将所有相关的 CI 策略变量放置在名为“CiPolicy”的单独部分中。
Windows 11 中 CI.dll 中 CiInitializePolicy 和 CiPolicy 部分的反汇编
这是通过 Windows Defender 应用程序控制 (WDAC) 或 HVCI 策略强制执行的,目前已有一些第三方安全产品供应商也采用了这种做法。可以在此处找到最新的阻止列表。
阻止列表拒绝攻击者轻松访问内核写入原语。虽然它非常有效,但它不是一种主动措施,只能处理以前发现的驱动程序。因此,这种缓解措施对驱动程序中的零日漏洞无效,防御者必须不断追踪它们,始终落后于攻击者一步。
从上面的描述中,敏锐的读者可以看到,如果没有启用 HVCI,则可能在不进行任何代码修补的情况下篡改 DSE。
方法一:“页面交换”
仅仅因为不再可能写入 CI!CiOptions 并不意味着它的值不能改变。该变量仍被虚拟地址访问,并且每次都会发生到物理地址的转换过程。因此,我们将改为更改翻译结果。
通过将物理页面从受 KDP 保护的页面交换到我们拥有的页面,我们重新获得了对内存的完全控制权。交换 GPA 仅意味着更改 PTE(页表条目)中的 PFN(页帧号),它本质上只是另一个指针。
我们可以为任何给定的虚拟地址计算 PTE 的虚拟地址,避免每次遍历所有页表。页表位于 Windows 内核用来管理分页结构的虚拟内存区域中,称为“PTE 空间”。PTE Base 由 KASLR(内核地址空间布局随机化)随机化,从 Windows 10 Redstone 开始。在之前的研究中,我们展示了一种可靠的方法来找到它。
除了写入之外,执行此方法还需要内核读取和内存分配原语。以下是 C 伪代码的分步实现过程:
页面交换的C伪代码
可以使用来自用户空间的页面,因此内核内存分配原语变得多余。对于 CI.dll,可以使用变量的默认值而不是复制页面。结果,内核空间的读取次数显着减少,因为只剩下少数必要的 PTE 读取。
有一段时间,KDP 似乎提高了防止 DSE 篡改的门槛,因为页面交换也需要内核读取原语。我们再次查看了 CI.dll 和 ntoskrnl.exe 是如何集成的,然后我们想,“为什么要使用 CI.dll 呢?让我们使用自己的回调而不是 CI!CiValidateImageHeader”。
回调交换演示
上图证明无需读取任何内核空间数据即可找到所有必要的地址,具体过程如下:
首先,在 ntoskrnl.exe 中找到回调结构。该结构作为参数传递给 CI!CiInitialize,这样我们就可以从调用中获得它的地址。内核只调用该函数一次,因此我们查找使用其导入表条目的CALL或JMP指令。找到调用站点后,返回到“.data”部分中指向未初始化内存的参数的寄存器分配。
在Windows 11的ntoskrnl.exe中反汇编SepInitializeCodeIntegrity和SeCiCallbacks
接下来,寻找要使用的替换回调函数。我们需要一个不带参数并返回零的函数。幸运的是,ntoskrnl.exe 有一些符合此要求的导出函数,例如 FsRtlSyncVolumes 或 ZwFlushInstructionCache,因此只需调用 GetProcAddress 即可。
最后,找到要恢复的原始回调函数。回调由 CI!CipInitialize 在结构中设置,因此它将引用所有回调。所有回调都设置在所有 Windows 构建的单个基本代码块中。搜索这种指令模式,如下图所示,并从 lea 指令中提取偏移量。要验证偏移量是否确实导致函数,遍历 PE 的异常目录以查找具有相同起始地址的 RUNTIME_FUNCTION 条目。
Windows 11中CI.dll中CipInitialize的反汇编
KDP 保护被“设计”绕过,因为 ntoskrnl.exe 没有选择使用回调结构。更改回调的另一个优点是篡改通过记录的查询系统信息 API 不可见。
虽然解析地址可能需要多行代码,但它是在用户空间中完成的,因此只需要内核写入原语,这与当前众所周知的 DSE 篡改方法相同。尽管 PoC 向内核空间写入了 8 个字节(64 位指针的大小),但可以通过在 CI.dll 中查找回调目标来减少这个数字。在撰写本文时,来自TrustedSec的Adam Chester也发布了一篇最新的研究文章,他选择了一种替代方法,通过扫描他创建的二进制签名来找到原始回调。
HVCI 涵盖了所有篡改方法,因为它在加载驱动程序时执行自己的验证。尽管 HVCI 已经存在多年,但直到最近才在新的 Windows 安装中默认启用。因此,我们一定要想出一个替代方案。
我们试图找到一种方法来在驱动程序加载期间确认 DSE 的状态。此外,一个将支持程序的阻塞。在这一点上,如何获得 DSE 状态的可见性应该是显而易见的,防御者可以利用攻击者用来查找内部变量的相同策略。毕竟,它已被证明是稳定的。
考虑到一个被篡改的状态只能持续很短的时间,假设我们开始运行时系统状态是有效的是合理的。此时,将保留内部变量的副本。
有3个选项可以拦截驱动程序加载:
1.在 NtLoadDriver API 的用户空间中放置一个钩子。
2.使用注册表回调来监视驱动程序注册表项路径上的操作。
3.使用文件系统微过滤器回调来创建驱动程序文件的部分 (IRP_MJ_ACQUIRE_FOR_SECTION_SYNCHRONIZATION)。
要知道回调是否作为驱动程序加载的一部分被触发,它需要检查当前进程是 SYSTEM 并且调用堆栈源自 ntoskrnl.exe 而不是任何其他驱动程序。
一旦驱动程序加载被拦截,通过检查任何变量的变化来检测 DSE 篡改。此时,可以简单地通过使用错误状态代码阻止 I\O 请求或将变量恢复到其保存状态来进行预防。
我们在本文中介绍了微软如何尝试在运行时保护 DSE,并介绍了两种新方法来篡改它并成功加载未签名的驱动程序。
虽然 HVCI 提供了最强大的解决方案,但我们共享了一种额外的方法来检测和防止 DSE 在运行时篡改。在内核模式下执行代码对攻击者仍然具有吸引力,因为它通常是破坏计算机上更高特权元素(例如管理程序、UEFI 和 SMM)或击败终端安全产品的必备手段。我们可能会看到 TTP 的转变,由于驱动程序阻止列表,攻击者会转向使用更多的内核1日漏洞来利用未修补的漏洞。
随着硬件辅助的安全功能变得越来越普遍,以及微软致力于利用它们的努力,攻击者利用 DSE 篡改可能会在可预见的未来逐渐消失。尽管如此,配置错误和老旧系统仍将存在这种攻击。
参考及来源:https://www.fortinet.com/blog/threat-research/driver-signature-enforcement-tampering