首先总结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 文档,自行添加了翻译:


wKiom1ZAPg-xmh_WAAOeb_QeCPE691.png

下面是 WinDbg 中输出的 GDT (全局描述表)中前几个 64 位段描述符的示例,其中的“P”栏位只有2种可能的取值:0或3,即内核或用户模式。黄框中的4个不同特权级的段都跨越线性地址空间从0~FFFFFFFF 的整个 4GB 范围。Windows 内核在 GDT 中创建这种数据结构来实现“平坦内存”分段模型,也就是没有分段,因为4个段的基址和段限都相同。


wKiom1ZARg3wG4X2AABtAQhN7xE697.png


因此当 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 :

wKiom1Y_cbbTrY83AAAbsEfr31s904.png


在WinDbg菜单中选择停止调试,然后退出程序,再次用 LiveKD.exe打开 WinDbg,这将直接调试内核,执行  !idt 2e 命令,获取处理int     2Eh 的 ISR,可以看到,这个8字节的门描述符最终指向的就是 nt!KiSystemService() 的地址 842447fe;注意,线性地址7FFFFFFF是用户与内核空间的分水岭,往上80000000属于内核空间:

wKioL1Y_cyuDWetyAABGo6TaEuo312.png


执行 u 842447fe L25 命令,反汇编nt!KiSystemService() 的前25行,发现其最终跳转到了nt!KiFastCallEntry+0x8f 偏移处(8424495f地址处):

wKioL1Y_c9jQePD7AAGRFzCYgLA402.png


使用KD.EXE 也可以验证:

wKiom1Y_c-OgCj1fAAB7fg0lckI573.png



wKiom1Y_dBaiQN6BAAGVaUPwvq8722.png

wKioL1Y_dJHDPWYJAAE4CbVOTpM464.png


由此证实了通过中断进行系统调用的流程。但是,在calc.exe进程中,究竟是选择中断法还是MSR寄存器法,还需要加以验证。为此,再次以 WinDbg 打开 calc.exe,按照前面的流程,先执行 u ntdll!NtOpenFile 命令,因为 OpenFile() 是任何一个应用使用机率最大的 Windows API 之一,它将导致调用用户模式代理:ntdll!NtOpenFile() ,因此我们选择后者来反汇编:

wKiom1Y_dPPjwUdXAAAtlFnzGW4434.png

可以看到在上图的 A 处,首先将分发 ID ,0B3h,装载到 EAX 寄存器中,该 ID 将会通过一系列的函数调用传递到内核模式的 KiSystemService() / KiFastCallEntry(),后者使用该系统服务号查找并调度执行内核模式中的 NtOpenFile() 系统调用。

接着将地址 7ffe0300 处的 ShareUserData!SystemCallStub(系统调用存根)复制到 EDX 寄存器中,然后使用带有存储器寻址格式操作数的汇编指令 call dword ptr [edx],也就是调用这个存根保存的函数地址,换言之,我们下一步要转储地址 7ffe0300 保存的内容,看看是什么函数的地址。输入指令 dd 7ffe0300:

wKiom1Y_dUbT1wMiAAATx0YPmbc642.png

从上图得知, 7ffe0300 地址处开始的 4 字节16 进制数为 77c071b0,换言之,前面的 call dword ptr [edx] 指令等价于 call 77c071b0,于是我们继续反汇编这个地址。输入指令 u 77c071b0:

wKioL1Y_dcqBr9_nAAAglz0QcFM160.png

从上图得知,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个寄存器的地址如下图所示)

wKiom1Y_dfnAMYmzAAA5k5ji35Y303.png


需要特别指出,指令 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 为该段中的地址,从而进入内核模式:

wKioL1ZBserD7qOZAABbBwKIgMs487.png


在调试模式下我们无法转储 GDT 的内容,不过没有关系,既然已经得到段选择符,再次正常引导系统,调试内核转储 GDT ,然后索引第8项即可。

