Windows x64内核学习笔记(六)—— LFENCE&CFG
预测执行
众所周知,CPU的运行速度是非常快的,每秒能执行百千万条指令,而指令是从哪里来的呢,当然是从内存中读取的。由于内存的运行效率低于CPU,就导致了一个问题,即CPU访问内存的速度远大于内存访问CPU的速度。
那么,应该怎么做,才能将CPU的性能尽可能利用起来呢?
CPU处理一条指令大致可以分为四个阶段:读取指令、翻译指令、执行指令、写回结果,这四个阶段分别能够独立运行。如果说,CPU每次处理一条指令都等这四个阶段结束之后,再去读取下一条指令,显然是不合理的,当然CPU也不是这么做的,而是当第一条指令读取完毕进入翻译阶段时,马上去读取第二条指令,当第一条指令进入执行阶段时,CPU已经在翻译第二条指令了,同时,CPU正在读取第三条指令。
读取指令 | 翻译指令 | 执行指令 | 写回结果 |
---|---|---|---|
指令1 | 指令1 | 指令1 | 指令1 |
指令2 | 指令2 | 指令2 | |
指令3 | 指令3 | ||
指令4 |
那么,如果遇到JCC指令该怎么办呢?我们知道,JCC指令需要判断表示位来决定执行哪条分支的代码,假设CPU读取的第二条指令是JCC指令,当第一条指令进入执行阶段时,CPU该读取哪条分支的指令作为第三条指令?
对于这种情况,CPU在设计上采用了「预测执行」,即把其中一条分支(通常是条件为真的那条)中的指令假定为程序即将执行的部分,然后不管三七二十一先这样往下运行,当然,运行前需要保存当前CPU的状态。
如果第一条指令执行后,CPU发现确实和它预测的一样,那么CPU就已经抢跑了一部分指令,提高了运行效率;如果CPU发现条件为假,不应该走向这条分支,那么CPU可以根据之前保存的状态进行回退,重新走向另一条分支。
无论最终CPU走向哪条分支,都会把结果写入高速缓存中,当下次再走到这条分支时,优先走高速缓存中的那条。
实验一:理解预测执行
第一步:编译以下代码
#include <stdio.h>
#include <windows.h>
void main()
{
DWORD64 time1, time2;
DWORD i;
DWORD x = 0, y = 0;
PDWORD arr = (PDWORD)malloc(0x10000000 * sizeof(PDWORD));
for (i = 0; i < 0x10000000; i++)
{
arr[i] = i; //使用循环遍历为每个成员赋值
}
time1 = GetTickCount64();
for (i = 0; i < 0x10000000; i++)
{
if (arr[i] <= 0x8000000)
x++;
else
y++;
}
time2 = GetTickCount64();
printf("time:%d\n", time2 - time1); // 计算for循环运行的时间
printf("x = %x\ny = %x\n", x, y);
system("pause");
return;
}
运行结果:
第二步:修改代码,再次运行
#include <stdio.h>
#include <windows.h>
#include <time.h>
void main()
{
DWORD64 time1, time2;
DWORD i;
DWORD x = 0, y = 0;
PDWORD arr = (PDWORD)malloc(0x10000000 * sizeof(PDWORD));
srand(time(0));
for (i = 0; i < 0x10000000; i++)
{
arr[i] = 0x7FFC000 + rand(); // 改为使用随机数赋值,随机数范围:0x7FFC000~0x8003FFF
}
time1 = GetTickCount64();
for (i = 0; i < 0x10000000; i++)
{
if (arr[i] <= 0x8000000)
x++;
else
y++;
}
time2 = GetTickCount64();
printf("time:%d\n", time2 - time1);
printf("x = %x\ny = %x\n", x, y);
system("pause");
return;
}
运行结果:
结论:可以看到,当使用随机数进行赋值时,循环的运行时间要明显长很多,这是因为CPU在运行过程产生错误预测的次数增多,导致回退状态的次数增多,从而降低了运行效率。
幽灵漏洞
描述:幽灵漏洞(Spectre)属于硬件漏洞,它正是由CPU的「预测执行」机制导致的漏洞,对应的CVE是CVE-2017-5753/CVE-2017-5715。
当CPU进行预测执行时,如果最终CPU发现预测的结果与实际运行不一致,则会进行回退,但是,很重要的一点是,CPU只会还原自身的状态,而不会还原高速缓存的状态。
int test()
{
char arr[20];
char data;
bool bRet = 1;
if(bRet == 0) //条件永远不成立
{
data = arr[100];
}
return 0;
}
例如上面这段代码,虽然if的条件永远为假,但是由于存在预测执行这个机制,CPU在执行到if指令前会模拟继续执行其中的指令,并且在这个时候是不会对访问权限进行校验的,因此有机会进行越界访问。
当CPU执行到if语句时,发现条件为假,便会回退到先前状态,直接跳到return的位置,但是,在高速缓存中已经留下了内存访问的痕迹,通过侧信道攻击能够有机会得知其中的数据,这即是幽灵漏洞的大致思路。
除此之外,还存在一个与之比较相似的漏洞,叫做熔断(Meltdown),它则是由于CPU的「乱序执行」机制导致的漏洞,不过不是目前学习的重点,因此在本篇不作细述,具体可参考这篇文章。
LFENCE
描述:在内核文件中,能够经常看到在某行jcc指令的下方存在一条lfence指令,它的功能是禁止CPU对后面的指令预测执行。虽然禁止预测执行产生了性能上的部分损耗,但是提高了系统整体的安全性。
CFG
描述:控制流防护(Control Flow Guard,CFG)是微软在Windows10和Windows8.1Update3中默认启用的一种安全防护机制,它主要是用于在发生间接跳转时,检查目标地址的合法性。
_guard_dispatch_icall
描述:_guard_dispatch_icall是CFG机制的派遣函数。
在内核中,许多函数都需要进行间接调用,出于安全性考虑,避免目标地址被控制,因此不会直接CALL目标地址,而是先通过_guard_dispatch_icall检查地址合法性,并由其进行调用,如果地址合法,它的作用相当于「CALL RAX」。
以PspSystemThreadStartup函数为例,代码实现部分就引用了_guard_dispatch_icall函数。
通过查看_guard_dispatch_icall的交叉引用,能看到一共有三千五百多条引用处。
观察_guard_dispatch_icall函数的具体实现,可以看到是通过一张位图来检测目标地址是否指向非法地址。
如果目标地址合法,则将目标地址写入栈顶然后使用RETN进行跳转。
为什么使用栈进行调用而不直接执行「CALL RAX」呢?其实也是为了防止CPU预测执行。
在Visual Studio 2019中,可在项目配置里手动开启和关闭CFG保护。
但是在开启CFG后,编译代码时可能会报错。
可以通过修改调试信息格式进行兼容。
可以观察一下未开启CFG与开启CFG编译之后的变化,首先在不开启CFG的情况下编译以下代码。
#include <stdio.h>
#include <windows.h>
void main()
{
typedef void (*PFUNCTION)();
printf("call function");
PFUNCTION function = (PFUNCTION)0xffffffff;
function();
system("pause");
return;
}
使用IDA查看main函数关键部分的汇编指令。
可以看到,程序直接调用了[rbp+0F0h+var_E8]中的地址,未检查目标地址的合法性。
然后,开启CFG,再次编译,查看汇编指令。
可以看到,此时间接调用指令已经消失,改为使用CFG派遣函数进行调用。