深入解析微软控制流防护(CFG)技术
我们如期推出控制流完整性系列的第二篇,本次重点讨论微软的控制流完整性实现。控制流完整性(CFI)是一种漏洞利用缓解技术,能防止漏洞转化为实际攻击。如需更详细解释,请阅读本系列首篇文章。
安全研究人员应研究人们实际使用的产品,而微软在桌面计算市场占据绝对份额。Windows和Visual Studio中的新反攻击措施影响重大,这些措施直接关系到大量用户。
对于急于了解控制流防护(CFG)的读者:只需在编译器和链接器标志中添加/guard:cf
,并查看我们的示例了解CFG能防护和不能防护的情况。
微软的CFI实现
微软的CFI实现称为控制流防护(CFG),需要操作系统和编译器的双重支持。最低支持的操作系统为Windows 8.1 Update 3,最低编译器版本为Visual Studio 2015(推荐VS2015 Update 3)。本文所有示例均基于Visual Studio 2015和x86-64架构的Windows 10。
CFG有完善的文档支持:包括官方文档页面、编译器选项文档,甚至功能开发期间的博客文章。CFG是CFI的直接实现:
- 编译器首先识别程序中的所有间接分支
- 确定哪些分支需要保护(例如静态可识别目标的分支无需CFI检查)
- 在潜在脆弱分支处插入轻量级检查,确保分支目标是有效目的地
与上一篇博客类似,本文不深入探讨CFG的技术实现(已有大量优秀文献),而是重点介绍如何在程序中使用CFG,并展示CFG能防护和不能防护的情况。不过我们会提及CFG与Clang的CFI实现的重要差异。
CFG与Clang CFI的对比
本对比旨在展示两种实现如何将控制流完整性的理论概念转化为实际应用保护机制。两种实现没有优劣之分,它们针对不同的软件生态系统,各自在现实约束(如源代码可用性、性能、易用性、API/ABI稳定性、向后兼容性等)下实现有意义的软件保护。
受保护内容
使用微软CFG或Clang CFI保护的程序在间接控制流转移前执行轻量级检查,验证流目标是否属于预定义的有效目标集。
Windows程序存在大量无法被劫持的间接调用。例如通过IAT进行的API调用在程序加载后设置为只读,Visual Studio编译器会安全地省略这些调用的CFG检查。
Clang的CFI还包含不完全与CFI相关的检查,如指针转换的运行时验证。
有效目标定义
控制流防护使用单个进程级映射来管理所有有效控制流目标,映射中的任何内容都被视为有效目标(图1b)。CFG通过SetProcessValidCallTargets
API提供运行时调整有效目标映射的能力,这对处理JIT代码或手动加载动态库特别有用。
CFG还提供三个控制指定方法中CFG行为的编译器指令(定义在Windows SDK的ntdef.h中):
__declspec(guard(ignore))
:禁用方法内所有间接调用的CFG检查,并忽略方法中引用的任何函数指针__declspec(guard(nocf))
:禁用方法内所有间接调用的CFG检查,但跟踪引用的函数指针并将其添加到有效目标映射__declspec(guard(suppress))
:防止导出函数成为有效CFG目标,用于阻止安全敏感函数被间接调用
Clang的CFI保护粒度更细,每个间接控制流转移的目标必须匹配预期的类型签名(图1a)。根据启用选项,对类成员函数的调用还会验证是否在正确的类层次结构中。实际上,每个类型签名和类层次结构都有固定的有效目标映射。
图1:cfg_icall示例中有效调用目标的差异
- (a) Clang CFI的有效目标:只有匹配预期函数签名的函数在列表中
- (b) VS2015 CFG的有效目标:每个合法函数入口点都在列表中
保护执行机制
控制流防护将执行职责分配给编译器和操作系统:编译器插入检查并提供初始有效目标集,操作系统维护目标集并验证目标。
Clang的CFI完全在编译器层面执行,操作系统不感知CFI。
动态库、JIT代码等边缘情况
控制流防护支持跨库调用,但只有在库也使用CFG编译时才执行保护。动态生成的代码页可以添加或排除在有效目标映射外。通过GetProcAddress
获取的外部函数总是有效调用目标*。
Clang的CFI通过-fsanitize-cfi-cross-dso
标志支持跨库调用,库和应用程序都必须使用此标志编译。动态生成的代码不受CFI保护。使用-fsanitize-cfi-cross-dso
时,通过dlsym
获取的外部函数会自动添加为有效目标,否则会触发CFI违规。
*例外:使用__declspec(guard(suppress))
保护的函数必须通过导入表链接,否则不可调用。
在Visual Studio 2015中使用CFI
在Visual Studio中使用控制流防护极其简单。MSDN网站有详细文档说明如何通过GUI和命令行启用CFG:只需在编译器和链接器标志中添加/guard:cf
。
注意事项(仅适用于通过SetProcessValidCallTargets
动态调整有效间接调用目标的情况):
- 需要较新版本的Windows SDK(默认安装的VS2015版本缺少正确定义,需安装最新版本10.0.14393.0)
- 必须将SDK目标设置为Windows 10(
#define _WIN32_WINNT 0x0A00
) - 必须链接mincore.lib(包含必要的导入定义)
控制流防护示例
我们创建了包含特殊设计漏洞的示例,展示如何使用CFG以及CFG能防护的一些错误。这些示例中的漏洞无法被编译器静态识别,但会在运行时被CFG检测到。我们尽可能模拟了CFG能阻止和不能阻止的潜在恶意行为。
这些CFG示例修改自Clang CFI示例,以展示两种实现中有效调用目标的不同含义。每个示例构建两个二进制文件(一个带CFG,如cfg_icall.exe;一个不带CFG,如no_cfg_icall.exe),用于说明CFG的特性和保护能力。
cfg_icall
此示例类比Clang CFI博客中的cfi_icall示例,但稍作修改以适配VS2015和CFG。示例二进制文件接受单个命令行参数(有效值0-3),每个值展示间接调用保护的不同方面:
- 选项0:正常有效的间接调用,在任何CFI方案下都应正常工作
- 选项1:无效间接调用(目标从数组边界外读取),但目标函数与有效调用具有相同函数签名。在Clang CFI和CFG下都能工作,但在某些未来方案中可能失败
- 选项2:无效间接调用,目标是有效函数入口但签名与调用者预期不同。在Clang CFI下失败,但在CFG下工作
- 选项3:指向无效函数入口点的无效间接调用。在任何CFI方案下都应失败,在Clang CFI和CFG下都失败
其他选项应指向未初始化内存,在两种CFI实现下都会正确失败。
cfg_vcall
cfg_vcall示例(源自前文的cfi_icall示例)显示当目标不是有效入口点时,虚拟调用受到CFG保护。示例展示两个模拟漏洞:
- 无效转换模拟类型混淆漏洞:在Clang CFI下失败,但在CFG下成功
- 模拟释放后使用或类似内存破坏:对象指针被攻击者创建的对象替换,函数指针指向函数中间。错误调用被Clang CFI和CFG共同阻止
图2:WinDbg中看到的控制流防护违规
cfg_valid_targets
此示例修改自cfg_icall,展示如何使用SetProcessValidCallTargets
手动更新CFG位图,将bad_int_arg和float_arg从有效调用目标列表中移除。只有选项0能工作,其他选项都会返回CFG错误。
cfg_guard_ignore
控制流防护可以在特定方法中禁用,此示例展示如何使用__declspec(guard(ignore))
编译器指令完全禁用指定方法内的CFG。
cfg_guard_nocf
控制流防护可以在特定方法中部分禁用,此示例展示如何使用__declspec(guard(nocf))
编译器指令禁用指定方法中间接调用的CFG,但仍为任何引用的函数指针启用CFG。示例比较了__declspec(guard(nocf))
和__declspec(guard(ignore))
的效果。
cfg_guard_suppress和cfg_suppressed_export
有时库中包含不应被间接调用的安全敏感方法。__declspec(guard(suppress))
指令可防止导出函数通过函数指针调用。这两个示例共同展示抑制导出的工作原理:cfg_suppressed_export是一个包含抑制导出和正常导出的DLL;cfg_guard_suppress尝试通过GetProcAddress获取的指针调用两个导出。
所有流程必须结束
现在您已了解控制流防护是什么以及如何保护您的应用程序,请立即为您的软件启用它!启用CFG非常简单,只需在编译器和链接器标志中添加/guard:cf
。要查看CFG如何保护软件的实际示例,请查看我们的CFG示例展示。我们希望微软在未来的Visual Studio版本中继续改进CFG。