如下图所示,重新引导系统进入正常模式,启动 WinDbg,执行 r gdtr 命令输出 GDT 的起始地址,然后先通过 dd 命令设置 GDT 的转储上下文,再执行 dg 8 命令,通过索引8转储其对应的段描述符内容:

wKiom1ZBtOfQ2rQuAABti4XTUJE169.png

在上图中我们看到,索引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 官方文档的示意图:

wKiom1ZCEiKiHhWiAADYhO2R4Dk521.png


执行命令 r cr0 转储 CR0 的内容,然后再执行 .formats 命令,后接32位的16进制数(CR0内容),将其转换为32位2进制数,以便对照上图查看 PG 与 PE 标志位的值:

wKioL1cRpHSjMG2uAAAz-7QUxTc204.png

由此可知,处理器启用了分页,因此下面几张图输出的 KiFastCallEntry() 入口地址为虚拟地址,需要经由地址翻译转换为物理地址后,处理器才能从相应的内存单元取指令并执行(这里忽略地址翻译的结果已经保存在了 TLB 中,在这种情况下,MMU 将使用 TLB 中的物理地址作为索引寻址 L1~L3 Cache 中匹配的高速缓存行,取出行中存储的指令)

当然,以上这些细节都不需要程序猿和逆向工程狮操心;硬件和系统软件会负责全部过程。

另外,CR0 的 16 位,即 WP 位(参考上图)。当此位置 1 时,表明禁止 CPU 向只读的页面写入数据。通常,Windows 内核代码,包含一些重要的系统服务函数,它们所在的页面会被映射为只读,然后置 WP = 1,这就在某种程度防止了一些 rootkit 或后门程序修改系统服务/存根函数,或者挂钩敏感的内核数据结构,比如服务描述符表(KeServiceDescriptorTable),所以这些程序在修改它们要***的内核代码数据结构前,通常会置 WP = 0,也就是禁用写保护,CR0 仅在

Ring0 权限级可见(可读),这对内核模式的后门或 rootkit 不成问题。



在调试模式下执行 rdmsr 0x176 命令,输出 IA32_SYSENTER_EIP 寄存器的内容,也就是 CPU 要跳转到的内核模式指令的地址:

wKiom1ZBsc2xy5flAAArzXqMTnk718.png


反汇编 8427b8d0 地址处开始的机器指令,证实为 KiFastCallEntry() 的入口地址:

wKioL1ZBsiPxukbjAAAkWsxw8wQ644.png


这样就跳转到了nt!KiFastCallEntry(),它将调度内核空间中的同名函数 nt!NtOpenFile(),实际执行用户应用请求的操作。下面这个图对寄存器法的整个过程进行了总结:

wKioL1Y_gSPCDf3zAAByaGdRJA0718.png


  

用依赖性遍历工具(Dependency Walker),查看 ntdll.dll 中的 NtOpenFile() 用户模式代理,以及 ntoskrnl.exe 中的实际系统调用服务(本机 API)NtOpenFile(),如下所示:

 wKioL1ZAFIWQlaYNAADwSQYyRNk495.png


实际上, ntoskrnl.exe 中这些与 ntdll.dll 中一一对应的原生系统服务,是从一个叫做 KeServiceDescriptorTable (服务描述符表)的内核数据结构中导出的;另一个叫做

KeServiceDescriptorTableShadow(服务描述符表影子)的内核数据结构包含相同的系统服务列表,并且其中还多了一张在 Win32k.sys 中实现的 USER 与 GDI 例程的列表,在调试内核时,一般情况下无法查看到 KeServiceDescriptorTableShadow 中保存的这份 Win32k.sys 导出函数列表副本,但还是可以用 Dependency Walker 打开 Win32k.sys,查看“正本”,可以看到其中包含一组最基本的2维绘图函数,用来生成窗口,图形用户界面等等:

wKiom1ZAupqSgwaeAAFsnivyeKo538.png


回到主题上来,前面提到,执行 SYSENTER 或 INT 2Eh 前,需要加载一个系统服务号到 EAX 寄存器,系统服务号的作用就是提供给 KiSystemService() / KiFastCallEntry()在 KeServiceDescriptorTable 或 KeServiceDescriptorTableShadow 中,索引相应系统服务的入口地址并调度执行。具体而言,32位的系统服务号中的 bit 12, 13 用于指定其中一个服务描述符表,低12位(bit 0~11)用来在表中索引系统服务函数。后面会介绍实际的查找过程。

首先查询这2个数据结构的地址:

wKiom1ZAwAriwf7lAAAc07zkwwA189.png

由于我的系统是 32 位 Windows 7旗舰版,因此加载的内核映像 ntkrnlpa.exe 在调试器中会以其原始文件名,即 ntkrpamp.exe 显示:

wKioL1ZAwr6jTK4NAAFn11B0aTk452.png


KeServiceDescriptorTable 结构的前面16字节是一个叫做SST(系统服务表)的子结构,如下所示:

wKiom1ZAzAziKcs6AAD66OoJP8o122.png

在理解上图的基础上,使用前面得出的 KeServiceDescriptorTable 地址,转储其前 16 字节内容:

wKiom1ZAzVqiXiUvAAAcCOp2V1U902.png

SSDT 的起始地址为 84285f8c,SSDT 中系统服务例程数量为 0x191(401)个。

枚举 SSDT 中前面10几个函数的入口地址,它们与 ntdll.dll 中的导出函数一一对应:

wKioL1ZA0n7yUIGPAAJi8t_jan8340.png

ntdll.dll 导出了 1985 个函数,其中的 401个需要切换到内核模式才能完成实际的任务,NtOpenFile() 属于其中之一,因此系统服务分发/调度器 KiSystemService() / KiFastCallEntry() ,在 KeServiceDescriptorTable 的 SSDT  401 个系统服务中选择相应的本机 API 来调用;ntdll.dll 中其它的导出函数在用户模式实现了完整的功能,因此应用程序调用这些函数不需要切换到内核模式,当然也没有对应的 SSDT 项。

继续前面对系统服务号的讨论:KiSystemService() / KiFastCallEntry() 需要一个系统服务号在 SSDT 中索引并调用相应的系统服务例程。

在用户模式的 ntdll.dll 中,每个Nt*() 都有一个同名称的 Zw*() 与其对应,2者在用户空间的入口点完全相同:wKiom1ZA2_3QTZqLAAAlOeIXqbw425.png


在内核模式的 ntkrnlpa.exe / ntoskrnl.exe 中,也有同名的 Nt*()-Zw*() 函数对:


wKioL1ZA3gTSaHFLAACKy-8YO3I387.png

不同之处在于,内核模式的 Nt*() 入口地址记录在 SSDT 中,而 Zw*() 则没有记录,这是由于,在 Zw*() 内部,通过加载系统服务号到 EAX 寄存器,然后调用 KiSystemService() / KiFastCallEntry(),最终会调度相应的 Nt*() 例程来执行,因此 Zw*() 就没有必要保存在

SSDT 中。由于微软将用户模式 Nt*()/Zw*() 与内核模式 Nt*() 强制关联在一起,内核模式设备驱动程序不允许直接调用内核模式 Nt*(),而是需要通过 Zw*() 来间接调用 Nt*()

通过反汇编内核模式 NtReadFile() 与  ZwReadFile() ,不但能揭露出2者的调用关系,还能理清系统服务号的查找过程:

wKioL1ZA5b7iYhsNAAGiLS0hj4c143.png


wKiom1ZA50qhoPh4AACCLPMBrCY149.png


注意,nt!ZwReadFile() 的前6字节中,后4个字节 11010000 就是以 Intel 机器小端法表示的系统服务号,它是一个4字节的16进制数,还原成正确的“字节序”为

