首先总结2种切换到内核模式方法的各自流程:
内存法(中断法):
(用户模式)WriteFile() -> ntdll!NtWriteFile() -> ntdll!KiIntSystemCall() -> int 2Eh -> 查找IDT的内存地址,偏移0x2E处 ->(内核模式)nt!KiSystemService()
-> nt!KiFastCallEntry() -> nt!NtWriteFile()
通过0x2E中断转移控制到内核模式后,系统服务分发/调度器为 nt!KiFastCallEntry(),它负责调用内核空间中的同名异前缀函数 nt!NtWriteFile(),后者有一个系统服务号;也叫做分发 ID,该 ID 需要在执行 int 2Eh 前,加载到EAX 寄存器,以便通知 nt!KiSystemService()要它分发的系统调用(本机API),但是最终还是经由 nt!KiFastCallEntry() 来分发
MSR寄存器法(快速法):
(用户模式)WriteFile() -> ntdll!NtWriteFile() -> ntdll!KiFastSystemCall() -> 分别设置 IA32_SYSENTER_CS 寄存器的值为 Ring0 权限代码段描述符对应的段选择符;设置 IA32_SYSENTER_ESP 寄存器的值为 Ring0 权限的内核模式栈地址;设置 IA32_SYSENTER_EIP 寄存器指向 nt!KiFastCallEntry() 的起始地址 ->
SYSENTER ->(内核模式)nt!KiFastCallEntry() -> nt!NtWriteFile()
通过 SYSENTER 转移控制到内核模式后,系统服务分发/调度器为 nt!KiFastCallEntry() ,它负责调用内核空间中的同名异前缀函数 nt!NtWriteFile()
SYSENTER指令隐含了6步操作:
1.从 IA32_SYSENTER_CS 取出段选择符加载到 CS 中。
2.从 IA32_SYSENTER_EIP 取出指令指针放到 EIP 中
3.将 IA32_SYSENTER_CS 的值加上8,将其结果加载到 SS 中。(也就是将Ring0权限代码段选择符+8,来计算 Ring0 权限的内核模式堆栈段地址对应的段描述符)
4.从 IA32_SYSENTER_ESP 取出堆栈指针放到 ESP 寄存器中
5. 从 EIP 指向的地址处取指令,从而真正进入内核模式
6.若 EFLAGS 中 VM 标志已被置,则清除 VM 标志。
寄存器法看似比内存法多了很多步骤,尤其是 SYSENTER 指令的前置准备工作与隐含的内部操作,但是所有这些加起来,与访问内存中的 IDT 并取回数据相比,仍然快了数十至数百个处理器时钟周期。另外,中断法在进入内核模式后还要多一次对 nt!KiSystemService() 的调用,因此增加了性能开销。
ntdll!Nt* 为 nt!Nt* 系统调用的用户模式代理,前者在其中一个叫做SytemCallStub 的变量中保存 ntdll!KiFastSystemCall() 的地址(后面会验证);
ntdll!KiFastSystemCall() 中的 SYSENTER 指令负责实际从Ring3 到 Ring0 的转移,即进入内核模式。
在 Intel Pentium II 或 Windows XP 以前,系统调用只能通过 INT 2Eh 中断切换到内核模式,并且 nt!KiSystemService() 作为实际的系统服务分发/调度器。
在这之后,无论使用 INT 2Eh 或 SYSENTER,实际的系统服务分发/调度器都是 nt!KiFastCallEntry(),如前所述,这就没有必要使用 INT 2Eh 来多执行一次nt!KiSystemService()。
下面结合用户模式调试与内核模式调试来验证上述内容,首先用 WinDbg 打开 calc.exe (Windows 计算器)或其它任意可执行 PE 文件,在底部的命令行输入
u ntdll!KiIntSystemCall,反汇编这个函数,可以看到其 77c071c4 地址处的2字节机器指令序列,int 2Eh :
在WinDbg菜单中选择停止调试,然后退出程序,再次用 LiveKD.exe打开 WinDbg,这将直接调试内核,执行 !idt 2e 命令,获取处理int 2Eh 的 ISR,可以看到,这个8字节的门描述符最终指向的就是 nt!KiSystemService() 的地址 842447fe;注意,线性地址7FFFFFFF是用户与内核空间的分水岭,往上80000000属于内核空间:
执行 u 842447fe L25 命令,反汇编nt!KiSystemService() 的前25行,发现其最终跳转到了nt!KiFastCallEntry+0x8f 偏移处(8424495f地址处):
使用KD.EXE 也可以验证:
由此证实了通过中断进行系统调用的流程。但是,在calc.exe进程中,究竟是选择中断法还是MSR寄存器法,还需要加以验证。为此,再次以 WinDbg 打开 calc.exe,按照前面的流程,先执行 u ntdll!NtOpenFile 命令,因为 OpenFile() 是任何一个应用使用机率最大的 Windows API 之一,它将导致调用用户模式代理:ntdll!NtOpenFile() ,因此我们选择后者来反汇编:
可以看到在上图的 A 处,将地址 7ffe0300 处的 ShareUserData!SystemCallStub(系统调用存根)复制到 EDX 寄存器中,然后使用带有存储器寻址格式操作数的汇编指令 call dword ptr [edx],也就是调用这个存根保存的函数地址,换言之,我们下一步要转储地址 7ffe0300 保存的内容,看看是什么函数的地址。输入指令 dd 7ffe0300:
从上图得知, 7ffe0300 地址处开始的 4 字节16 进制数为 77c071b0,换言之,前面的 call dword ptr [edx] 指令等价于 call 77c071b0,于是我们继续反汇编这个地址。输入指令 u 77c071b0:
从上图得知,77c071b0 是 ntdll!KiFastSystemCall() 的起始地址,换言之,系统调用存根就保存了指向这个地址的指针(7ffe0300);ntdll!KiFastSystemCall() 的内容为只有4字节的机器指令,其中第2条的2字节指令 0f34 ,也就是 Intel Pentium II 处理器以后新增的 SYSENTER 指令,它将程序对 CPU 的控制权转移到 Ring0 特权的代码,也就是切换到内核模式。
如前所述,SYSENTER 指令隐含的6步中最为关键的就是从 IA32_SYSENTER_EIP 寄存器取出指令指针放到EIP中,而 IA32_SYSENTER_EIP 寄存器保存的即是 nt!KiFastCallEntry() 的起始地址。(通过内核调试器命令 rdmsr 0x176 可以获取该地址,这3个寄存器的地址如下图所示)
这样就跳转到了nt!KiFastCallEntry(),它将调度内核空间中的同名函数 nt!NtOpenFile(),实际执行用户应用请求的操作。下面这个图对寄存器法的整个过程进行了总结:
最后,把注意力放回上面那张反汇编 ntdll!KiFastSystemCall() 的图,细心的你或许已经发现, ntdll!KiFastSystemCall() 的内存地址后面不远处,就是 ntdll!KiIntSystemCall() 的起始地址,既然 calc.exe 进程的用户空间中存在2条进入内核空间的途径,或许意味着程序中有一个类似 CMP..... JE/JGE 的汇编判断逻辑,用于向前兼容不支持 SYSENTER 指令的旧型 Intel 处理器使用 INT 2Eh 进入内核空间。(只是猜测,各位有兴趣可以自行验证)