Windows 控制流保护(CFG)分析

Niels Pfau、Patrick Kochberger
ARES 2024,维也纳,2024 年 7 月 30 日至 8 月 2 日
原文 DOI:https://doi.org/10.1145/3664476.3670432

译注:本文是 cfg2-en.pdf 的中文 Markdown 译稿。函数名、寄存器名、汇编指令、Windows 标志、漏洞编号和命令均保留英文原名,以免改变技术含义。原文中的完整调试器输出、汇编图和 JavaScript 清单不适合双栏文本重排,本文保留其功能说明与关键代码语义。

摘要

网络安全不断演进,防御机制也必须持续发展和完善。缓冲区溢出、释放后使用(use-after-free,UAF)等内存破坏攻击,长期以来一直是严重威胁,尤其影响 Web 浏览器。为应对这类风险,微软引入控制流保护(Control Flow Guard,CFG),以缓解 ROP 和基于 UAF 的利用等高级攻击技术。

本文考察 CFG 的内部机制、实现、有效性以及可能削弱其安全性的绕过方式。通过分析微软的 CFG 设计原则,读者可以深入理解它如何在程序执行期间维护控制流完整性。论文还以 ChakraCore JavaScript 引擎为例,通过直接覆写返回地址展示该缓解措施的局限;并进一步讨论其他可能规避 CFG 的场景。结论是:CFG 对特定利用方式有效,但必须与其他缓解措施组合使用,并持续研究新的防护方案。

**CCS 概念:**安全与隐私;Web 应用安全;软件逆向工程。
**关键词:**软件安全、利用、缓解措施、Windows、控制流保护。

1. 引言

缓冲区溢出、任意地址写(write-what-where)及 UAF 等内存破坏漏洞,通常都试图劫持一个进程的控制流。攻击者一旦能控制该控制流,就可能把执行重定向到自己的代码,从而取得任意代码执行能力。代码执行权限会同时危害机密性、完整性和可用性:攻击者可以监视用户、窃取或篡改数据,或使进程、系统不可用。

现实中遭利用的零日漏洞尤其危险,会威胁企业、政府和记者、政治人物等高价值目标。尽管现代技术提高了利用难度,Google Project Zero 的统计显示,近年“在野利用”的零日攻击数量仍呈增长趋势。

不能指望开发者永远不犯错,因此操作系统、链接器和编译器必须提供额外的对策。软件本身存在漏洞时,这些对策尝试让底层缺陷难以被利用。内存安全语言也能通过消除内存破坏类错误来提高应用安全性。微软为此采用 DEP、ASLR 等不同机制;但即使二者结合十分强大,在特定场景下攻击者仍可能绕过它们。因此微软继续开发了 Arbitrary Code Guard、Return Flow Guard、Supervisor Mode Execution Prevention 等技术。

开发缓解措施时最大的障碍是性能开销。例如性能代价很高的 Return Flow Guard 并未随稳定版 Windows 发布。本文分析 CFG——一种以控制流完整性(CFI)为目标的缓解技术——的汇编级实现、安全影响、性能影响及潜在绕过方法。

本文的贡献如下:

  • 分析微软首次以 CFI 为基础、试图使更多漏洞失去可利用性的缓解机制 CFG;
  • 考察相关 Windows API 与底层函数,理解其工作方式;
  • 评估不同的 CFG 绕过思路,讨论其缺陷、限制与后续研究方向。

2. 相关工作

Li 等人研究了 CFI 的概念、历史和多种实现,分析了 12 种开源 CFI 技术的设计与实现缺陷,并以 CScan、CBench 模拟攻击者、评估其效果。其基准测试表明,这些方案无法保护所有间接调用目标,并存在明显兼容性问题。该研究有意未覆盖微软 CFG,因此留下了研究 CFG 内部机制与弱点的空间;本文建立在其关于常见 CFI 设计、实现缺陷的洞见之上。

