控制流防护 Control Flow Guard
😄
1 CFG 支持
微软于 2014 年在 Windows 8.1 Update 3(KB3000850)中首次发布了 CFG(Control Flow Guard),并在之后的 Windows 10 中自动启用。(参考MSDN Control Flow Guard for platform security)
CFG 主要用来解决代码中针对间接跳转的缓解,如 jmp/call/ret 等间接跳转。代码运行过程中,在间接跳转之前,系统会通过 CFG Bitmap 来动态判断跳转目的地址是否是可信赖的区域。试图在汇编级别保护间接调用。这也叫做控制流完整性(Control Flow Integrity,CFI)。分为:
- User CFG,在用户代码中的 CFG。
- Kernel CFG,内核代码中的 KCFG。
CFG 的启用需要系统和编译器同时支持(就像ASLR一样),从 Virtual Studio 2015 Updated 2 版本以上开始支持(项目属性—C/C++—代码生成—控制流防护—是 guard:cf)。
程序中有了 CFG 之后,间接跳转之前加了一个跳转判断:

1.1 PE中的CFG标志
当在 VS 2019 项目属性—C/C++—代码生成—控制流防护—是 guard:cf 启用 CFG 后:
PE
Optionnal Header.DLL characteristics有相关签名指出程序中使用了 CFG。CFG 数据编译在
.gfids节中,但是该节一般会被合并到.rdata节中。在
.rdata节中的 CFG 数据结构用数据目录表 _IMAGE_LOAD_CONFIG_DIRECTORY64 中相关字段来描述。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51typedef struct _IMAGE_LOAD_CONFIG_DIRECTORY64 {
DWORD Size;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD GlobalFlagsClear;
DWORD GlobalFlagsSet;
DWORD CriticalSectionDefaultTimeout;
ULONGLONG DeCommitFreeBlockThreshold;
ULONGLONG DeCommitTotalFreeThreshold;
ULONGLONG LockPrefixTable;
ULONGLONG MaximumAllocationSize;
ULONGLONG VirtualMemoryThreshold;
ULONGLONG ProcessAffinityMask;
DWORD ProcessHeapFlags;
WORD CSDVersion;
WORD DependentLoadFlags;
ULONGLONG EditList;
ULONGLONG SecurityCookie;
ULONGLONG SEHandlerTable;
ULONGLONG SEHandlerCount;
ULONGLONG GuardCFCheckFunctionPointer;
ULONGLONG GuardCFDispatchFunctionPointer;
ULONGLONG GuardCFFunctionTable;
ULONGLONG GuardCFFunctionCount;
DWORD GuardFlags;
IMAGE_LOAD_CONFIG_CODE_INTEGRITY CodeIntegrity;
ULONGLONG GuardAddressTakenIatEntryTable;
ULONGLONG GuardAddressTakenIatEntryCount;
ULONGLONG GuardLongJumpTargetTable;
ULONGLONG GuardLongJumpTargetCount;
ULONGLONG DynamicValueRelocTable;
ULONGLONG CHPEMetadataPointer;
ULONGLONG GuardRFFailureRoutine;
ULONGLONG GuardRFFailureRoutineFunctionPointer;
DWORD DynamicValueRelocTableOffset;
WORD DynamicValueRelocTableSection;
WORD Reserved2;
ULONGLONG GuardRFVerifyStackPointerFunctionPointer;
DWORD HotPatchTableOffset;
DWORD Reserved3;
ULONGLONG EnclaveConfigurationPointer;
ULONGLONG VolatileMetadataPointer;
ULONGLONG GuardEHContinuationTable;
ULONGLONG GuardEHContinuationCount;
ULONGLONG GuardXFGCheckFunctionPointer;
ULONGLONG GuardXFGDispatchFunctionPointer;
ULONGLONG GuardXFGTableDispatchFunctionPointer;
ULONGLONG CastGuardOsDeterminedFailureMode;
ULONGLONG GuardMemcpyFunctionPointer;
} IMAGE_LOAD_CONFIG_DIRECTORY64, *PIMAGE_LOAD_CONFIG_DIRECTORY64;
使用 VS 自带工具 dumpbin 查看 PE 文件头信息(工具—命令行—开发者 Power Shell):
1 | dumpbin /headers /loadconfig C:\Win32\LoadDriverIO\x64\Release\LoadDriverIO.exe |
说明如下:
- Guard CF function table:RVA,指向已识别到的程序中的所有函数地址(不仅仅是间接调用函数的地址)。且 dumpbin.exe 会将识别到的函数在最下面进行输出。
- Guard CF function count:表示 Guard CF function table 表中的函数个数。
- Guard Flags:CFG 相关标志。当包含 CF instrumented、FID table present 则表示已启用 CFG。

