Windows拓展控制流保护机制(XFG)分析

robots

 

微软似乎在不断扩展其安全防护和缓解机制,并将其应用到Windows 10操作系统上。本文介绍一种微软即将推出的安全防护机制:eXtended Flow Guard(XFG)。

XFG目前尚未发布,也不会应用到即将发布的Windows 10 21H1版本中。到目前为止,微软唯一一次公开提及XFG是在2019年上海Bluehat大会上的演讲Advancing Windows Security

虽然XFG还未发布,但是可以使用Visual Studio 2019 Preview编译包含XFG防护机制的应用程序。目前已经有一些介绍XFG工作原理的博客文章,但这些文章主要介绍如何将XFG编译到自定义的应用程序中,而不是深入研究常见的利用场景。

这篇文章的目的是让人们更加清楚地了解XFG是否真的是一个更加安全的控制流保护(CFG)机制版本,本文首先从简要回顾CFG和XFG的工作原理开始。

 

CFG原理回顾

CFG是一种粗粒度的控制流完整性(CFI)解决方案,它维护与每个函数对应的位图,并在调用时确定所讨论的函数是否是有效的调用目标。

微软已经公开承认CFG的缺点之一是返回地址被覆盖,这个问题将由Intel CET和Shadow Stack解决。XFG的设计与CFG非常相似,因此还必须依赖Shadow Stack来防止返回地址被覆盖。

由于我们关注的是XFG,而不是单独的安全缓解措施(如Intel CET和Shadow Stack),因此我们将探讨使用有效调用目标的vtable覆盖。

浏览器漏洞利用时,获得指令指针控制权的一种常见方法是覆盖objects vtable中的条目并调用相关的方法。CFG的引入实际上是为了缓解这种类型的利用场景。

由于CFG是一个粗粒度的CFI解决方案,只要它是有效的调用目标,vtable条目可以替换为不同的函数指针。这意味着CFG不考虑调用位置,只考虑调用目标。

为了更好地理解这一点,让我们研究一下ntdll.dll中的API NtCreateFile以及CFG是如何检查的。CFG检查由函数LdrpDispatchUserCallTargetESS执行,它期望在RAX寄存器中找到NtCreateFile的地址。

为了模拟这一点,我们可以在WinDBG中修改RAX和RIP,并逐步执行LdrpDispatchUserCallTargetESS的第一部分:

0:019> r rip = ntdll!LdrpDispatchUserCallTargetES
0:019> r rax = ntdll!NtCreateFile
ntdll!LdrpDispatchUserCallTargetES:
00007ffb`27dd11d0 4c8b1dd1910f00  mov     r11,qword ptr [ntdll!LdrSystemDllInitBlock+0xb8 (00007ffb`27eca3a8)] ds:00007ffb`27eca3a8=00007df5f77d0000
0:019> p
ntdll!LdrpDispatchUserCallTargetES+0x7:
00007ffb`27dd11d7 4c8bd0          mov     r10,rax
0:019>
ntdll!LdrpDispatchUserCallTargetES+0xa:
00007ffb`27dd11da 49c1ea09        shr     r10,9
0:019>
ntdll!LdrpDispatchUserCallTargetES+0xe:
00007ffb`27dd11de 4f8b1cd3        mov     r11,qword ptr [r11+r10*8] ds:00007ff5`e41c7868=1111144444444444

LdrpDispatchUserCallTargetESS使用RAX中的函数地址作为CFG位图的索引,并获取一个64位的值。

接下来,执行位测试来检查提供的函数地址是否是有效的调用目标。这是通过再次使用函数地址作为索引来完成的:

ntdll!LdrpDispatchUserCallTargetES+0x12:
00007ffb`27dd11e2 4c8bd0          mov     r10,rax
0:019>
ntdll!LdrpDispatchUserCallTargetES+0x15:
00007ffb`27dd11e5 49c1ea03        shr     r10,3
0:019>
ntdll!LdrpDispatchUserCallTargetES+0x19:
00007ffb`27dd11e9 a80f            test    al,0Fh
0:019>
ntdll!LdrpDispatchUserCallTargetES+0x1b:
00007ffb`27dd11eb 7509            jne     ntdll!LdrpDispatchUserCallTargetES+0x26 (00007ffb`27dd11f6) [br=0]
0:019>
ntdll!LdrpDispatchUserCallTargetES+0x1d:
00007ffb`27dd11ed 4d0fa3d3        bt      r11,r10
0:019>
ntdll!LdrpDispatchUserCallTargetES+0x21:
00007ffb`27dd11f1 731b            jae     ntdll!LdrpDispatchUserCallTargetES+0x3e (00007ffb`27dd120e) [br=0]
0:019>
ntdll!LdrpDispatchUserCallTargetES+0x23:
00007ffb`27dd11f3 48ffe0          jmp     rax {ntdll!NtCreateFile (00007ffb`27de1ab0)}

在这个示例中,我们发现NtCreateFile是一个有效的调用目标,LdrpDispatchUserCallTargetES通过JMP指令向其分派执行。

我们有时可以用一个有效的调用目标函数的地址覆盖vtable指针,来完成漏洞利用。为了使其正常工作,我们还需要能够控制函数的参数,这超出了本文的范围,就不再进一步介绍。

总之,CFG被称为“粗粒度”,因为它只考虑调用目标,而不考虑调用位置,这使得它更容易被绕过。

 

XFG原理介绍介绍

根据2019年上海Bluehat演讲中介绍,微软正试图开发和实现一个更细粒度的CFI解决方案XFG,XFG技术将同时考虑到调用目标和调用地址。

一般的概念是,在每次使用XFG之前,编译器将根据函数名、参数数目、参数类型和返回类型生成55位hash,这个hash将在调用XFG之前嵌入到代码中。

下面是从Chakra.dll中获取的代码片段,它在insider preview版本中使用了XFG:

.text:000000000002E8CC mov     rcx, [rbx+18h]
.text:000000000002E8D0 and     [rsp+48h+var_18], 0
.text:000000000002E8D5 mov     rax, [rcx]
.text:000000000002E8D8 mov     r10, 0F8D8BEB272D33870h
.text:000000000002E8E2 mov     rdx, [rsp+48h+arg_8]
.text:000000000002E8E7 lea     r9, [rsp+48h+var_18]
.text:000000000002E8EC mov     rax, [rax+18h]
.text:000000000002E8F0 mov     r8d, 4
.text:000000000002E8F6 call    cs:__guard_xfg_dispatch_ical

可以看出,hash值被保存在R10寄存器中。

与CFG一样,调用目标的函数地址放在RAX中。

XFG函数被称为LdrpDispatchUserCallTargetXFG,我们可以通过手动将RIP设置为LdrpDispatchUserCallTargetXFG,将RAX设置为NtCreateFile来演示它的工作原理:

0:019> r rip = ntdll!LdrpDispatchUserCallTargetXFG
0:019> r rax = ntdll!NtCreateFile
0:019> p
ntdll!LdrpDispatchUserCallTargetXFG+0x4:
00007ffb`27dd1234 a80f            test    al,0Fh
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x6:
00007ffb`27dd1236 750f            jne     ntdll!LdrpDispatchUserCallTargetXFG+0x17 (00007ffb`27dd1247) [br=0]
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x8:
00007ffb`27dd1238 66a9ff0f        test    ax,0FFFh
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0xc:
00007ffb`27dd123c 7409            je      ntdll!LdrpDispatchUserCallTargetXFG+0x17 (00007ffb`27dd1247) [br=0]
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0xe:
00007ffb`27dd123e 4c3b50f8        cmp     r10,qword ptr [rax-8] ds:00007ffb`27de1aa8=0000000000841f0f

在上面显示的最后一条指令中,R10中的哈希值与调用目标之前的值8字节进行比较。编译器将在每个函数之前插入生成的哈希。

由于调用目标将在调用LdrpDispatchUserCallTargetXFG之前将哈希值移到R10中,因此这两个值应该匹配才能允许执行。如果比较成功,则通过JMP指令调度执行。

这种编译时散列的使用比CFG的粗粒度方法更难绕过。使用不同的函数指针重写vtable似乎几乎不可能,因为55位的哈希冲突不太可能。

 

XFG局限性分析

在这一点上,似乎XFG成功地减少了任何有意义的vtable重写的尝试,并且比CFG安全得多。但是,我们仍然需要研究哈希比较失败时会发生什么。

如果执行没有到调用目标,则执行下面所示的代码段:

ntdll!LdrpDispatchUserCallTargetXFG+0x17:
00007ffb`27dd1247 4c8bd8          mov     r11,rax
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x1a:
00007ffb`27dd124a 48c1e008        shl     rax,8
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x1e:
00007ffb`27dd124e 418ac2          mov     al,r10b
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x21:
00007ffb`27dd1251 48c1c808        ror     rax,8
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x25:
00007ffb`27dd1255 49c1eb09        shr     r11,9
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x29:
00007ffb`27dd1259 49c1e303        shl     r11,3
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x2d:
00007ffb`27dd125d 4c031d44910f00  add     r11,qword ptr [ntdll!LdrSystemDllInitBlock+0xb8 (00007ffb`27eca3a8)] ds:00007ffb`27eca3a8=00007df5f77d0000
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x34:
00007ffb`27dd1264 4d8b1b          mov     r11,qword ptr [r11] ds:00007ff5`e41c7868=1111144444444444

我们注意到,调用目标函数地址被移到R11中,并用作CFG位图的索引。同样的64位CFG位图值被移到R11代码段的末尾。

一开始这看起来很混乱,但是如果继续执行,我们还会发现下面显示的代码:

ntdll!LdrpDispatchUserCallTargetXFG+0x37:
00007ffb`27dd1267 48c1c803        ror     rax,3
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x3b:
00007ffb`27dd126b 448ad0          mov     r10b,al
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x3e:
00007ffb`27dd126e 48c1c003        rol     rax,3
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x42:
00007ffb`27dd1272 a80f            test    al,0Fh
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x44:
00007ffb`27dd1274 7511            jne     ntdll!LdrpDispatchUserCallTargetXFG+0x57 (00007ffb`27dd1287) [br=0]
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x46:
00007ffb`27dd1276 4d0fa3d3        bt      r11,r10
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x4a:
00007ffb`27dd127a 732b            jae     ntdll!LdrpDispatchUserCallTargetXFG+0x77 (00007ffb`27dd12a7) [br=0]
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x4c:
00007ffb`27dd127c 48c1e008        shl     rax,8
0:019> p
ntdll!LdrpDispatchUserCallTargetXFG+0x50:
00007ffb`27dd1280 48c1e808        shr     rax,8
0:019>
ntdll!LdrpDispatchUserCallTargetXFG+0x54:
00007ffb`27dd1284 48ffe0          jmp     rax {ntdll!NtCreateFile (00007ffb`27de1ab0)}

调用目标地址再次用作索引,通过位测试检查位图值。在这一点上,我们应该注意到代码几乎与CFG相同。

在位测试之后,我们再次发现NtCreateFile是一个有效的调用目标,并向它发送执行。即使我们没有提供任何哈希,并且初始哈希比较失败,也会发生这种情况。

实际上,当没有提供正确的散列时,XFG返回到使用CFG。从NtCreateFile显示的示例中,很明显,XFG不会阻止我们用CFG有效的调用目标覆盖vtable。

 

总结与结论

从表面上看,XFG似乎是一个更细粒度的CFI解决方案,应该可以减轻大多数试图覆盖vtable的利用技术。

然而,在XFG的实现中,当基于散列的比较失败时,微软基本上内置了一个安全降级机制,这意味着XFG不会比CFG更安全,面临同样的攻击风险。

应该注意的是,XFG只在insider preview中可用,因此在发布之前可能会进行更改。在撰写本文时,它的当前实现已经超过六个月了。

 

参考资料

1.(Microsoft, 2021): https://insider.windows.com/en-us/

2.(David Weston, 2019): https://query.prod.cms.rt.microsoft.com/cms/api/am/binary/RE37dMC

3.(Connor McGarr, 2020): https://connormcgarr.github.io/examining-xfg/

4.(Quarkslab, 2020): https://blog.quarkslab.com/how-the-msvc-compiler-generates-xfg-function-prototype-hashes.html

(完)