McGarr 在关于 CVE-2019-0567 的文章中研究了 CFG 的功能与不足。该漏洞位于微软开源 JavaScript 引擎 ChakraCore,类型混淆可被利用以获得任意读写原语,继而泄露进程内存并劫持控制流。其工作解释了 JavaScript 对象布局,基于 Project Zero 的崩溃 PoC 构造任意写、任意读与绕过 NaN-boxing 的 64 位读写原语,最终泄露栈、绕过 ASLR/DEP,并在 ChakraCore 中实现代码执行。本文关注其中与 CFG 有关的部分。

Yunhai 在 Black Hat 演讲中提出多个“通用绕过”方法,意图跨不同 Windows 和 CFG 版本适用。本文扩展其资料,以 CVE-2019-0567 的利用尝试测试一种技术,并在理论层面讨论现代 Windows 中可能可行的其他方法。原资料中两项已被微软修复、在新 CFG 上不再有效的技术——利用 ntdll!NtContinue 与覆写 CFG 例程函数指针——不在本文重点之内。

Tang 等人曾通过逆向 Windows 10 Technical Preview(build 6.4.9841)分析 CFG 的系统启动、位图创建、运行时检查及弱点。由于目标是预览版系统,其中一些细节已经过时。本文更新分析目标至 Windows 10 Pro 22H2(Build 19045.4046)。

3. 研究方法

本文从三个方面理解微软的 CFG:

  • 分析该机制的基本概念及相关(内核)函数;
  • 分析 LdrpDispatchUserCallTarget() 的汇编代码;
  • 以不同思路进一步考察 CFG 的可能绕过方式。

实验环境包括 Windows 10 Pro 22H2(OS Build 19045.4046)、WinDbg Preview 1.2210.3001.0、IDA Freeware 8.2.230124 及 ChakraCore 提交 331aa3931ab69ca2bd64f7e020165e693b8030b5

4. 控制流保护

Windows CFG 建立在 CFI 概念之上。每个软件都应有其自然、合法的控制流;攻击者若通过内存破坏等漏洞改变它,就可能把执行重定向到恶意代码或 shellcode,从而实现(远程)代码执行。

相关的 CFI 概念还包括 Return Flow Guard、eXtended Control Flow Guard(XFG)及 Intel Control-flow Enforcement Technology(CET)。

4.1 释放后使用漏洞

理解 CFG 前,必须先理解它试图缓解的漏洞类型。攻击者可以通过下列方式劫持控制流:

  • 经典缓冲区溢出或类型混淆导致的直接返回地址覆写;
  • write-what-where 漏洞导致的直接返回地址覆写;
  • SEH 覆写(仅适用于 x86);
  • UAF 漏洞;
  • 与内存破坏无关的攻击,例如命令注入或 DLL 劫持。

CFG 主要意在缓解其中借助已释放对象的虚函数表(vftable)重定向控制流的 UAF 漏洞。C++ 等支持面向对象概念的内存不安全语言允许对象具有虚函数;程序通过对象的 vftable 访问它们。

例如,调用 classTwo->sharedFunction() 时,汇编层面会跳转到 vftable 中保存的函数地址。若对象已经被释放、却仍调用其虚函数,程序会访问未分配或未映射内存并崩溃,这就是 UAF。默认情况下它造成拒绝服务;但若攻击者能在调用虚方法前控制这片已释放内存,便能篡改 vftable 中的函数指针并劫持控制流。

无 CFG 时,虚函数调用的大意如下:先把对象载入 RAX,解引用得到 vftable,再间接调用其中的地址。调用第 2 个至第 n 个虚函数时,间接调用的偏移按指针大小递增(32 位为 4 字节,64 位为 8 字节)。

1
2
3
4
mov rax, qword ptr [rsp+20h]  ; 对象
mov rax, qword ptr [rax] ; vftable
mov rcx, qword ptr [rsp+20h]
call qword ptr [rax] ; 虚函数目标

若启用 CFG,编译器不会直接调用 RAX 中的地址,而会把目标保存后经由 __guard_dispatch_icall_fptr 调度。该函数指针指向 CFG 例程,用来验证 RAX 中目标地址的合法性。