其中 GuardFlags 成员指定使用了哪些 CFG 缓解措施:
1 |
|
| PE 字段 | 对应函数 | 加载函数 |
|---|---|---|
| Guard CF address of check-function pointer | __guard_check_icall_fptr | ntdll!LdrpValidateUserCallTarget |
| Guard CF address of dispatch-function pointer | __guard_dispatch_icall_fptr | ntdll!LdrpDispatchUserCallTarget |
| Guard CF function table | 指向一个 RVA 数组的指针,这个数组包含程序中所有间接跳转/调用的函数地址的 RVA。 | |
| Guard CF function count | RVA的个数。 |
1.2 VS开启CFG代码分析
如下代码:
1 |
|
不开启 CFG 功能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14.text:0000000140001510 // int __fastcall main()
.text:0000000140001510 main proc near
.text:0000000140001510
.text:0000000140001510 push rdi
.text:0000000140001512 sub rsp, 20h
.text:0000000140001522 mov eax, 8
.text:0000000140001527 imul rax, 0
.text:000000014000152B lea rcx, cfgTest1 // void (*near * cfgTest1)(void)
.text:0000000140001532 call qword ptr [rcx+rax]
.text:0000000140001541 xor eax, eax
.text:0000000140001543 add rsp, 20h
.text:0000000140001547 pop rdi
.text:0000000140001548 retn
.text:0000000140001548 main endp开启 CFG 功能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18.text:0000000140001FC0 // int __fastcall main()
.text:0000000140001FC0 main proc near
.text:0000000140001FC0 var_18 = qword ptr -18h
.text:0000000140001FC0
.text:0000000140001FC0 push rdi
.text:0000000140001FC2 sub rsp, 30h
.text:0000000140001FD2 mov eax, 8
.text:0000000140001FD7 imul rax, 0
.text:0000000140001FDB lea rcx, cfgTest1 // void (*near * cfgTest1)(void)
.text:0000000140001FE2 mov rax, [rcx+rax]
.text:0000000140001FE6 mov [rsp+38h+var_18], rax
.text:0000000140001FEB mov rax, [rsp+38h+var_18]
.text:0000000140001FF0 call cs:__guard_dispatch_icall_fptr
.text:0000000140002002 xor eax, eax
.text:0000000140002004 add rsp, 30h
.text:0000000140002008 pop rdi
.text:0000000140002009 retn
.text:0000000140002009 main endp
可以看到,加了 CFG 后,不再是直接调用间接地址 call qword ptr [rcx+rax],而是将间接地址交给 rax,然后调用 __guard_dispatch_icall_fptr 函数。
2 CFG的增强
2.1 CFG占位符函数
在程序中,在间接调用前,会将间接调用地址传给 __guard_check_icall_fptr/__guard_dispatch_icall_fptr 函数
上述 VS 2017 以前的编译器生成的 CFG 函数为
__guard_check_icall_fptr指向_guard_check_icall_nop占位符函数,该函数只是简单返回。VS 2017 及之后的编译器生成的 CFG 函数为
__guard_dispatch_icall_fptr指向_guard_dispatch_icall_nop(占位符函数),占位符函数中仅仅跳转至目标函数。这是因为允许程序在不支持 CFG 的旧 Windows 版本上执行。Windows 10 之后才支持 dispatch 函数。
__guard_check_icall_fptr:
1 | .mrdata:00000001801811E0 __guard_check_icall_fptr dq offset _guard_check_icall_nop |
__guard_dispatch_icall_fptr:
1 | .00cfg:0000000180185000 __guard_dispatch_icall_fptr dq offset _guard_dispatch_icall_nop |
2.2 CFG的支持
Bitmap 中使用连续的 2 位来表示连续 16 字节目标地址是否是有效地址。如下图:

- 在支持 CFG 的进程加载期间,Windows 10 之前,不支持 dispatch 函数,系统会将
GuardCFCheckFunctionPointer加载配置为ntdll!LdrpValidateUserCallTarget函数,而此时GuardCFDispatchFunctionPointer的值将保留为 0。 - Windows 10 和 Windows Server 2016 开始,
GuardCFDispatchFunctionPointer加载配置为ntdll!LdrpDispatchUserCallTarget。
需要说明: VS 2017 及之后的编译器生成的 CFG 函数在 Windows 10 之前的系统上运行时将不会起作用,因为 Windows 10 之前的系统不支持 dispatch 函数。
抑制调用(SuppressedCall)和抑制导出(Export suppression)是在 Windows 10 Anniversary Update and Windows Server 2016 引入的。因此 SuppressedCall、Export suppression 标记的函数在此之前的系统中使用时,进程的 Bitmap 会将这两类函数标记为有效的目标函数。
但是从 Windows 10 Anniversary Update and Windows Server 2016 之后,抑制调用(SuppressedCall)将会在进程的 Bitmap 中标记为无效的目标函数。
| 通用的CFG | 增强的CFG | |
|---|---|---|
| 编译器 | Visual Studio 2015 | Visual Studio 2017及之后 |
| 操作系统 | Windows 8.1~Windows 10 Anniversary Update (1607,2016/8/2),Windows Server 2016 1703 |
Windows 10 1703, Windows Server 2016 1709 及之后 |
| Bitmap(2 bits) | ◦ Bit n:表示16个连续地址的首地址。(n mod 2 = 0,偶数位) ◦ Bit n+1:表示除首地址之外的15个连续地址。 CFG 验证时,仅使用Bitmap中的一位。 16字节对齐的目标地址,如果Offset(偶数)对应的Bitmap[Index]为0时,说明该目标函数无效,为1则有效。 非16字节首地址对齐的函数,如果Offset(奇数)对应的Bitmap[Index]为0时,说明该目标函数无效,为1则有效。 |
CFG 验证时,仅使用Bitmap中的两位。 {bit n, bit n+1} {0, 0}:16个连续的地址不包含有效的目标函数。 {1, 0}:16个连续地址中,首地址是有效的目标函数。 {1, 1}:16个连续地址中,除首地址之外的15个地址中包含目标函数。 {0, 1}:16个连续地址属于抑制导出(Export suppressed)函数。 |
| CFG(占位符函数) | __guard_check_icall_fptr _guard_check_icall_nop |
__guard_dispatch_icall_fptr _guard_dispatch_icall_nop |
| 进程加载的CFG函数 | ntdll!LdrpValidateUserCallTarget | ntdll!LdrpDispatchUserCallTarget |
| 是否支持SuppressedCall、Export-suppressed | 否 | 是 |

在 PE32 上,必须先启用 DEP 才能启用 CFG。
2.3 抑制调用(SuppressedCall)
在程序中,可以使用关键字 DECLSPEC_GUARD_SUPPRESS 来注明一个函数不能被当做间接调用函数地址。如果在程序中间接调用该函数,程序将直接崩溃终止。
1 |
|
函数反汇编如下:
1 | .text:00007FF63C881F70 // int __cdecl main(int argc, const char **argv, const char **envp) |
分析:
- 当程序中调用
indirect_supressed_function();函数时,会将该函数的地址给rax,然后调用__guard_dispatch_icall_fptr。当前程序没有启用抑制导出,程序加载期间会将__guard_dispatch_icall_fptr加载为LdrpDispatchUserCallTarget函数。 - 在函数
LdrpDispatchUserCallTarget中,动态跟踪 Bitmap 状态位为{0, 0},则会调用LdrpHandleInvalidUserCallTarget让程序终止执行。
如下图,使用关键字 DECLSPEC_GUARD_SUPPRESS 注明的函数,在编译后,Dumpbin.exe 能够识别到会有一个 S 标志。

