CFG(ControlFlowGuard)
引言
CFG(控制流防护)是微软对/Gs,DEP和ALSR等漏洞缓解技术的又一项安全性功能扩展,运行在Windows8.1 Update和Win10之上(当然一个开启了CFG应用是可以运行在不支持CFG的平台上的),CFG缓解了因溢出导致的任意代码执行漏洞利用,以及防止内存损坏和勒索软件攻击等。开发者启用CFG功能需要Visual Studio 2015以上,具体如何开启CFG选项可以见微软关于CFG的介绍[1]
同时在今年(2023)的黑客大会上来自著名的EDR厂商Elastic的安全研究员John Uhlmann发表了名为“You Can Run, but You Can't Hide - Finding the Footprints of Hidden Shellcode”[2]的演讲。
他的主要内容是结合CFG会记录当前或曾经是可执行的所有私有内存地址以及VAD会记录内存块的原始内存属性以及当前的内存属性来追踪可疑的内存块(shellcode)。
通过这篇文章我们希望了解CFG以及针对其POC(CFG-FindHiddenShellcode)[3]分析其是如何通过CFG来查找可疑内存块的。
细节
从微软的简单描述中,我们并不能理解CFG是如何对我们的代码执行进行保护的,所以我们写一个简单的测试程序,分别在开启了CFG和未开启CFG状态下进行调试。代码如下:
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 | typedef void (WINAPI * MYSHELLCODE)();
int main()
{
/ / 用于弹出计算器的ShellCode,由msfvenom生成
UCHAR ShellCode[] =
"\xeb\x27\x5b\x53\x5f\xb0\xb0\xfc\xae\x75\xfd\x57\x59\x53\x5e"
"\x8a\x06\x30\x07\x48\xff\xc7\x48\xff\xc6\x66\x81\x3f\xd8\xcd"
"\x74\x07\x80\x3e\xb0\x75\xea\xeb\xe6\xff\xe1\xe8\xd4\xff\xff"
"\xff\x07\xb0\xfb\x4f\x84\xe3\xf7\xef\xc7\x07\x07\x07\x46\x56"
"\x46\x57\x55\x56\x51\x4f\x36\xd5\x62\x4f\x8c\x55\x67\x4f\x8c"
"\x55\x1f\x4f\x8c\x55\x27\x4f\x8c\x75\x57\x4f\x08\xb0\x4d\x4d"
"\x4a\x36\xce\x4f\x36\xc7\xab\x3b\x66\x7b\x05\x2b\x27\x46\xc6"
"\xce\x0a\x46\x06\xc6\xe5\xea\x55\x46\x56\x4f\x8c\x55\x27\x8c"
"\x45\x3b\x4f\x06\xd7\x8c\x87\x8f\x07\x07\x07\x4f\x82\xc7\x73"
"\x60\x4f\x06\xd7\x57\x8c\x4f\x1f\x43\x8c\x47\x27\x4e\x06\xd7"
"\xe4\x51\x4f\xf8\xce\x46\x8c\x33\x8f\x4f\x06\xd1\x4a\x36\xce"
"\x4f\x36\xc7\xab\x46\xc6\xce\x0a\x46\x06\xc6\x3f\xe7\x72\xf6"
"\x4b\x04\x4b\x23\x0f\x42\x3e\xd6\x72\xdf\x5f\x43\x8c\x47\x23"
"\x4e\x06\xd7\x61\x46\x8c\x0b\x4f\x43\x8c\x47\x1b\x4e\x06\xd7"
"\x46\x8c\x03\x8f\x4f\x06\xd7\x46\x5f\x46\x5f\x59\x5e\x5d\x46"
"\x5f\x46\x5e\x46\x5d\x4f\x84\xeb\x27\x46\x55\xf8\xe7\x5f\x46"
"\x5e\x5d\x4f\x8c\x15\xee\x50\xf8\xf8\xf8\x5a\x4f\xbd\x06\x07"
"\x07\x07\x07\x07\x07\x07\x4f\x8a\x8a\x06\x06\x07\x07\x46\xbd"
"\x36\x8c\x68\x80\xf8\xd2\xbc\xe7\x1a\x2d\x0d\x46\xbd\xa1\x92"
"\xba\x9a\xf8\xd2\x4f\x84\xc3\x2f\x3b\x01\x7b\x0d\x87\xfc\xe7"
"\x72\x02\xbc\x40\x14\x75\x68\x6d\x07\x5e\x46\x8e\xdd\xf8\xd2"
"\x64\x66\x6b\x64\x29\x62\x7f\x62\x07\xd8\xcd" ;
PVOID Target = VirtualAlloc(NULL, sizeof(ShellCode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!Target) {
printf( "VirtualAlloc Fail %d\n" , GetLastError());
return - 1 ;
}
CopyMemory(Target, ShellCode, sizeof(ShellCode));
MYSHELLCODE Ptr = (MYSHELLCODE)Target;
Ptr();
return 0 ;
}
|
上面的代码先申请了一段内存并将shellcode放入其中,之后执行,最终结果是会弹出一个计算器。我们依据上述代码分别在开启CFG和不开启CFG的情况下生成两个可执行文件并通过X64dbg进行调试对比。
下图是在未开启CFG情况下准备调用Ptr函数
下图是未开启CFG的调用:可以看到是直接跳转到了ShellCode位置执行
下图是在开启CFG情况下准备调用Ptr函数:调用了guard_dispatch_icall_ftpr保存的地址
下图是开启CFG的调用:可以看到是先执行 [_guard_dispatch_icall_fptr] 之后再跳转到ShellCode执行
_guard_dispatch_icall_fptr是为了实现CFG机制而被添加到PE文件的加载配置目录表(LOAD_CONFIG_DIRECTORY)中的五个域其中之一(如下):
1 2 3 4 5 | .rdata: 000000014000B7A0 dq offset __guard_check_icall_fptr ; GuardCFCheckFunctionPointer
.rdata: 000000014000B7A8 dq offset __guard_dispatch_icall_fptr ; GuardCFDispatchFunctionPointer
.rdata: 000000014000B7B0 dq offset __guard_fids_table ; GuardCFFunctionTable
.rdata: 000000014000B7B8 dq 0Dh ; GuardCFFunctionCount
.rdata: 000000014000B7C0 dd 14500h ; GuardFlags
|
我在查找资料过程中发现 J 总 在2014年写的关于CFG原理介绍[4],那时候
_guard_dispatch_icall_fptr还是属于保留字段(Reserved2),具体是什么时候更改的无从所知,可能是在2016年。
guard_dispatch_icall_fptr 在开启了CFG之后保存的是NTDLL!LdrpDispatchUserCallTarget。(依据J总的文章中之前指向的应该是 NTDLL!LdrpValidateUserCallTarget)
如果将其放在不支持CFG的机器上运行则指向间接跳转指令stub,最终调用JMP Rax指令跳转到目标地址.
从NTDLL!LdrpDispatchUserCallTarget的汇编代码中可以了解其大概的判断流程,先获得GuardCFBitMapAddress地址(此地址位于LdrSystemDllInitBlock一个偏移处,不同的NTDLL版本可能不同),之后将目标地址右移9位作为BitMap的取值下标Index,之后依据如下公式获取从BitMap中取出值:
1 | SIZE_T BmValue = BitMapAddress + sizeof(PVOID) * Index
|
之后将目标地址右移3位,并取低五位作为BmValue的位下标。比如下标位10则判断BmValue下标位10的位是0/1,是1则代表有效,是0则代表无效。当然这只是针对目标地址是按照0x10对齐的,不过一般情况下函数都是按照0x10对齐。NTDLL!LdrpDispatchUserCallTarget的伪代码如下:参数一是BitMapAddress的地址,参数二是需要判断的目标地址
BitMapAddress需要根据自己当前Ntdll中BitMapAddress所在偏移来获取,比如我的BitMapAddress存放在Ntdll.ModuleBase + 0x179390位置处:
1 2 | DWORD BitOffset = 0x179390 ;
size_t BitMapAddresss = * (size_t * )((size_t)NtdllHandle + BitOffset);
|
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 | void MyLdrpDispatchUserCallTarget(size_t BitMapAddress,size_t TargetAddress)
{
/ / unsigned __int64 qword_180179390;
/ / unsigned __int64 v0; / / rax
unsigned __int64 v2; / / r10
__int64 v1; / / r11
v1 = * (size_t * )(BitMapAddress + 8 * (TargetAddress >> 9 ));
v2 = TargetAddress >> 3 ;
if ((TargetAddress & 0xF ) ! = 0 )
{
v2 & = 0xFFFFFFFFFFFFFFFEui64 ;
if (!_bittest64(&v1, v2 % 64 )) {
printf( "%llx Is Not Valid\n" ,TargetAddress);
return ;
}
}
else if (_bittest64(&v1, v2 % 64 ))
{
printf( "%llx Is Valid\n" , TargetAddress);
return ;
}
if (_bittest64(&v1, (v2 | 1 ) % 64 )) {
printf( "%llx Is Valid\n" , TargetAddress);
return ;
}
printf( "%llx Is Not Valid\n" , TargetAddress);
return ;
}
|
在对MyLdrpDispatchUserCallTarget测试过程中,我发现这个函数是可以用来查找下个函数的起始地址的,我相信有很多人判断下个函数的地址是通过C3(ret)来判断的,我觉着这个函数提供了另外一个不错的方案。举个例子:
1 2 3 | for (DWORD i = 0 ; i < SIZE; i + + ) {
MyLdrpDispatchUserCallTarget(BitMapAddresss, (size_t)GetAtomNameA + i);
}
|
我向其传递GetAtomNameA地址并尝试验证接下来一段范围内的地址是否有效,结果运行如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 7ffeed0437d0 Is Valid
7ffeed0437d1 Is Not Valid
7ffeed0437d2 Is Not Valid
...
7ffeed0437de Is Not Valid
7ffeed0437df Is Not Valid
7ffeed0437e0 Is Not Valid
...
7ffeed0437f0 Is Not Valid
...
7ffeed0437ff Is Not Valid
7ffeed043800 Is Valid
7ffeed043801 Is Not Valid
7ffeed043802 Is Not Valid
7ffeed043803 Is Not Valid
...
|
为了节省篇幅,我省略了其中的一部分,...代表的地址都是无效的,很明显可以看到7ffeed0437d0作为GetAtomNameA的起始地址是有效的,下一个有效地址7ffeed043800地址则代表GlobalAddAtomExA ,当中的所有地址都是无效的,所以我们便可以据此来判断下个函数所在地址了。(只是个小插曲)
还记得上面说的CFG会记录当前或曾经是可执行的所有私有内存地址吗?这句话的意思是,CFG会将所有曾经或现在是可执行的私有内存地址保存在BITMAP中,当然他并不是真的把这些地址记录在案,而是如果你分配了这么一段私有内存,那么这段连续的内存在BITMAP中都被认为是有效地址,不像上面的DLL中只有函数的起始地址才被认为是有效的。
我们将使用VirtualAlloc分配的可读可写可执行的内存地址作为目标地址传递给MyLdrpDispatchUserCallTarget你就会得到这样的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 1b119fc0000 Is Valid
1b119fc0001 Is Valid
1b119fc0002 Is Valid
1b119fc0003 Is Valid
...
1b119fc0020 Is Valid
1b119fc0021 Is Valid
1b119fc0022 Is Valid
1b119fc0023 Is Valid
...
1b119fc0049 Is Valid
1b119fc004a Is Valid
1b119fc004b Is Valid
...
|
所有分配到的地址都是有效的。
所以接下来让我们看看John Uhlmann的poc是如何利用CFG这一特性来查找可以内存块的。
首先通过获取用户层可访问到的最大的虚拟内存地址,并从零开始遍历所有虚拟内存地址。
以下为代码片段,源码详见上述POC
1 2 3 4 5 | GetSystemInfo(&g_sysinfo);
va = 0 ;
while (va < (ULONG_PTR)g_sysinfo.lpMaximumApplicationAddress){
...
}
|
其次判断BitMapAddress + (va >> 9) * 8地址处的BitMap内存是否已提交,否则跳过继续遍历
1 2 3 4 5 6 7 8 | PULONG_PTR pCfgEntry = pCfgBitMap + ((ULONG_PTR)va >> CFG_INDEX_SHIFT);
if (!VirtualQueryEx(hProcess, (PVOID)pCfgEntry, &mbiCfg, sizeof(mbiCfg)))
break ;
if (MEM_COMMIT = = mbiCfg.State){
...
}
|
这样做的原因是在64位系统中,BitMap映射到进程空间的大小为2T(依据此文章[5])或者使用VMMap查看的BitMap内存映射,可以看到两者是相符的,同时已提交内存大小在39M左右。
也就是说BitMap中并不是所有内存都是可以访问的,用来记录Guard Function Address也只是用到了一部分内存。(如果你硬是要访问的话就会触发异常,比如你非得查看0x00位置处的内存地址是否有效)。
之后假如我们找到了一个va地址并且BitMapAddress + (va >> 9) * 8处的BitMap内存是已提交的,也就是我们可以判断va是否是一个有效的跳转地址,我们希望继续得到此地址所在内存块是否是私有内存。
在看接下来的代码之前我们先总结一下:我们通过VirtualAlloc(Ex)申请的可执行的内存块是私有的,且这个内存块的所有地址均有效并记录在BitMap中,另外VirtualAlloc(Ex)是按照页大小的倍数申请的,就是说即便我们只申请0x100个字节,但实际得到的是0x1000大小的内存块。
那么BitMap中是如何表示这0x1000个字节是有效的呢?先别急,让我们先解决以下问题。
我在查找资料的过程中,文章中总是提到BitMap中的1位总是代表8个地址范围,为什么?
其实我们可以这样理解:以之前32位举例,将目标地址右移8位取出一个32位的值,则如下目标范围地址取出来的值其实是一样的:0x100001-0x1000ff总共是256个地址,那每一位可表示地址范围可由256 / 32获得也就是 8,这就像将10个苹果分给五个人,每个人拿到几个苹果一样简单,但当我第一看到BitMap中的1位总是代表8个地址范围实在是让人捉摸不透。
既然如此,则在BitMap中1字节代表64个地址范围,1页(0x1000字节)代表64页地址范围
如果我用VirtualAlloc申请了0x1000大小可执行内存,则在BitMap中是这样表示:
BitMap会用连续8个0xFFFFFFFFFFFFFFFF来表示这0x1000个地址是有效的。
接着看代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | ULONG_PTR vaRegionEnd = va + mbiCfg.RegionSize * 64 ;
while (va < vaRegionEnd){
pCfgEntry = pCfgBitMap + ((ULONG_PTR)va >> CFG_INDEX_SHIFT);
SIZE_T stBytesRead = 0 ;
ULONG_PTR ulEntry = 0 ;
if (!ReadProcessMemory(hProcess, pCfgEntry, &ulEntry, sizeof(ulEntry), &stBytesRead))
break ;
if (MAXULONG_PTR = = ulEntry){
if ( 0 = = hiddenRegionSize){
hiddenRegionStart = va;
}
hiddenRegionSize + = g_sysinfo.dwPageSize;
}
va + = g_sysinfo.dwPageSize;
}
|
这段代码通过Va找到的BitMap内存块的大小并依据上述关系反向映射Va所在内存块大小(va-vaRegionEnd),并开始遍历Va所在内存块。比如BitMap是4k大小,则对应的Va内存块大小就是644k。当发现某个用于判断Va地址是否有效的BmValue是*MAXULONG_PTR(0xFFFFFFFFFFFFFFFF)则认定这一页0x1000个地址都是有效的,即便这只能代表0x200个地址有效,同时将Va自增到下一页再进行判断。
再然后通过判断如果BmValue不是0xFFFFFFFFFFFFFFFF或者Va内存块遍历结束了就进行下一步,判断找到Va内存块是否已经提交并且此内存块在分配时候以及现在都不具备可执行属性,但在期间有过可执行属性(被认为是可疑的),最后放到容器中并返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | if ((hiddenRegionSize > 0 ) && ((MAXULONG_PTR ! = ulEntry) || (va = = vaRegionEnd))){
if (VirtualQueryEx(hProcess, (PVOID)hiddenRegionStart, &mbiStart, sizeof(mbi)) &&
(MEM_COMMIT = = mbiStart.State) &&
VirtualQueryEx(hProcess, (PVOID)(hiddenRegionStart + hiddenRegionSize - 1 ), &mbiEnd, sizeof(mbi)) )
{
/ / Is this region non - executable in the VAD tree?
bool bHiddenRegion = !(PAGE_EXECUTE_FLAGS & mbiStart.Protect) &&
!(PAGE_EXECUTE_FLAGS & mbiStart.AllocationProtect);
/ / Handle a few common (likely) false positives.
bool bLikelyFalsePositive =
(mbiStart.AllocationBase ! = mbiEnd.AllocationBase) || / / hidden region overlaps allocation
(hiddenRegionSize = = 0x3000 ); / / 12K region
if (bHiddenRegion && (bAggressive || !bLikelyFalsePositive))
{
result.push_back((PVOID)(hiddenRegionStart));
}
}
}
|
之后做的就是对找到的可以的内存进行详细的输出,上述代码就是利用CFG查找隐藏内存的核心了。
总结
总体而言,作者因为使用VirtualProtect修改某一地址内存属性为不可执行却不会将地址其从BitMap中移除这一特性迸发出用此来查找可以的shellcode内存的灵感是非常巧妙的,如果结合其他的内存特征来捕获恶意的shellcode可能会事半功倍,作为恶意开发者而言除了通过加密混淆等方法隐藏自己的shellcode,针对于此可能更应该在VirtualAlloc(Ex)上面下下功夫?
引用
[1]:https://learn.microsoft.com/en-us/windows/win32/secbp/control-flow-guard
[2]:https://www.blackhat.com/asia-23/briefings/schedule/index.html#you-can-run-but-you-cant-hide---finding-the-footprints-of-hidden-shellcode-31237
[3]:https://github.com/jdu2600/CFG-FindHiddenShellcode/tree/main
[4]:https://powerofcommunity.net/poc2014/mj0011.pdf
[5]:https://bbs.kanxue.com/thread-200845.htm
实战CVE漏洞分析与防范(第1季)