4.2 微软的 CFG 实现

CFG 需要在系统初始化阶段准备。早期分析显示,NT 内核通过 nt!MiInitializeCfg 初始化 CFG;该函数依体系结构调用 nt!MmCreateSection 创建包含一个或两个 CFG 位图的共享内存节对象。

对于 x86,一个节对象足够;在 x64 上需要两个位图:一个服务 64 位进程,另一个服务 WoW64 的 32 位进程。原有资料提到 nt!MiParseImageCfgBits,但本文在稳定版 Windows 10 内核中未发现该函数或明显的改名版本,这意味着预览版与稳定版的实现细节已经变化。

4.2.1 CFG 位图

CFG 需要以近似 O(1) 的代价判断相对跳转和间接调用的目标是否合法,因此采用位图而不是数组;后者将导致 O(n) 查找和不可接受的性能损失。

64 位进程理论上有 128 TB 可寻址空间。若每个地址使用一个布尔值,位图会比地址空间本身还大。利用编译器通常按 16 字节边界生成 x64 函数代码,可将规模先降至约 8 TB;再将“布尔值”由 1 字节改成 1 位,可降至 1 TB。但不是每个函数都会 16 字节对齐,优化、内联汇编或编译器设置都可能破坏该假设。

微软因而在每 16 字节范围使用两个位:

位状态 含义
{0, 0} 这 16 字节范围内没有有效函数入口。
{1, 0} 有效函数恰好从对齐的 16 字节地址开始。
{1, 1} 有效函数从该 16 字节范围内的某个非对齐位置开始。

这使 CFG 可以在函数不按典型 16 字节边界对齐时仍正常工作。代价是若链接器产生未对齐函数,攻击者可能跳到其前 16 字节的其他位置;但必须同时满足未对齐和攻击者确实能从该范围获益,风险相对受限。

不同架构下的理论位图大小如下:

应用与体系结构 位图大小
x86 或 x64 上的普通 32 位应用 32 MB
3 GB x86 模式、启用 /LARGEADDRESSAWARE 的 32 位应用 48 MB
x64 上、启用 /LARGEADDRESSAWARE 的 32 位应用 2 TB + 64 MB
64 位应用 2 TB

对于较大的位图,微软用两项技术降低内存与加载成本:

  1. 内存管理器仅提交至少含一个 1 位的页面。若某个 4 KB 区域全为 {0,0},只保留而不提交该区域;因此会保留完整的 2 TB 地址范围,但只提交含 {1,0}{1,1} 的页面。CFG 把仅保留的区域视为无效目标。
  2. Windows 为降低 ASLR 成本,通常只在系统启动时随机化一次库的基址,重启后才会产生新基址。因此同一库在多个进程中的 CFG 位图相同,可作为以页文件为后备、可共享的内存映射;物理页在 RAM 中只需一份。这样对已加载模块,主要只须计算进程私有内存的位状态,加载开销接近于零。

应用启动或加载映像时,会依据链接器在编译期创建的有效目标列表构造位图。链接器把所有有效函数目标的 RVA 存在 PE 的 .gfids 节中(默认与 .rdata 合并);即使映像自身没有直接调用的函数,只要可能被导出或经 GetProcAddress() 获取,也会列入。dumpbin /HEADERS /LOADCONFIG 可以从 EXE 或 DLL 中查看这类 CFG 元数据。

启用 ASLR 的映像在加载时使用相对虚拟地址,实际函数地址为:

1
Function VA = Function RVA + Image Base Address

若 DLL 或二进制文件头中不存在 CFG 元数据,则该映像内的每个地址都可作为间接调用或跳转目标。因此,只有应用使用的每个模块都启用 CFG 时,CFG 才有效。对于未使用 ASLR、且未按首选基址映射的映像(例如 rebasing),由于其再次加载时基址无法保证一致,对应位图页面必须设为进程私有页面。

4.2.2 CFG 的改进