这个 S 叫做 Header flag。下面就在 PE 文件中查看 Header flag:
定位到配置目录表。得到
_IMAGE_LOAD_CONFIG_DIRECTORY64的文件偏移地址。根据_IMAGE_LOAD_CONFIG_DIRECTORY64结构,GuardCFFunctionTable偏移为0x80,则成员的文件偏移为0xA340 + 0x80 = 0xA3C0。![8.png]()
找到
GuardCFFunctionTable,是个VA值 不是RVA。如下图,可以得到该表的虚拟地址0x00000001'40010000。![9.png]()
可以看到和 Dumpbin.exe 工具识别到的一样。
![6.png]()
定位到表
GuardCFFunctionTable数据。首先看到Imagebase = 0x00000001'40000000。则该表的RVA = 0x10000,图下图刚好位于.gfids节开头,文件偏移为0xD400。![10.png]()
解析
GuardCFFunctionTable表的Header flag。对照 Dumpbin.exe 的解析,可以知道GuardCFFunctionTable表每个元素为5字节,最高字节就是Header flag。- Header flag == 1,用 S 表示。标志该函数是
SuppressedCall抑制调用函数。 - Header flag == 2,用 E 表示。标志该函数是
Export-Suppressed抑制导出函数。
![11.png]()
参考 Relevance of Security Features Introduced in Modern Windows OS—P38、How Control Flow Integrity is implemented in Windows 10—GuardCFFunctionTable。
![12.png]()
- Header flag == 1,用 S 表示。标志该函数是
使用 Github systeminformer 提供的 PEView.exe 工具可以查看这些函数。
Header flag 在不同编译器中所占的位长可能不同,计算公式如下:
Header flag = (GuardFlags & IMAGE_GUARD_CF_FUNCTION_TABLE_SIZE_MASK) >> IMAGE_GUARD_CF_FUNCTION_TABLE_SIZE_SHIFT。
在 Windows 10 21H2、VS 2019 下:Header flag = (0x00017500 & 0xF0000000) >> 28 = 1。
2.4 抑制导出(Export-Suppressed)
当系统开启抑制导出功能时,如果 A 程序中有导出函数,则这些导出函数都叫做抑制导出函数(Export suppressed functions)。B 进程加载了 A 程序,则 A 中的抑制导出函数在 B 进程中就开始有一些安全限制了。
如果系统用没有启用抑制导出功能,则 A 中所有的导出函数在 B 进程中都可以当做有效的间接调用目标,此时 Bitmap 中的两位状态为 {0, 1}。
抑制导出由操作系统启用和禁用(编译器没有开启/关闭开关)。
在 PowerShell 中使用如下命令启用:
1 | Set-ProcessMitigation -System -Enable CFG, SuppressExports |
如果系统启用了抑制导出功能,则进程在初始化期间,会将 __guard_check_icall_fptr 加载为 ntdll!LdrpValidateUserCallTargetES 函数。对于支持增强 CFG 的系统,则会将 __guard_dispatch_icall_fptr 加载为 ntdll!LdrpDispatchUserCallTargetES。
- 如果系统启用了抑制导出功能,在上述进程 B 中的抑制导出函数将会被视作无效的间接调用目标,此时 Bitmap 中的两位状态为
{ 0, 1}(0是低位),当 ShellCode 试图在此位图状态下调用抑制导出函数时,将会触发LdrpHandleInvalidUserCallTarget函数。但是如果使用合法的函数如GetProcAddress来获取抑制导出函数的地址时,系统会将 Bitmap 中的的两位状态修改为{1, 0},将抑制导出函数视为有效的间接目标函数,然后在进程中就可以使用该抑制导出函数了。 - 如果系统没有启用抑制导出功能,抑制导出函数在进程 Bitmap 中的两位状态仍然是
{0, 1},但是在ntdll!LdrpDispatchUserCallTarget中会将该状态的函数当做有效的目标地址。
不管是否启用抑制导出功能,抑制导出函数的地址必须是 16 字节对齐的,也就是 Bitmap 的 {0, 1} 状态的函数必须是 16 字节对齐的。可以在 ntdll!LdrpDispatchUserCallTarget 函数中进行验证。
示例:
在 3.3 的代码中增加一个导出函数:
1 | extern "C" __declspec( dllexport ) int MyFunc(void); |
然后使用 Dumpbin.exe 工具查看其 GuardCFFunctionTable:

GuardCFFunctionCount = 0x10,函数表中共 16 个函数。- SuppressedCall 函数的
Header flag = S,Export-Suppressed 函数的Header flag = E。
本节参考:
- Relevance of Security Features Introduced in Modern Windows OS
- How Control Flow Integrity is implemented in Windows 10
- Control Flow Guard PE metadata
3 CFG Bitmap
3.1 Bitmap原理和使用
Windows 支持两种 CFG,Windows 10 之后引入了更精细的流程(增强的CFG)。
| CFG类型 | 使用函数 | 启用抑制导出(Export-Suppression) | 间接地址验证流程 |
|---|---|---|---|
| 常规的 CFG | __guard_check_icall_fptr | 是:将函数 __guard_check_icall_fptr 加载为 ntdll!LdrpValidateUserCallTargetES。 否:将函数 __guard_check_icall_fptr 加载为 ntdll!LdrpValidateUserCallTarget。 |
验证目标,返回。 |
| 增强的 CFG | __guard_dispatch_icall_fptr | 是:将函数 __guard_dispatch_icall_fptr 加载为 ntdll!LdrpDispatchUserCallTargetES。 否:将函数 __guard_dispatch_icall_fptr 加载为 ntdll!LdrpDispatchUserCallTarget。 |
验证目标,调用目标,检查堆栈,返回。 |
启用抑制导出时,在 GuardFlags 下会包含 IMAGE_GUARD_CF_ENABLE_EXPORT_SUPPRESSION(0x00008000)
不管是常规还是增强的 CFG,不管是否启用抑制导出,在间接地址验证时都会使用到 Bitmap 中连续的 2 个位。
- 如果位状态为 {0, 0},则分派可能无效。
- 如果位状态为 {1, 0},且地址为 16 字节对齐,则分派有效。 否则,它可能无效。
- 如果位状态为 {1, 1},并且地址不是 16 字节对齐的,则分派有效。 否则,它可能无效。
- 如果位状态为 {0, 1},目标地址是导出抑制函数(Export-Suppressed)。
{1, 0} :1 是低位,0 是高位。
使用 2 个位的原因是:无法保证编译器生成所有函数都在 16 字节上对齐。手工编写的汇编代码和某些优化可能会违反此规则。
所以需要在原基础上增加一个位,来表示那些没有 16 字节对齐的函数,因为这些函数也是有效的间接调用函数。
所以在 CFG 位图中:
- 偶数位对应一个 16 字节对齐的函数起始地址。
- 奇数位对应一个非 16 字节对齐的函数起始地址( 紧接着前一个偶数位的15 个 Bytes)。
奇数位和偶数位表示同一个 16 Bytes,偶数位表示起始地址,奇数位则为起始地址后的 15 Bytes。
怎么来解释奇数位表示非 16 字节对齐的函数起始地址呢?
如函数地址是 pAddr = 0x00007ff'00402013,因为 pAddr & 0xF != 0,所以该函数起始地址不是 16 字节对齐的。
在解释这之前就必须要知道 x64 下 Windows 是如何验证函数地址是否有效的流程:
Bitmap_Index = pAddr >> 9,取的函数地址的高 55 位作为定位位图的索引;Bitmap[Bitmap_Index] = Bitmap_Base + Bitmap_Index*8,定位到位图指定的条目;Offset = (pAddr >> 3) & 0x3F,取得函数地址的bit 3 ~ bit 8共 6 位,作为定位条目中的位,此时Offset为偶数;- 判断函数地址是否已经 16 字节对齐,如果已经对齐的则进行如下流程;
- 测试
Bitmap[Bitmap_Index]中的bit Offset位,如果为 1 则直接jmp pAddr,跳转到目标函数。
如果函数地址不是 16 字节对齐的:
Bitmap_Index = pAddr >> 9,取的函数地址的高 55 位作为定位位图的索引(同上);Bitmap[Bitmap_Index] = Bitmap_Base + Bitmap_Index*8,定位到位图指定的条目(同上);Offset = (pAddr >> 3) & 0x3F,取得函数地址的bit 3 ~ bit 8共 6 位,作为定位条目中的位(同上),此时Offset为奇数;- 判断函数地址是否已经 16 字节对齐,如果没有对齐则进行如下流程;
Offset = Offset & 3E,将最低位清零。判断上一个偶数位对应地址是否是一个有效的间接跳转地址(该偶数位为 1)。 如果是则进行下一步;Offset = Offset & 3F,测试Bitmap[Bitmap_Index]中的bit Offset位,如果为 1 则直接jmp pAddr,跳转到目标函数。
实际上非 16 字节对齐的函数,使用的就是上面提到的 {1,1} 这个方式。

