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派遣函数进行调用。

参考资料

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值