早期实现中,攻击者若能诱使程序分配新的可执行内存(JIT 引擎的合法行为常会这么做),该区域所有地址都会在 CFG 位图中标记为 {1,1},从而使攻击者可以跳至该区域任意位置,实质绕过 CFG。

微软引入两个内存保护标志以更精细地控制这一行为:

  • VirtualAlloc() 支持 PAGE_TARGETS_INVALID0x40000000),使新分配内存中的所有地址初始均为无效 CFG 目标;任何到这些地址的间接调用或跳转都会触发 CFG 并终止进程。
  • VirtualProtect() 不支持 PAGE_TARGETS_INVALID,但支持同值的 PAGE_TARGETS_NO_UPDATE。正常情况下,把区域改为可执行会将其中地址设为有效目标;使用此标志会阻止函数更新 CFG 信息。若区域此前无效,调用后仍无效;若调用不将内存设为可执行,该标志无效。

这两项设置避免了可执行内存一经分配就自动成为有效间接目标,对经常分配和生成 JavaScript 机器码的 JIT 引擎尤为重要。对于需要将特定 JIT 入口标为有效目标的场景,可用 SetProcessValidCallTargets();但因为攻击者若能调用它就可标记自己分配的内存,该 API 默认受到 CFG 抑制。

微软还加入了“导出抑制”(export suppression)和“调用抑制”(call suppression),可由编译器、操作系统或开发者把潜在危险函数列入黑名单:

GFIDS 标志 含义
IMAGE_GUARD_FLAG_FID_SUPPRESSED / 0x1 调用目标被显式抑制。
IMAGE_GUARD_FLAG_EXPORT_SUPPRESSED / 0x2 调用目标作为导出被抑制。

调用抑制会使函数对所有间接调用/跳转均无效;导出抑制则允许模块自身间接调用该函数,但禁止导入该函数的其他模块或二进制文件间接调用它。PE 查看工具(如 peview)可读取这些属性。

4.2.3 运行时 CFG

Windows 8.1 Update 3 的早期 CFG 实现中,进程先保存间接调用目标,再调用 __guard_check_icall;它会跳至函数指针 ntdll!__guard_dispatch_icall_fptr 所指的验证函数 ntdll!LdrpDispatchUserCallTarget()。该函数使用 CFG 位图验证目标是否可用于间接调用或跳转。

验证过程如下:

  1. 将 CFG 位图基址载入寄存器(清单中的例子为 R11)。
  2. 将待调用地址从 RAX 复制至 R10,右移 9 位,即除以 512;再将所得偏移加入位图基址,定位包含验证位的地址。
  3. 还原原始目标地址至 R10,右移 3 位,即除以 8。
  4. test 检查子寄存器 AL 的低四位是否有任意一位置 1;若有则跳至处理非 16 字节对齐地址的分支。
  5. 执行位测试;若位图对应位已置位,则跳至目标地址;否则进入无效目标处理。

若目标地址有效,执行间接调用或跳转并继续运行;若无效,则通过 LdrpHandleInvalidUserCallTarget()__fastfail 终止进程,错误码为 CFG Indirect Call Failure (0xA),表示 CFG 发现间接 calljmp 的目标不符合 CFG 位图允许的调度目标。

从位图状态看,运行时结果为:

  • {0,0}:目标无效;
  • {1,0}:只有目标地址 16 字节对齐时有效;
  • {1,1}:目标地址非 16 字节对齐时有效;
  • {0,1}:目标因被抑制而无效。

不在位图中的地址只是“可能无效”,因为抑制等原因会影响位图状态;同时,某些被抑制调用也可能由注册表等机制在该进程中被允许。

4.2.4 内核 CFG

Visual Studio 可通过 /guard:cf 编译将在内核模式运行的驱动。Windows 10 和 Windows 8.1 Update 3 的早期版本并未积极使用内核 CFG(kCFG)。用户模式 CFG 位图由 Windows 内核保护,而内核中没有更高权限实体保护 kCFG 位图:攻击者若利用脆弱驱动获得内核代码执行,便可能修改页表项,将所有地址标为有效目标。因此早期为驱动启用 CFG 会带来性能成本,却难以提供有效防护。