1 | .text:000000018008C620 // =============== S U B R O U T I N E ======================================= |
通过以上分析,有效的函数地址:
- 16字节对齐的目标地址。
- {1, x},有效地址,不会验证 x。
- {0, 1},有效地址,启用了抑制导出。
- 非16字节对齐地址。
- {1, 1},有效地址。
验证过程如下图:

解释明明 shr r10, 3 后,取得高 61 位,为什么说取得的是 Offset(6 bits) 呢?
1 | .text:000000018008C632 mov r10, rax |
是因为:bt r11, r10,是先 r10 mod 64 后再测试 r11 中的位。在 x86 下 bt ebx, eax 则会 eax mod 32,根据目的寄存器宽度来选择是模多少。
如 0101 0011b = 0x53 = 83,低 6 位为 0x13 = 19:
83 mod 64 = 1919 mod 64 = 19所以跟直接取低 6 位来
mod 64没有区别,6 位表示的范围为0~63。
同理:
83 mod 32 = 1919 mod 32 = 19跟直接取低 5 位来
mod 32没有区别,5 位表示的范围为0~31。
这里引出来两个问题:
为什么测试内存是否 8 字节对齐时仅使用地址低 3 位,而 16 字节对齐时仅使用地址低 4 位?
答:8 的倍数是 8、0x10,也就是说可以判断地址
VirtualAddress mod 8 = 0即为 8 字节对齐(只要低 3 位为 0,不管高位是多少都是 8 的整数倍)。也就是地址VirtualAddress低 3 位必须为 0 才表示对齐。同理VirtualAddress mod 16 = 0则地址的低 4 位必须为 0 才表示对齐。X mod 8,等价于取 X 的低 3 位。X mod 16,等价于取 X 的低 4 位。X mod 32,等价于取 X 的低 5 位。X mod 64,等价于取 X 的低 6 位。
为什么
Offset在 x86 下为 5 位,在 x64 下为 6 位?答:在 x86 下,
bt ebx, eax是 mod 32,即取低 5 位即可,低 5 位的范围不大于 32。在 x64 下,
bt r11, r10是 mod 64,即取低 6 位即可,低 6 位的范围不大于 64。
CFG 通过验证间接调用的目标是否位于已知函数的开头来解决 CFl 的间接 CALL/JMP 调用。如果目标不在已知函数的开头, 则该过程将简单地终止。
常规的 CFG 流程:
![1.png]()
增强的 CFG 流程:
![2.png]()
3.2 Bitmap大小
正如您之前看到的,强制程序每隔几条指令迭代一次函数调用列表是不切实际的。 因此,不是需要线性时间 O(n) 的算法,而是性能要求指示使用 O(1) 算法来代替——使用恒定查找时间的算法,而不管表中存在多少函数。 这个常量查找时间应该尽可能短。 这种要求的明显赢家将是一个可由目标函数地址索引的数组,它指示该地址是否有效(例如简单的 BOOL)。 但是,对于 128 TB 的可能地址,这样的数组本身必须是 128 TB * sizeof(BOOL),这是一个不可接受的存储大小——比地址空间本身还大。 我们能做得更好吗?
首先,我们可以利用编译器应该在 16 字节边界上生成 x64 函数代码这一事实。 这会将所需数组的大小减少到仅 128/16 TB * sizeof(BOOL)。 但是使用整个 BOOL(最坏情况下为 4 个字节,最好情况下为 1 个字节)是极其浪费的。 我们只需要一个状态,有效或无效,只需要使用 1 位。 这使得计算结果为 8 TB / 8 = 1 TB。 然而,不幸的是, 无法保证编译器生成所有函数都在 16 字节上对齐。手工编写的汇编代码和某些优化可能会违反此规则。 因此,我们必须想出一个解决办法。 一个可能的选择是简单地再使用另一位来指示函数是否开始于下一个 15 字节的某处,而不是 16 字节起始的地址本身。 因此,我们有以下可能性:
■ {0, 0} No valid function begins inside this 16-byte boundary.
■ {1, 0} A valid function begins exactly on this aligned 16-byte address.
■ {1, 1} A valid function begins somewhere inside of this 16-byte address.
由于这种设置,16 字节对齐的函数在 Bitmap 的 2 位状态将为 {1, 0}(bit 0 = 1,bit 1 = 0),而非 16 字节对齐的函数在 Bitmap 的 2 位状态将为 {1, 1}。
考虑到这一点,我们可以应用以下公式来计算 CFG 位图的大小:
- x86 或 x64 上的 32 位应用程序: 2 GB / 16 * 2 = 32 MB。
- 带有 /LARGEADDRESSAWARE 的 32 位应用程序,在 x86 上以 3 GB 模式启动: 3 GB / 16 *2 = 48 MB
- 64 位应用程序: 128 TB / 16 * 2 = 2 TB
- 带有 /LARGEADDRESSAWARE 的 32 位应用程序,在 x64 上 4 GB / 16 *2 = 64 MB,加上保护 64 位 Ntdll.dll 和 WoW64 组件所需的 64 位位图的大小,因此 2 TB + 64MB
分配和填充 2 TB 的位(每个进程执行仍然是一个难以承受的性能开销。 即使我们已经固定了间接调用本身的执行成本,进程启动也不能允许这么长时间,2 TB 的提交内存会立即耗尽提交限制。 因此,使用了两个节省内存和提高性能的技巧。
首先,内存管理器将仅 Reserve 位图(不提交Commit)。基于这样的假设:CFG 验证函数时在对 CFG 位图访问期间,如果位状态为
{0,0}则视为异常。 因此,只要个页面区域包含 4 KB 的位状态都是{0, 0},这个页面在内存中仅为保留状态(Reserve)。内存管理中,只需要提交至少设置了一位{1, X}的页面。接下来,如第 5 章“内存管理”的 ASLR 部分所述,系统通常只在启动时执行一次库的随机化/重定位,作为避免重复重定位的性能节省措施。 因此,支持 ASLR 的库在给定地址加载一次后,将始终在同一地址加载。 因此,这也意味着一旦为该库中的函数计算了相关的位图状态,它们在所有其他也加载相同二进制文件的进程中将是相同的。 因此,内存管理器将 CFG 位图视为页面文件支持的可共享内存区域,并且对应于共享位的物理页面仅在 RAM 中存在一次。
这降低了 RAM 中已提交页面的成本,意味着只需要计算与私有内存对应的位。 在常规应用程序中,私有内存不可执行,除非有人修补了库的写时复制情况(但这不会在图像加载时发生),因此加载应用程序的成本,如果它共享相同的库 之前推出的其他应用,几乎为零。
3.3 初始化Bitmap
- 系统初始化时,会调用
MiInitializeCfg函数来初始化对 CFG 的支持。 该函数创建一个或两个内存区对象(MmCreateSection)作为保留内存,其大小依赖于平台,如前所示。 对于 32 位平台,一张位图就足够了。 对于 x64 平台,需要两个位图,一个用于 64 位进程,另一个用于 Wow64 进程(32 位应用程序)。 内存区对象的指针存储在MiState全局变量的子结构中。 - 创建进程后,相应的内存区将安全地映射到进程的地址空间中。 这里的安全意味着该部分不能被进程中运行的代码取消映射或更改其保护。 (否则,恶意代码可能只是取消映射内存、重新分配并用 1 位填充所有内容,从而有效地禁用 CFG,或者通过将区域标记为读/写来简单地修改任何位。)
4 User CFG、Kernel CFG
一、User CFG
- 用户模式CFG只能对标记为地址空间布局随机化(ASLR)兼容的图像强制执行(由与Microsoft链接器的/DYNAMICBASE选项指定)。这是由于操作系统内部如何处理CFG,它基本上连接到ASLR基础设施。一般来说,作为第一步,CFG用户应该为他们的图像启用ASLR。工具不应假设操作系统在没有ASLR设置的情况下始终忽略CFG,而通常应该同时设置两者。
- 请注意,要执行kCFG,需要虚拟化基础安全(VBS)。然而,即使VBS被禁用,kCFG也有一些有限的功能。这超出了这篇博客文章的范围)。
只读的CFG 位图由操作系统维护,部分位图由所有进程共享。CFG 位图中:
- 偶数位对应一个 16 字节对齐的地址。
- 奇数位对应 15 个非 16 字节对齐的地址。
Bitmap 的核心就是根据目标地址本身,就能判断该地址是否是有效的跳转地址。
因此,内存管理器将 CFG 位图视为页面文件支持的可共享内存区域,并且对应于共享位的物理页面仅在 RAM 中存在一次。
LdrpValidateUserCallTarget:
1 | .text:000000018008C580 // =============== S U B R O U T I N E ======================================= |
LdrpValidateUserCallTargetES:
1 | .text:000000018008C5D0 // =============== S U B R O U T I N E ======================================= |
LdrpDispatchUserCallTarget:
1 | .text:000000018008C620 // =============== S U B R O U T I N E ======================================= |
LdrpDispatchUserCallTargetES:
1 | .text:000000018008C670 // =============== S U B R O U T I N E ======================================= |
LdrpHandleInvalidUserCallTarget:
1 | .text:000000018008C4C0 // =============== S U B R O U T I N E ======================================= |
二、 Kernel CFG
尽管使用 Visual Studio 和 /guard:cf 编译的驱动程序最终也具有与用户模式图像相同的二进制属性,但 Windows 10 的第一个版本并未对这些数据执行任何操作。与受保护的用户模式 CFG 位图不同 如果要创建一个更高、更可信的实体(内核),则没有任何东西可以真正“保护”内核 CFG 位图。 恶意利用可以简单地编辑与包含要修改的所需位的页面对应的 PTE,将其标记为读/写,然后继续进行间接调用或跳转。 因此,设置这种微不足道的可绕过缓解措施的开销根本不值得。
随着越来越多的用户启用 VBS 功能,可以再次利用 VTL 1 提供的更高安全边界。 SLAT 页表条目通过提供第二个边界来防止 PTE 页面保护更改来解决这个问题。 虽然位图对于 VTL 0 是可读的,因为 SLAT 条目被标记为只读,但如果内核攻击者试图更改 PTE 以将其标记为可读/可写,则他们无法对 SLAT 条目执行相同的操作。 因此,这将被检测为 HyperGuard 可以对其采取行动的无效 KCFG 位图访问(仅出于遥测原因——因为这些位无论如何都无法更改)。
KCFG 的实现几乎与常规 CFG 相同,除了未启用导出抑制、longjmp 支持以及为 JIT 目的动态请求额外位的能力之外。 内核驱动程序不应该做任何这些事情。 取而代之的是,这些位是根据“地址获取 IAT 表”条目(如果已设置)在位图中设置的; 每次加载驱动程序映像时,通过保护表中的常用函数条目; 以及由 MiInitializeKernelCfg 引导期间的 HAL 和内核。 如果未启用虚拟机管理程序,并且不存在 SLAT 支持,那么这些都不会被初始化,并且 Kernel CFG 将保持禁用状态。
就像在用户模式下一样,加载配置数据目录中的动态指针被更新,在启用的情况下,它将指向 __guard_check_icall 用于检查功能,而 __guard_dispatch_icall 用于增强 CFG 模式下的调度功能。 此外,名为 guard_icall_bitmap 的变量将保存位图的虚拟地址。
关于内核 CFG 的最后一个细节是,不幸的是,动态驱动程序验证程序设置将不可配置(有关驱动程序验证程序的更多信息,请参阅第 6 章,“/O 系统”),因为这需要添加动态内核挂钩并将执行重定向到 可能不在位图中的功能。在这种情况下,将返回 STATUS_VRF_CFG_ENABLED (0xC000049F),并且需要重新启动(此时可以使用 Verifier 驱动程序挂钩构建位图)。