0x00000111

首先,将系统服务号从4字节的16进制数 0x00000111 解析为32位2进制数,这可以通过WinDbg 的 .formats 命令进行转换:

wKioL1ZA6yOyqNLKAAFuq6O-HlU546.png


温习前面的 KeServiceDescriptorTable 数据结构,依次搜索,定位到其中的 SSDT :

wKioL1ZA8lTzhqbbAADZPX1eDvc855.png


最终得证,KiSystemService() 将调用的本机 API 确实为 NtReadFile():

wKioL1ZA85KT0EiHAAAh4-vMP08698.png

这就是系统服务号的完整查找过程。

总结一下,不仅从用户模式请求系统调用需要传递分发 ID 到内核模式(请回顾前文);就连内核例程间的相互调用,只要被调者是属于 KeServiceDescriptorTable 或 KeServiceDescriptorTableShadow 的各自 SSDT 中的服务例程,都需要主调者传递分发ID。不同的是,在用户模式中,由 KiFastSystemCall() / KiIntSystemCall() 代为接收分发 ID,然后通过 SYSENTER / INT 2Eh 指令,在切换到内核模式时,一并将其传递给 KiSystemService() / KiFastCallEntry();而内核例程间的调用,例如设备驱动程序调用本机 API ,就直接传递分发 ID 给 KiSystemService() / KiFastCallEntry()。


wKiom1ZA-B6Q5X_rAABXBXXtG10174.png


把注意力放回前面那张反汇编 ntdll!KiFastSystemCall() 的图,细心的你或许已经发现, ntdll!KiFastSystemCall() 的内存地址后面不远处,就是 ntdll!KiIntSystemCall() 的起始地址,既然 calc.exe 进程的用户空间中存在2条进入内核空间的途径,或许意味着程序中有一个类似 CMP..... JE/JGE 的汇编判断逻辑,用于向前兼容不支持 SYSENTER 指令的旧型 Intel 处理器使用 INT 2Eh 进入内核空间。(只是猜测,各位有兴趣可以自行验证)

有了上面关于 CR0 寄存器的 WP 位,系统服务号在 SSDT 中的查找过程。。。等基础知识,最后来看一个禁用 WP 位并挂钩 SSDT 中系统服务的例子,节选自对某个 rootkit 的反汇编代码片段。

1。首先,运行于 Ring0 特权级别的恶意代码通过下面的逻辑置 CR0 的 WP = 1:

01:    .text: 0001062F        push    eax
02:    .text: 00010630        mov     eax,  cr0
03:    .text: 00010633        mov     [esp+8+var_4],  eax
04:    .text: 00010637        and     eax,  0FFFEFFFFh
05:    .text: 0001063C        mov     cr0,  eax
06:    .text: 0001063F        pop     eax

第 1 行将 eax 的当前值存储在当前栈顶,因为第 2 行要读 cr0 的值到 eax 中并在第 4 行进行修改;

第 3 行将 cr0 的内容复制到栈内的一个局部变量中,这无关紧要;

注意,第 4 行通过将 cr0 的当前值与立即数 0xFFFEFFFF 进行“按位与”运算,实际上这就等同于将 cr0 的第 16 位(WP 位)清 0 ;

第 5  行将修改结果写回 cr0,实现了禁用写保护位;第 6 行从栈上恢复先前保存的值到 eax 中。假设上面这一小段代码属于一个叫做 DisableWP() 的函数。


2。调用 DisableWP(),然后获取系统服务描述符表(其中内嵌了 SSDT)的地址,并获取某个系统调用存根(stub)函数的第 2 个字节开始的 4 字节内容(即系统服务号,后面会验证),这样就知道该存根函数会调用 SSDT 中的哪个系统服务,最后,只要把这个系统服务的入口点地址覆盖成要执行的恶意代码或 shellcode 例程的地址,就完成了钩子函数的设置:

01:    .text: 000117D4  sub_117D4  proc  near
01:    .text: 000117D4        push   ebp
02:    .text: 000117D5        mov    ebp,    esp
.......
08:    .text: 000117E1        mov    ecx,    ds:KeServiceDescriptorTable
.......
10:    .text: 00011808        call   DisableWP
11:    .text: 0001180D        mov    esi,    ds:ZwQuerySystemInformation
12:    .text: 00011813        mov    eax,    [esi+1]
13:    .text: 00011816        mov    ecx,    [ecx]
14:    .text: 00011818        mov    dword ptr [ecx+eax*4],    offset  sub_1123E
.......
16:    .text: 00011836  sub_117D4  endp

第 1—2 行是例程 sub_117D4 的“序言”部分,即保存主调函数的栈帧,并展开被调函数的栈帧;

第 8 行将位于全局数据段(由 ds 寄存器引用)的系统服务描述符表地址加载到 ecx 中;

第 10 行禁用写保护,从而后面的逻辑可以修改 SSDT 所在的,被映射为只读的页面;

第 11 行将系统调用存根函数 ZwQuerySystemInformation() 的地址加载到 esi 中;

第 12 行将 ZwQuerySystemInformation() 的第 2 个字节开始的 4 字节——即系统服务号/索引——复制到 eax 中;

由前文可知,KeServiceDescriptotTable 的前 4 字节存储了 SSDT(KiServiceTable)中第一个系统服务函数的地址(参考前面转储 KeServiceDescriptorTable 内容的示意图),因此第 13 行使用方括号的存储器寻址格式将 ecx 更新为该地址;

由于每个系统服务的入口点地址在 SSDT 中都占用 4 字节,因此第 14 行将系统服务索引(eax)乘以 4 ,加上第一个系统服务的地址(ecx),就得到要调用的入口点,并且把 sub_1123E——也就是要干坏事的例程的地址写入这个常规的函数入口。注意 dword ptr 汇编前缀,表明目标操作数,即 [ecx+eax*4] 应该是一个 4 字节指针,换言之就是内存地址;从现在开始,所有调用 ZwQuerySystemInformation() 的其它内核组件或设备驱动程序,最终都会重定向到我们的恶意代码。

下面验证 ZwQuerySystemInformation() 的前几字节确实涉及了特定的系统服务号,并在其后续逻辑中调用了该服务,这样我们挂钩该服务的工作才没有白干:

wKiom1cSeUfSmvwaAABy_d19PkA806.png


 由上图可知,系统服务号为 0x105;接下来,在 SSDT 中查找到相应的系统服务:


wKioL1cSfruSnnbHAADKBL0Z5tY018.png


将上图与第二段返汇编代码结合起来理解,不难看出,就第 13 行而言,ecx 就是地址 0x841AAB00,而 [ecx] 就是取得地址 0x841AAB00 处的内容,即 0x840BF43C (KiServiceTable 中的第一个系统服务指针);类似地,就第 14 行而言,表达式 ecx+eax*4 就是在计算 KiSystemService() 要调用的系统服务指针(0x840BF850),该指针指向的地址用 [ecx+eax*4] 表示,即 0x84271F45,这就是系统服务 NtQuerySystemInformation() 第一条指令的地址。从而第 14 行代码的效果就是将此地址覆盖为 sub_1123E 例程的地址,实现系统服务挂钩。


无论是由用户模式的存根函数切换到内核模式来调用 KiSystemService(),还是由内核模式设备驱动程序通过 Zw*() 例程来间接调用 KiSystemService(),在调用 KiSystemService() 前,都需要将系统服务号加载到 eax 寄存器中,作为它的参数之一传递。由于 KiSystemService() 是未文档化,非公开的系统例程,除了用调试器反汇编它的机器码对其进行逆向之外,还可以参考一个叫做 ReactOS 的开源项目,其中的 KiSystemService() 函数逻辑基本上反映了微软的同名函数,如下源码所示:


VOID
KiSystemService(
 IN PKTHREAD Thread,
 IN PKTRAP_FRAME TrapFrame,
 IN ULONG Instruction        //通过 eax 传入的系统服务号
 )
 
{
      ULONG Id, Number, ArgumentCount, i;
      PKPCR Pcr;
      ULONG_PTR ServiceTable, Offset;
      PKSERVICE_TABLE_DESCRIPTOR DescriptorTable;
      PVOID SystemCall;
      PVOID* Argument;
      PVOID Arguments[0x11]; // Maximum 17 arguments
      KIRQL OldIrql;
      ASSERT(TrapFrame->Reserved == 0xBADB0D00);

 ........(省略无关内容).......

    //取得系统服务号,将其与 0xFFFFF 按位与运算后,保存在局部变量 Id 中

      Id = Instruction & 0xFFFFF;
      DPRINT1("[SWI] (%x) %p (%d) \n", Id, Thread, Thread->PreviousMode);

   //获取系统服务描述符表(KTHREAD——内核线程结构中的 ServiceTable 成员指向该表 )
     
      ServiceTable = (ULONG_PTR)Thread->ServiceTable;
      Offset = ((Id >> SERVICE_TABLE_SHIFT) & SERVICE_TABLE_MASK);
      ServiceTable += Offset;
      DescriptorTable = (PVOID)ServiceTable;


   //将 Id 与 服务号掩码按位与运算,得出实际的系统服务号

      Number = Id & SERVICE_NUMBER_MASK;
   
   //如果系统服务号不在系统服务描述符表中,返回错误信息(DescriptorTable->Limit 是
   //系统服务的数量,请参考前面的 KeServiceDescriptorTable 结构示意图)
   
      if (Number > DescriptorTable->Limit)
      {
 
         UNIMPLEMENTED;
         ASSERT(FALSE);
      }

    //根据服务号在统服务描述符表中查找对应的系统服务,SystemCall 指向该系统服务的入口点
   
      SystemCall = (PVOID)DescriptorTable->Base[Number];
  
      if (Offset & SERVICE_TABLE_TEST)
      {
     
        // TODO
        ASSERT(FALSE);
      }
      
    //在实际调度该系统服务前,检查它需要多少参数

      ArgumentCount = DescriptorTable->Number[Number] / 4;
      ASSERT(ArgumentCount <= 17);


    //首先复制要求通过寄存器传递的前 4 个参数
        
      Argument = (PVOID*)&TrapFrame->R0;
      for (i = 0; (i < ArgumentCount) && (i < 4); i++)
      {
            
       //前 4 个参数复制到内核栈中    
         Arguments[i] = *Argument;
         Argument++;
      }
           
     ........(省略无关内容).......
           
    //调度执行该系统服务;KiSyscallHandlers 数组中的每个函数指针指向的例程负责调度
    //相应的系统服务。
         
      TrapFrame->R0 = KiSyscallHandlers[ArgumentCount]((PVOID)SystemCall, (PVOID)Arguments);
                                                           
                                                     
   ........(省略无关内容).......
 }


可以看到,KiSystemService() 有三个参数,最后一个就是在主调函数的反汇编代码中,最先通过 eax 寄存器传递给它的系统服务号,而前二个参数是通过栈传递的:

0: kd> u ZwQuerySystemInformation
nt!ZwQuerySystemInformation:
8403d264 b805010000      mov     eax,105h
8403d269 8d542404        lea     edx,[esp+4]
8403d26d 9c              pushfd
8403d26e 6a08            push    8
8403d270 e849140000      call    nt!KiSystemService (8403e6be)
8403d275 c21000          ret     10h


作为留给各位的练习,请尝试将前二个参数与上面的代码对应起来并解释其含义。