较新的 Windows 10 中,VBS(基于虚拟化的安全)可借助硬件虚拟化创建并隔离内存区域。启用 VBS 后,系统同时有两个内核:VTL 0 的标准 Windows 内核,以及 VTL 1 的安全内核;VTL 0 的内核、驱动和进程不能控制或修改 VTL 1 数据。

安全内核利用二级地址转换(SLAT)限制标准内核访问,从而保护内核模式驱动的 CFG 位图。内核模式进程可覆写其他进程的普通 PTE,但对 VTL 0 内核而言,SLAT 页表项是只读的;攻击者若试图通过改写页表绕过 kCFG,会遇到第二条边界,HyperGuard 可据此处理该利用尝试。

kCFG、VBS 和 Windows 内核不在本文主要范围内。与用户模式 CFG 相比,kCFG 的实现总体相近,但不支持导出抑制、longjmp 支持以及为 JIT 动态申请额外位,因为这些对驱动都是不符合预期的行为。

5. 性能影响

额外的安全机制必然带来安全与性能之间的权衡。论文使用 hyperfine 对 ChakraCore JavaScript 引擎进行基准测试:一个 JavaScript 文件包含大量面向对象概念,以触发许多虚函数调用;另一个主要进行数学运算,以避免虚函数调用。每组测试先预热 3 次,再实际运行 50 次。

图 4 的结果表明,CFG 对应用性能的影响极小;无论是否频繁进行虚函数调用,启用 CFG 的运行时间差异都不显著。

6. 绕过 CFG

CFG 因其设计存在若干限制,在合适条件下可被规避。需要强调的是,至今没有已知“直接”绕过 CFG 的通用技术:直接绕过通常要先绕过内存保护,例如篡改位图。攻击者更常利用包装函数、二进制降级、返回地址覆写等间接路径。

微软知悉这些限制,并将目前已知的许多规避方式排除在其漏洞缓解绕过赏金计划范围外,包括:

  • 通过返回地址破坏劫持控制流;
  • 利用粗粒度 CFI 的限制,例如脱离上下文调用函数;
  • 利用未启用 CFG 的映像;
  • 依赖修改或破坏只读内存的绕过;
  • 依赖 CONTEXT 记录破坏、竞态条件、异常处理或线程暂停的绕过;
  • 间接调用前缺少 CFG 插桩的情况。

该计划中真正属于范围的技术,是在已启用 CFG 的进程中,通过一个受 CFG 保护的间接调用或跳转获得指令指针控制权的技术。

7. 返回地址覆写

若攻击者可以直接覆写函数返回地址,并绕过 DEP 等其他缓解措施,CFG 即失去作用。原因是攻击者一旦控制指令指针,对控制流的粒度控制甚至超过原程序本身,必要时可以“绕开” CFG 的检查路径。

论文使用 ChakraCore 的 CVE-2019-0567 演示该场景。该类型混淆漏洞提供任意读原语以泄露栈位置,也提供任意写原语以覆写栈上的返回地址。PoC 不篡改 vftable,而是直接覆写返回地址,并在以 CFG 编译的 ChakraCore 中获得代码执行。

其思路是泄露 stackLimitForCurrentThread 字段(当前线程栈的字节级边界),然后枚举栈寻找函数的返回地址。利用链从 dataview2 对象的 type 指针出发,沿 javascriptLibraryScriptContextThreadContext 等指针遍历,再以静态偏移定位栈边界。随后使用循环和读原语扫描栈,找到 chakracore!JsRun 的返回地址;用写原语将其覆写为第一个 ROP gadget,并连续布置余下 gadget,最终经 kernel32!WinExec 启动计算器。

由于此利用并不通过虚函数调用来劫持控制流,不会执行相应的 CFG 调用检查例程。因此 CFG 无法检测或阻止该劫持。类型混淆被利用、其他缓解措施被绕过后,攻击者就可以执行 shellcode。

