首先总结2种切换到内核模式方法的各自流程,以 Windows API WriteFile() 调用为例:
内存法(中断法):
(用户模式)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() 来分发
粗略地讲,INT 指令在内部涉及如下几个操作:
1。清空陷阱标志(TF),和中断允许标志(IF);
2。依序把(E)FLAGS,CS,(E)IP 寄存器中的值压入栈上;
3。转移到 IDT 中的中断门描述符记载的相应 ISR(中断服务例程)的起始地址;
4。执行 ISR,直至遇到 IRET 返回。
最关键的第3步涉及“段间”转移,通过中断门描述符,能够引用一个 Ring0 权限代码段,
该代码段对应的 64 位段描述符(存储在 GDT 中)中的 DPL 位,即特权级位等于0(0=Ring0;3=Ring3,即便由 Intel 规定的段描述符的 DPL 位有4种取值,但 Windows 仅使用了其中的最高特权级 Ring0 与最低特权级 Ring3,总体而言,用户模式应用程序位于 Ring3 代码或数据段;内核与设备驱动程序则位于 Ring0 代码或数据段 ),再结合段描述符中的“基址”与中断门描述符中的“偏移”,就能计算出 ISR 在 Ring0 代码段中的起始地址。下表是64位段描述符的格式,取自 Intel 文档,自行添加了翻译:
下面是 WinDbg 中输出的 GDT (全局描述表)中前几个 64 位段描述符的示例,其中的“P”栏位只有2种可能的取值:0或3,即内核或用户模式。黄框中的4个不同特权级的段都跨越线性地址空间从0~FFFFFFFF 的整个 4GB 范围。Windows 内核在 GDT 中创建这种数据结构来实现“平坦内存”分段模型,也就是没有分段,因为4个段的基址和段限都相同。
因此当 INT 指令将程序控制从Ring3 代码段转移到 Ring0 代码段,实际上就从用户模式切换到了内核模式,稍后我们会看到,这与调用 SYSENTER 指令前的一个操作有着异曲同工之妙。
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 标志。
由此可知,INT 2Eh 与 SYSENTER 指令都涉及从一个 Ring3 代码段跳转到一个 Ring0 代码段,从而进入内核模式,不同的是,INT 2Eh 从内存中的 IDT 和 GDT 取得跳转所需的地址(外加计算);而 SYSENTER 直接从寄存器中取得跳转所需的地址。
寄存器法看似比内存法多了很多步骤,尤其是 SYSENTER 指令的前置准备工作与隐含的内部操作,但是所有这些加起来,与访问内存中的 IDT /GDT 并取回数据相比,仍然快了数十至数百个处理器时钟周期。另外,中断法在进入内核模式后还要多一次对 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 处,首先将分发 ID ,0B3h,装载到 EAX 寄存器中,该 ID 将会通过一系列的函数调用传递到内核模式的 KiSystemService() / KiFastCallEntry(),后者使用该系统服务号查找并调度执行内核模式中的 NtOpenFile() 系统调用。
接着将地址 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 特权的代码,也就是切换到内核模式。
由此可见,不仅在执行 INT 2Eh 指令前需要加载系统服务号至 EAX 寄存器,执行 SYSENTER 指令前也需要相同的操作(传递给 SYSENTER 指令所在函数 KiFastSystemCall() ),这样,分发 ID 就从用户模式传递到了内核模式,供内核模式的 KiSystemService() / KiFastCallEntry() 系统服务分发/调度器据此执行实际的系统调用。
后面我们会验证系统服务号的完整查找过程。
如前所述,SYSENTER 指令隐含的6步中最为关键的就是从 IA32_SYSENTER_EIP 寄存器取出指令指针放到EIP中,而 IA32_SYSENTER_EIP 寄存器保存的即是 nt!KiFastCallEntry() 的起始地址。(通过内核调试器命令 rdmsr 0x176 可以获取该地址,这3个寄存器的地址如下图所示)
需要特别指出,指令 rdmsr 与 wrmsr(向 MSR 寄存器中加载信息)需要在 Windows 系统
启动时,按住 F8 键,选择“调试模式”启动系统,然后在命令行提示符下启动 WinDbg.exe,(KD.exe 无法在调试模式下启动),从主菜单中选择“File”-> “Kernel Debug”,在打开的对话框中,切换到“Local”选项卡,单击“确定”。这样 cmd.exe shell 就会创建一个 WinDbg.exe 子进程来调试内核。并且 rd* 命令才能正常使用。如果没有以调试模式引导,而是使用 LiveKd.exe 创建 WinDbg.exe / KD.exe 子进程来“实时”调试内核,则rd* 命令无法工作,会输出 “no such msr”的信息。
按照上表内容,首先执行 rdmsr 0x174 命令,获取 IA32_SYSENTER_CS 寄存器中的值,这是一个 64 位的值,也就是段选择符,一般而言,这个段选择符会引用一个具有 Ring0 DPL 的代码段描述符,以用来支持 SYSENTER 指令设置 EIP 为该段中的地址,从而进入内核模式:
在调试模式下我们无法转储 GDT 的内容,不过没有关系,既然已经得到段选择符,再次正常引导系统,调试内核转储 GDT ,然后索引第8项即可。
如下图所示,重新引导系统进入正常模式,启动 WinDbg,执行 r gdtr 命令输出 GDT 的起始地址,然后先通过 dd 命令设置 GDT 的转储上下文,再执行 dg 8 命令,通过索引8转储其对应的段描述符内容:
在上图中我们看到,索引8的段选择符引用的段描述符的基址为0,这个段很熟悉,因为我们在通过第一张图片展示“平坦内存”模型时,输出中就包含了这个 Ring0 权限的代码段,而既然该段的“基址”为0,那么 IA32_SYSENTER_EIP 寄存器的内容就能决定最终跳转到的内核模式例程的线性(虚拟)地址。(如果段基址不是0,还要与其相加计算出最终的线性地址)
为严谨起见,我们还要检查控制寄存器 CR0 的内容,具体而言,当 CR0 的 bit 31(PG 位)为“1”时,表明处理器启用了分页,此时,IA32_SYSENTER_EIP 寄存器中的线性地址需要进行地址翻译来转换为物理内存中的地址。
地址翻译涉及处理器的 MMU(内存管理单元),TLB(转换后援缓冲器)硬件,以及操作系统维护的多个数据结构之间的紧密协作,这些数据结构包括:PD(页目录),PDE(页目录项),PT(页表),PTE(页表项)。限于篇幅,本文不打算描述地址翻译的细节,各位可以参考 Intel 文档以及介绍操作系统原理的书籍。好消息是,通过内核调试器的一些扩展命令,我们可以模拟硬件与系统软件配合进行地址翻译的过程,以后有机会再独立发文介绍。
下面来看看 CR0 的结构,引用 Intel 官方文档的示意图:
执行命令 r cr0 转储 CR0 的内容,然后再执行 .formats 命令,后接32位的16进制数(CR0内容),将其转换为32位2进制数,以便对照上图查看 PG 与 PE 标志位的值:
由此可知,处理器启用了分页,因此下面几张图输出的 KiFastCallEntry() 入口地址为虚拟地址,需要经由地址翻译转换为物理地址后,处理器才能从相应的内存单元取指令并执行(这里忽略地址翻译的结果已经保存在了 TLB 中,在这种情况下,MMU 将使用 TLB 中的物理地址作为索引寻址 L1~L3 Cache 中匹配的高速缓存行,取出行中存储的指令)
当然,以上这些细节都不需要程序猿和逆向工程狮操心;硬件和系统软件会负责全部过程。
在调试模式下执行 rdmsr 0x176 命令,输出 IA32_SYSENTER_EIP 寄存器的内容,也就是 CPU 要跳转到的内核模式指令的地址:
反汇编 8427b8d0 地址处开始的机器指令,证实为 KiFastCallEntry() 的入口地址:
这样就跳转到了nt!KiFastCallEntry(),它将调度内核空间中的同名函数 nt!NtOpenFile(),实际执行用户应用请求的操作。下面这个图对寄存器法的整个过程进行了总结:
用依赖性遍历工具(Dependency Walker),查看 ntdll.dll 中的 NtOpenFile() 用户模式代理,以及 ntoskrnl.exe 中的实际系统调用服务(本机 API)NtOpenFile(),如下所示:
实际上, ntoskrnl.exe 中这些与 ntdll.dll 中一一对应的原生系统服务,是从一个叫做 KeServiceDescriptorTable (服务描述符表)的内核数据结构中导出的;另一个叫做
KeServiceDescriptorTableShadow(服务描述符表影子)的内核数据结构包含相同的系统服务列表,并且其中还多了一张在 Win32k.sys 中实现的 USER 与 GDI 例程的列表,在调试内核时,一般情况下无法查看到 KeServiceDescriptorTableShadow 中保存的这份 Win32k.sys 导出函数列表副本,但还是可以用 Dependency Walker 打开 Win32k.sys,查看“正本”,可以看到其中包含一组最基本的2维绘图函数,用来生成窗口,图形用户界面等等:
回到主题上来,前面提到,执行 SYSENTER 或 INT 2Eh 前,需要加载一个系统服务号到 EAX 寄存器,系统服务号的作用就是提供给 KiSystemService() / KiFastCallEntry()在 KeServiceDescriptorTable 或 KeServiceDescriptorTableShadow 中,索引相应系统服务的入口地址并调度执行。具体而言,32位的系统服务号中的 bit 12, 13 用于指定其中一个服务描述符表,低12位(bit 0~11)用来在表中索引系统服务函数。后面会介绍实际的查找过程。
首先查询这2个数据结构的地址:
由于我的系统是 32 位 Windows 7旗舰版,因此加载的内核映像 ntkrnlpa.exe 在调试器中会以其原始文件名,即 ntkrpamp.exe 显示:
KeServiceDescriptorTable 结构的前面16字节是一个叫做SST(系统服务表)的子结构,如下所示:
在理解上图的基础上,使用前面得出的 KeServiceDescriptorTable 地址,转储其前 16 字节内容:
SSDT 的起始地址为 84285f8c,SSDT 中系统服务例程数量为 0x191(401)个。
枚举 SSDT 中前面10几个函数的入口地址,它们与 ntdll.dll 中的导出函数一一对应:
ntdll.dll 导出了 1985 个函数,其中的 401个需要切换到内核模式才能完成实际的任务,NtOpenFile() 属于其中之一,因此系统服务分发/调度器 KiSystemService() / KiFastCallEntry() ,在 KeServiceDescriptorTable 的 SSDT 401 个系统服务中选择相应的本机 API 来调用;ntdll.dll 中其它的导出函数在用户模式实现了完整的功能,因此应用程序调用这些函数不需要切换到内核模式,当然也没有对应的 SSDT 项。
继续前面对系统服务号的讨论:KiSystemService() / KiFastCallEntry() 需要一个系统服务号在 SSDT 中索引并调用相应的系统服务例程。
在用户模式的 ntdll.dll 中,每个Nt*() 都有一个同名称的 Zw*() 与其对应,2者在用户空间的入口点完全相同:
在内核模式的 ntkrnlpa.exe / ntoskrnl.exe 中,也有同名的 Nt*()-Zw*() 函数对:
不同之处在于,内核模式的 Nt*() 入口地址记录在 SSDT 中,而 Zw*() 则没有记录,这是由于,在 Zw*() 内部,通过加载系统服务号到 EAX 寄存器,然后调用 KiSystemService() / KiFastCallEntry(),最终会调度相应的 Nt*() 例程来执行,因此 Zw*() 就没有必要保存在
SSDT 中。由于微软将用户模式 Nt*()/Zw*() 与内核模式 Nt*() 强制关联在一起,内核模式设备驱动程序不允许直接调用内核模式 Nt*(),而是需要通过 Zw*() 来间接调用 Nt*()
通过反汇编内核模式 NtReadFile() 与 ZwReadFile() ,不但能揭露出2者的调用关系,还能理清系统服务号的查找过程:
首先,将系统服务号从4字节的16进制数 0x00000111 解析为32位2进制数,这可以通过WinDbg 的 .formats 命令进行转换:
温习前面的 KeServiceDescriptorTable 数据结构,依次搜索,定位到其中的 SSDT :
最终得证,KiSystemService() 将调用的本机 API 确实为 NtReadFile():
这就是系统服务号的完整查找过程。
总结一下,不仅从用户模式请求系统调用需要传递分发 ID 到内核模式(请回顾前文);就连内核例程间的相互调用,只要被调者是属于 KeServiceDescriptorTable 或 KeServiceDescriptorTableShadow 的各自 SSDT 中的服务例程,都需要主调者传递分发ID。不同的是,在用户模式中,由 KiFastSystemCall() / KiIntSystemCall() 代为接收分发 ID,然后通过 SYSENTER / INT 2Eh 指令,在切换到内核模式时,一并将其传递给 KiSystemService() / KiFastCallEntry();而内核例程间的调用,例如设备驱动程序调用本机 API ,就直接传递分发 ID 给 KiSystemService() / KiFastCallEntry()。
最后,把注意力放回前面那张反汇编 ntdll!KiFastSystemCall() 的图,细心的你或许已经发现, ntdll!KiFastSystemCall() 的内存地址后面不远处,就是 ntdll!KiIntSystemCall() 的起始地址,既然 calc.exe 进程的用户空间中存在2条进入内核空间的途径,或许意味着程序中有一个类似 CMP..... JE/JGE 的汇编判断逻辑,用于向前兼容不支持 SYSENTER 指令的旧型 Intel 处理器使用 INT 2Eh 进入内核空间。(只是猜测,各位有兴趣可以自行验证)