在绝大多数操作中在权限和空间划分中存在应用层与内核层,应用层完成一些面向用户功能的操作,但实际上这些功能最后还得变成一种对内外部设备的请求,而这些请求一般不会由应用程序主动申请,不管在权限上还是工作量上都是不合理的。由此这些事情交给了内核。应用程序使用内核提供出的接口去进行系统调用以完成它们的工作。本文讨论关于Win10 18363操作系统中从应用层函数至内核层函数的带有KPTI缓解措施的调用主要流程。
理论知识:
- 什么是KPTI
KPTI(内核页表隔离)是Microsoft为了CPU漏洞Spectre与Meltdown漏洞而采取的缓解措施。漏洞的攻击点是CPU的两个优化技术(乱序执行,分支预测)
- 什么是分支预测
是当时为了提升性能而采取的一个新的优化技术,因为CPU在执行多个非分支指令时,会将多个指令同时执行
如表:
|
周期1 |
周期2 |
周期3 |
周期4 |
周期5 |
语句1 |
取指 |
译码 |
执行 |
访存 |
写回 |
语句2 |
|
取指 |
译码 |
执行 |
访存 |
语句3 |
|
|
取指 |
译码 |
执行 |
语句4 |
|
|
|
取指 |
译码 |
语句5 |
|
|
|
|
取指 |
但是当分支语句即将执行时,CPU无法预先得知哪条下条指令即将执行。因此只能等待分支语句前的所有语句执行完毕后才可进行判断。
而分支预测技术,通过预测即将执行的分支可能的跳转,来读取/执行分支指令后的指令。
比如存在以下代码:
for (int i = 0 ; i < 100; ++i)
{
if(i < 99)
printf(“i = %d\n”,i);
else
printf(“Error\n”);
}
此时i执行99次以内i<99一直都是成立的,因此当i 等于99的时候,CPU推测i < 99仍然成立,就会将 i < 99成立的下一条语句取指执行。如果i < 99不成立,则丢弃猜测执行的结果,执行else分支中的语句。
猜测失败的情况:
|
周期1 |
周期2 |
周期3 |
周期4 |
周期5 |
语句1 |
取指 |
译码 |
执行 |
访存 |
写回 |
跳转语句 |
|
取指 |
译码 |
执行 |
跳走 |
语句3(取消执行结果) |
|
|
取指 |
译码 |
执行 |
语句4(取消执行结果) |
|
|
|
取指 |
译码 |
语句5(取消执行结果) |
|
|
|
|
取指 |
- 什么是乱序执行
主要考虑的是语句之间的依赖关系,导致某些语句需要等待其他语句执行。对此问题提出的乱序执行的优化方案。
如以下代码:
int a = 1;
int b = a + 2;
int c = 3;
CPU开始尝试同时执行a = 1与b = a+2;,但是发现b=a+2需要依赖与a的数值。因此不得不等待a=1执行完成后,再执行b=a+2
为此乱序执行的方法是发现b=a+2需要依赖于a,就把这条指令放到c=3之后。语句就变成了
int a = 1;
int c = 3;
int b = a + 2;
省去了等待a 变量的时间。
- 漏洞是如何被利用的
漏洞利用程序使用训练分支预测功能的权值,使得让CPU以为接下来的分支预测执行的代码是我们预期的恶意代码,而这个代码尝试读取内核空间的数据,并且数据会存储在分支预测结果中。但当CPU发现分支预测失败后,会清空这部分数据。我们无法将数据带出。这时有一种测信道攻击的办法。
我们通过预先将缓存刷成其他数据,随后控制分支预测执行代码去访问内核数据,下一条指令将数据作为下标访问我们的另一个数组。这种方法会在缓存行中记录下通过内核空间数据作为下表访问数组的元素。利用在缓存的数据与在内存中访问速度的差异,得出内核地址中的数据。
- KPTI做了什么
KPTI 全称叫做内核页表隔离。原理是在内存页面映射中进行处理。剔除了普通进程中的内核空间页表映射,只映射很小一部分内核与应用层交互必须的内存空间。使得即使成功利用也无法读取更多敏感数据。并且在系统调用/中断处理中修改入口地址,添加KVAS段,来做相应的对接工作。
随机使用一个进程的页表去获得内核中的进程信息的物理地址来验证内核页表隔离的效果:
没有KPTI机制的操作系统:
含有KPTI机制的操作系统:
可以发现在这个页表中没有此内存的映射
代码分析:
我们随机挑选一个在ntdll.dll中的系统调用函数作为分析的开始。
当前系统环境:
从ZwCreateFile中开始
可以看到在syscall前有mov eax和mov r10 的操作,在windows 的系统调用中约定eax为调用号,r10暂存第一个参数(因为syscall指令会修改rcx)
首先先了解下syscall命令都做了些什么:
我们所关注的是
RCX = RIP
RIP = MSR[IA32_LSTAR]
R11=RFLAGS
RFLAGS = RFLAGS &(~MSR[IA32_FMASK]) (去除掉IA32_FMASK中存在的位)
IA32_FMASK:
0x4700 == 01000111 00000000
EFlags格式:
清除了TF/IF/DF/NT位,主要注意关了中断(IF)。
随后会进入MSR[IA32_LSTAR]的地址开始执行R0代码(CPL也被置零了)
随后进入内核空间:
可以发现进入了一个叫KVASCODE的段中的一个函数KiSystemCall64Shadow,这个段负责暂时接管这些内核调用
通过swapgs切换内核gs环境,实际上是与MSR[IA32_KERNEL_GS_BASE]的值进行了切换,内核gs基址指向KPCR(CPU控制块)结构
swapgs:
随后存储用户层的rsp至KPCR(CPU控制块)中的UserRspShadow。
从KPCR中取出KernelDirectoryTableBase作为当前的页表(cr3寄存器)。实现了页表切换。
途中有判断KPCR的ShadowFlags的地位是否为1,如果为1则跳过页表切换,猜测应该是判断是否开启KPTI
随后切换内核栈指针,并且使用了0x38个栈空间存储如图信息。并判断KPCR中的FeatureBits信息来设置AC标识位,限制内核访问用户空间。0
随后将关于分支预测的一些配置进行保存至Trapped信息中。 并进行内核用户分支预测模式切换。并且关闭了缓解措施(为了性能)
随后
如果以上两个BpbState与BpbFeatures的Flags判断跳转后:
通过lfence指令顺序化前后的读取指令顺序。
如果以上两个都没有跳转,则进入一个函数,是一个一直往上Call的代码,在最上面又会回到上图的位置(猜测是一种乱序执行的缓解措施)
随后进入真正的KiSystemCall64内部
判断是否是调试设置、是否使用了DR7等,对应用层的调试寄存器进行保存。还有部分Ums(用户模式调度)的内容
本文不讨论Windows 的其他机制,因此假设不是Ums线程以及调试信息已经处理完毕,或者不是调试模式。则跳转至LABEL_25处
LABEL_25处:
之前KVAS保存rax/rcx/rdx的代码:
可以发现在KVAS段中保存的rax/rcx/rdx都恢复回来了,在这里我们转到C语言模式为这些变量进行命名,标明他们不是从未谋面的数值。
并且rax 是从用户模式传进来的调用号一直没有变,rcx 是用户层传来的第一个参数,rdx为第二个参数,r8、r9在之前的环境中一直没有被修改,因此r8 r9 无需恢复。
在进程环境块中存储第一个参数、调用号、TrapFrame。
这时发现TrapFrame的数值竟然直接来自与rsp,而在整个函数中都没有调整rsp,这意味着rsp的数值来自于KVAS代码。
验证此猜测:
dt _KTRAP_FRAME出结构大小发现是0x190的大小
返回KVAS代码查看之前有一段栈空间分配操作0x158+0x38刚好是0x190
由于栈是倒着存储的,所以看结构体末尾(下图注释已经将数据正序),刚好传入每一个对应的变量。
由此得知在KVAS的那段栈分配实际上是造了个TRAP_FRAME
从此开始是关于函数真正开始分发的逻辑了:
- 首先CallNumber >> 7 & 0x20 为真的调用号,表明此函数为GDI函数,可以看出GDI函数的调用号是0x1000开始的,本次跟踪的是NtCreateFile函数,调用号是0x55。
- 后通过 &0xfff获得索引号
- 随后当当前线程不是Gui线程时,使用 SSDT(KeServiceDescriptorTable)表,当前线程是Gui线程时使用KeServiceDescriptorTableShadow,当前线程是受限Gui线程时使用KeServiceDescriptorTableFilter。
那么SSDT存的是什么呢?KeServiceDescriptorTable和KeServiceDescriptorTableShadow与KeServiceDescriptorTableFilter之间的区别是什么?
首先这几张表存放的数据格式是完全一样的,存放着每一个系统调用函数的地址、函数个数、参数表等
SSDTShadow的表中含有GUI函数而SSDT没有,SSDTFilter则是在受限的GUI线程时使用,部分函数无法使用(似乎Windows暂时还没有使用它,因此它和SSDTShadow一模一样)。
KeServiceDescriptorTable的结构:
struct {
PVOID ServiceTableBase; //函数表的基地址
PVOID ServiceCounterTable; //每个函数被调用的次数(不过似乎没有被使用)
unsigned int NumberOfServices; //由函数表中函数个数。
PVOID ParamTableBase; //包含每个函数参数字节数表 (不过似乎没有被使用)
};
然后进入 函数表
结构是:int 函数偏移[0x1d0]
这里存放着每个函数相对于这张表的偏移与通过栈传参的参数个数,每个数值都是一个int类型,注意不是unsigned int 。
格式:
| 31 - 4 | 3 - 0 |
| 函数偏移 | 通过栈传参的参数个数 |
实际参数个数 = 通过栈传参参数个数+4;(不知为何+4参见__fastcall调用约定的传参规则)
函数偏移则是基于本张表的偏移,例如此图中第一个函数:
fced7304中4是当前函数参数个数为4+4 = 8
剩下的ffced730是偏移(有符号的)
那么0x55号函数的地址就是
而参数个数则是 7 + 4 = 11个
注意:在调用Gui函数时,是SSDTShadow的第二个KeServiceDescriptorTable结构,而非第一个。第一个结构中仍存放着普通函数。
而接下来的代码就是做着和我们一样的事情:
可以看到KeServiceDescriptorTable有加IsGuiFunction,若IsGuiFunction等于0x20,就相当于跳过了第一个KeServiceDescriptorTable结构。
随后如果当前调用的函数是GUI函数,且之前有图形工作没有做完,则把它使用NtGdiFlushUserBatch绘制出来(猜测)
这里取出了参数个数,如果有则 * 8随后的代码IDA并没有分析出来
查看汇编:
查看调用原的数据段是否是用户层,如果是并且传入的用户栈如果高于MmUserProbAddress则设置为MmUserProbAddress,防止用户层使用内核地址作为栈地址。
随后以KiSystenServiceCopyEnd最为基址减去函数参数个数 * 8,最后跳过去执行。实际上是为了调用函数时的参数栈。
我们来看下,KiSystenServiceCopyEnd上都有些什么:
实际上是参数的拷贝,从用户栈上拷贝参数到内核栈中,随后使用这个栈去调用函数。
KiSystenServiceCopyEnd函数结束后刚好就是C代码的下一句
这就是对系统服务函数调用了,针对不同的情况去调用不同的前后函数,这里不考虑其他因素可以发现else中直接对函数进行了调用。随后将直接进入此函数。
至此执行流程成功进入了系统服务函数(NtCreateFile)中。