8. 其他已知弱点

上一节通过任意写原语直接覆写返回地址,展示了绕过 CFG 的一种方式。本节扩大视角,讨论其他可被攻击者利用的弱点和规避途径。

首先,攻击者可能试图使只读内存可写,例如设法修改 ntdll!__guard_dispatch_icall_fptr。若该函数指针可写,可将其改为 __guard_dispatch_icall_nop,使对应映像的 CFG 例程等同于 nop,从而关闭该映像的 CFG 检查。

其次,攻击者可以借助包装函数绕过抑制机制。微软将 VirtualProtect()VirtualAlloc() 等可能把内存改为 RWX 的危险 API 列入黑名单,并默认抑制其作为间接调用目标;但某个调用这些 API 的包装函数可能未被列入黑名单,攻击者便可能通过包装函数间接调用危险功能。

编译器缺陷也会意外引入绕过机会。例如,若导入地址表(其条目都是有效间接调用目标)被错误地设为可写,攻击者可修改函数指针并重定向至恶意代码;又如,编译器可能生成未执行 CFG 例程的间接调用 thunk,使攻击者规避目标验证。

纯数据攻击也能避开 CFG。例如命令注入不会先破坏栈,而是针对将未净化用户输入传给系统命令的程序,通过插入任意命令操纵系统。最后,二进制降级攻击会装载同一模块的旧版本、未启用 CFG 的副本;只要攻击者能促成这种旧模块加载,就会形成安全缺口。

9. 未来工作

CFG 能缓解部分内存破坏攻击,但必须正视其潜在弱点。攻击者在特定情形下可用 ROP,把现有汇编指令序列拼接成恶意执行链来规避 CFG。CFG 也不能阻止内存破坏漏洞本身;它只是在攻击者尝试通过控制指令指针劫持控制流时提供检查。

研究人员正在探索新技术及对现有技术的改进。XFG 被视为 CFG 的下一代,目标是更精确、更全面地防护应用控制流劫持;Intel CET 则从硬件层面验证控制转移并防止劫持。另一条路线是改用 Rust 等内存安全语言,从源头避免内存破坏漏洞;但这类语言本身仍需持续研究,识别可能的缺陷与攻击向量。持续评估新技术的有效性,有助于进一步确保控制流、内存和用户输入的完整性与安全性。

10. 结论

本文分析了微软为防止基于 UAF、并以 ROP 绕过 DEP 栈保护的利用而引入的 CFG 内部机制和实现。CFG 能防止特定利用通过受破坏 vftable 劫持控制流并获得任意代码执行;但它的覆盖范围主要是使用虚函数与 vftable 的 UAF 利用。

CFG 无法防止或捕获直接返回地址覆写及一般的栈破坏。论文以 ChakraCore 的同一漏洞说明:攻击者可以利用任意读写原语泄露栈,直接覆写返回地址,从而控制指令指针。总体而言,CFG 对常见于浏览器、且通常基于 UAF 的某些利用有良好缓解效果,也为执行 CFI 的后续技术奠定基础;但由于仍有明显局限,必须叠加多种缓解措施和保护,并向 Rust 等内存安全语言迁移。

参考资料(原文主要引用)

  • Google Project Zero:0day “In the Wild”https://googleprojectzero.blogspot.com/p/0day.html
  • B. Catlin 等:Windows Internals: User Mode,Microsoft Press,2017。
  • Zeyu Chen 等:All Use-After-Free Vulnerabilities Are Not Created Equal,RAID 2023。
  • Hyungseok Kim 等:关于 Intel CET 的研究,2022。
  • Yunhai:Bypass Control Flow Guard Comprehensively,Black Hat。
  • Jack Tang 等:Exploring Control Flow Guard in Windows 10
  • McGarr:关于 CVE-2019-0567 与 ChakraCore 利用的系列研究。

完整书目信息、图 1–6 及清单 1–14 请参见原始 PDF。