为什么要写这篇文章
1. 因为最近在学习《软件调试》这本书,看到书中的某个调试历程中讲了Windows的系统调用的实现机制,其中讲到了从Ring3跳转到Ring0之后直接进入了KiFastCallEntry这个函数。
2. 碰巧前天又在网上看到了一篇老文章介绍xxx安全卫士对Windows系统调用的Hook,主要就是Hook到这个函数
3. 刚刚做完毕业设计,对使用中断来实现系统调用的方式记忆犹新。
以上原因导致我最近眼前总是出现系统调用这个词,脑海中总是出现系统调用的实现方式,所以决定写篇文章清理一下思维。本文所有举例如无明确指明,均是在Windows XP SP3系统中获得。
本文的目的是探索一下Windows目前的系统调用的实现架构,介绍一种中规中矩的获取KiFastCallEntry函数地址的方法,但是在介绍之前还是要把该解释的解释清除。
Windows API和系统调用的关系
肯定会有人说Windows API就是系统调用,系统调用就是Windwos API,是这样的么?负责任的说:不是这样的!
先从广义上来说,Windows API是对于整个Windows操作系统自身的程序代码之外的应用程序来说的,而系统调用是Windows内核对于非内核程序代码之外的Windows系统程序代码来说的。也就是说系统调用要比Windows API低一个层次。一个Windows API是一个函数,这个函数可能会使用系统调用,请注意是可能,因为并不是所有的API都需要进入内核去完成这个API的功能。
Windows内核实现文件和应用层文件
在一个安装完成的Windows操作系统中可见并有效的内核实现文件是
C:\Windows\System32\ntoskrnl.exe
C:\Windows\System32\ntkrnlpa.exe
请注意有两个内核文件,其中第二个比第一个的名字少了os多了个pa,省去的os没有任何意义,但是多出来的pa所代表的意思是PAE(物理地址扩展),这是X86CPU的一个硬件特性,Windows启动之后根据当前系统设置是否开启了PAE特性会自动选择把其中一个作为内核加载到内存中。
为什么加了这么多限定词,因为ntoskrnl.exe这个文件名并不一定是这个文件的真实名称,可以从文件属性中看到
ntoskrnl.exe原始文件名为可能为ntoskrnl.exe或者ntkrnlmp.exe
ntkrnlpa.exe原始文件名为可能为ntkrnlpa.exe或者ntkrpamp.exe
可以发现其中的不同之处就是mp,mp就是Multi-processor(多处理器,也可以理解为多核,因为IA-32架构对多核处理器的编程和多处理器的编程是相似的机制)。为什么会出现这中情况呢?因为这完全是由计算机硬件的不同配置导致的。当安装Windows操作系统的时候,Windows安装程序会自动检测机器的CPU特性,根据CPU的核心数来确定使用哪一套内核。如果是单核心就只复制ntkrnlpa.exe和ntoskrnl.exe到系统目录下,如果是多核心就复制ntkrnlpamp.exe和ntoskrnlmp.exe到系统目录下,所以如果你有一台单核心CPU的机器,有一天你换了双核的CPU却没有重新安装操作系统,那么你就不会在看到熟悉的Windows启动画面了。类似这两个文件的还有一个文件C:\Windows\System32\hal.dll,这是Windows的硬件抽象层程序文件,这个就不做具体介绍了。额外补充一个不同的硬件配置所需要的文件列表:
Standard PC | hal.dll |
Advanced Configuration and Power Interface (ACPI) PC | halacpi.dll---->hal.dll |
ACPI Uniprocessor PC | halaacpi.dll--->hal.dll |
MPS Uniprocessor PC | halapic.dll---->hal.dll |
ACPI Multiprocessor PC | halmacpi.dll--->hal.dll |
Compag SystemPro Multiprocessor or 100% Compatible | halsp.dll---->hal.dll |
MPS Multiprocessor PC | halmps.dll----->hal.dll |
Silicon Graphics Visual Workstation | halsp.dll---->hal.dll |
不论什么配置,一旦系统安装完成后,对我们来说可见的内核文件就只有两个ntoskrnl.exe和ntkrnlpa.exe。这两个文件中的代码就是运行于RING0下的内核代码,他们里面所包含了真正的系统调用的代码。我们用Dependency Walker看一下:
(P1)
可以看到很多函数,他们之中有些是系统调用,会和RING3的程序代码有联系,有些仅仅是内核中的函数,只跟内核中其他的代码有联系。有一点需要说,在用Windbg调试内核的时候,无论系统使用的是哪个内核文件,Windbg都会把这个内核文件的符号(模块名)识别为nt。所以如果我们要查看内核中的NtOpenFile函数的反汇编,只需要输入一下命令
u nt!NtNtOpenFile
如下图:
(P2)
下面再说包含Windows API的文件,这个就非常多了,最基础的User32.dll,Ntdll.dll,Kernel.dll这三个文件包含了Windows系统相关的绝大多数API,当然还有其他函数簇比如包含socket函数簇的ws2_32.dll等等,这些dll中都导出了大量的API函数以及结构。这些函数都是运行在RING3层的代码,有些会跟内核层中的代码发生联系,有写也不会,就是上面提到的Windows API不一定会使用系统调用。比如我们举个简单的例子,拿CharNextA这个Windows API来说,这个API是在User32.dll中实现的。我们可以反汇编一下看看这个API的具体实现。
(P3)
可以看到这个函数在ret之前只有一个跳转je并且目标代码仍然在本函数内,而且没有任何中断或者快速系统调用,所以这个函数并没有离开RING3层,也就是说当你编写一个程序调用了这个API之后,这个API并没有把你的程序流程带入RING0的内核层代码。
Windows API使用系统调用的方法
通过中断实现系统调用
Windows API如果设涉及到系统调用就要由RING3进入RING0,这就牵扯到了X86保护模式下有特权级变化的控制转移。在早期的CPU中(Pentium II之前),没有快速系统调用这个机制,所以能用来进行特权级变化的控制转移的机制只有通过中断实现,保护模式下的中断的实现方式是通过IDT表来实现,IDT表中存放的是一种特殊的X86段描述符——门描述符,门描述符的格式如下
(P4)
可以看到其中有一个Selector字段和一个Offset字段,并且是不连续的,这里只介绍这两个字段的含义,其他字段的含义这里不再赘述,有兴趣的话可以自己去看下保护模式相关资料。说到底这个门描述符的作用就是描述一个程序段,对我们来说重要的就是Selector和Offset字段了,因为Selector可以帮我们找到它所描述的程序的【段】,Offset就是程序在【段】内的【偏移】,有了【段】和【偏移】就可以确定程序的线性地址。那么我们来试着找一找Windows通过中断来实现系统调用时候的流程!不会用Windbg的程序员不是好的狙击手。
首先用Windbg打开一个Calc.exe或者其他的普通应用程序,然后键入命令:
u ntdll!KiIntSystemCall
(P5)
如果有疑问为什么直接来到这个函数,现在先不忙解释,后面再说。可以看到这个函数中
int 2Eh
这一句汇编代码,我们知道了系统调用了2E号中断,从而进入了系统内核,知道了中断号下面我们要做的就是找到这个中断的服务程序,也就是RING3进入到RING0之后的第一条指令在哪里。下面就进入内核调试模式。通过
!pcr
查找IDT的线性地址
(P6)
找到了IDT的线性地址:0x8003f400,前面说过IDT中存放的是门描述符,每一个门描述符占用8个字节,所以我们要找的第2e个门描述符的地址应该为0x8003f400+2e*8,然后我们通过如下命令:
dq /c 1 8003f400+2e*8
查看内存内容:
(P7)
看到第一行的八个字节就是我们要找的2e号中断所对应的门描述符。根据门描述符的格式计算,得到这个门描述符中包含的
Offset【偏移】 = 0x8053e4a1
Selector = 0x0008
【偏移】已经找到了,下面就剩【段】了,【段】可以通过Selector【选择子】找到,这里需要说下【选择子】,选择子占用2个字节,格式如下
(P8)
选择子是真正可以存放在保护模式下的段寄存器中的结构,既然它被放在段寄存器中,那么通过它必然能够找到段的信息,保护模式下的段是通过段描述符来描述的,描述符的具体分类有三种:存储段描述符,系统段描述符,门描述符,这里只介绍存储段描述符,其他的请自行查阅保护模式相关资料。存储段描述符格式如下:
(P9)
可以看到存储段描述里包含了基址,界限和属性。对我们来说只要找到基址就行了。存储段描述符是保存在GDT中的,而选择子则包含了【描述符索引】即这个选择子所指向的描述符在GDT中的索引号,比如我们刚才计算得到的选择子Selector = 0x0008,其中索引号为1,就是说我们要找的段的段描述符在GDT中的第1项。由此可知,我们要找到【段】还需要找到段描述符,这个算法跟从IDT中找门描述符是一样的,先找到GDT的地址,从刚才的!pcr命令执行结果看到GDT = 0x8003f000,一个描述符占用8个字节,我们要找第一个描述符,计算得到描述符的地址为:0x8003f000+8*1,执行命令
dq /c 1 8003f000+1*8
得到结果:
(P10)
根据上述存储段描述符的格式计算得到该段的基地址为0x000000所以费劲千辛万苦找到了【段】=0x00000,现在【段】和【偏移】都找到了,那么我们要找的一个线性地址就找到了:
【段】:【偏移】 = 0x8053e4a1
反汇编看看这个地址的代码!
u 8053e4a1
得到结果:
(P11)
是这个函数nt!KiSystemService。为了验证我们没有计算错误,可以用Windbg直接显示2E号中断所对应的中断服务程序,执行命令:
!idt 2e
(P12)
可以看到通过脑力算出的结果与Windbg dump出来的结果是一样的,证明了我们的算法是没有问题的。
这样我们先总结一下使用中断实现系统调用时候RING3和RING3的函数接续:
NtDll!KiIntSystemCall > Nt!KiSystemService
使用快速系统调用机制
从Pentium II系列开始的CPU引入了快速系统调用这一特性,增加了两条指令SYSENTER和SYSEXIT(AMD CPU中的指令为SYSCALL和SYSRET)。这一机制的实现就是专门用于解决操作系统的系统调用的性能问题的,这种机制实现的控制转移比中断系统要快很多,因为转移的目标地址是存放在MSR寄存器内,而中断实现的系统调用目标地址存放在内存中的IDT中,所以能提高执行速度。
下面看一下在应用层是在哪里调用了这条指令的:
(P13)
可以看到在ntdll!KiFastSystemCall中有这条指令。
SYSENTER指令的工作机制是在调用SYSENTER指令前,软件必须通过下面的MSR寄存器,指定0层的代码段和代码指针,0层的堆栈段和堆栈指针:
1. IA32_SYSENTER_CS:一个32位值。低16位是0层的代码段的选择子。该值同时用来计算0层的堆栈的选择子。
2. IA32_SYSENTER_EIP:包含一个32位的0层的代码指针,指向第一条指令。
3. IA32_SYSENTER_ESP:包含一个32位的0层的堆栈指针。
MSR寄存器可以通过指令RDMSR/WRMSR来进行读写。寄存器地址如下表。这些地址值在以后的intel 64和IA32处理器中是固定不变的。
MSR | 地址 |
IA32_SYSENTER_CS | 174H |
IA32_SYSENTER_ESP | 175H |
IA32_SYSENTER_EIP | 176H |
当执行SYSENTER,处理器会做下面的动作:
1.从IA32_SYSENTER_CS从取出段选择子加载到CS中。
2.从IA32_SYSENTER_EIP取出指令指针放到EIP中
3.将IA32_SYSENTER_CS的值加上8,将其结果加载到SS中。
4.从IA32_SYSENTER_ESP取出堆栈指针放到ESP寄存器中
5.切换到0层。
6.若EFLAGS中VM标志已被置,则清除VM标志。
7.开始执行选择的系统过程。
又看到了【选择子】,了解了执行流程,下面还可以手工一步步计算出SYSENTER指令行执行CPU取出的第一条内核指令到底在哪里。有了上面的计算过程,这次计算就不需要写的很详细了,目标还是要找到【段】和【偏移】,很明显【偏移】放在MSR的176号地址中,Windbg用如下指令读取MSR:
rdmsr 176
(P14)
【偏移】 = 0x8053e60
下面找【段】,依然是通过选择子来计算,选择子在MSR的174号地址中存放
rdmsr 174
(P15)
【选择子】 = 0x0008,跟之前的选择子一样,这里就不用再去计算了,从刚才的结果中知道这个选择子指向的描述符所描述的内存基址是线性地址0x0000
所以我们要找的目标线性地址为【段】:【偏移】 = 0x8053e60
下面反汇编一下这个地址:
(P16)
看到是函数Nt!KiFastCallEntry。
总结一下使用快速系统调用机制的时候RING3和RING3的函数接续:
NtDll!KiFastSystemCall > Nt!KiFastCallEntry
完整的Windows API使用系统调用的过程
在XP系统之前,Windows只实现了一种系统调用的方式,那就是通过INT 2E号中断来实现的,所以从RING3到RING0之后的第一个函数一定是nt!KiSystemService,这个函数就是核心的系统调用分派函数,但是XP开始,Windows系统开始使用快速系统调用这一机制了,但是并没有失去对中断方式的支持,所以XP之后的系统都是两种实现方式共存的。具体做法我们可以用Windbg来继续探索,下面的工作就要在RING3来做了,因为我们要分析一个完整的Windows API使用系统调用的过程。在这之前需要先说一下有关Nt*和Zw*函数的问题。在应用层的Ntdll.dll中有大量的Nt开头的函数和Zw开头的函数,并且都是配对出现,他们所指向的地址都是相同的,所以他们的实现是完全相同的,只不过是别名问题。我们可以用Depdency Walker查看他们的入口地址发现都是相同的,也可以用Windbg反汇编看,关于这一点没什么好说的了,网上很多资料都有说。那我们就可以随便选择一个Nt开头的函数来进行分析,或者Zw开头的都是一样的。内核中也存在这样的函数,但是Nt开头的函数是真正的函数实现,Zw开头的函数是通过nt!KiSystemService函数最终调用的Nt开头的函数。我们选择NtOpenFile函数,需要说明的是这个函数是未导出的,我们在编程的时候使用的是kernel32.dll中导出的OpenFile或者OpenFile,最终是要进入NtOpenFile,从KERNEL32.dll到Ntdll.dll的过程省略。先反汇编NtOpenFile看看:
(P17)
看到在NtOpenFile函数中红色标注的代码,把一个内存中的一个dwrod取到了edx中,这个内存的符号为SharedUserData!SystemCallStub,然后跳转到edx中的值,所以edx中应该是一个函数的地址,我们看一下这个地址是什么
(P18)
看到这个地址中存放的地址是0x7c92e510,反汇编这个地址
(P19)
看到SharedUserData!SystemCallStub保存的是快速系统调用的入口函数的地址,其中通过sysenter进入内核,进入内核之后的过程上面已经介绍过了。
现在就可以总结一下一个Windows API如果使用了系统调用之后的流程了:
无论在何处直接调用了RING3的API并且需要使用系统调用,最终都会通过Ntdll.dll这个模块来进入内核(此说法是错误的,因为Win32子系统就是个例外,Win32子系统也可以进入内核与Win32k.sys交互),例如一个函数OpenXXX,无论其在哪个DLL实现,最终都要进入Ntdll.dll这个函数中的NtOpenXXX,在该函数中的内容就是读取一个用户共享数据区中的一个变量SytemCallStub,这个变量的值就是包含实现特权级变化的控制转移代码的函数入口,其实就是ntdll!KiFastSystemCall的地址,在这个函数中使用了SYSENTER指令,可以直接进入内核的nt!KiFastCallEntry,这个函数会调用nt!KiSystemService这个函数,这个函数的任务是查找SSDT中对应系统调用号的系统调用的实现地址nt!NtOpenXXX。
本来我是想做一个推测:在XP系统中通过中断方式实现系统调用的代码仍然存在,那么我们能不能根据需要选择我们希望使用的系统调用的实现方式呢?产生这个想法的原因是Windows并没有直接把实现特权级变化的控制转移代码的函数入口硬编码到每一个API中,而是使用了一个变量—SharedUserData!SytemCallStub中,既然这样实现肯定是为了可以方便切换这个函数的地址,在现在的XP以及更新的操作系统中,这个变量中存放的是ntdll!KiFastSystemCall,就是通过快速系统调用的方式来实现系统调用,那么如果我们修改这个变量的值,使其等于ntdll!KiIntSystemCall的话,是不是就可以改变整个系统的系统调用方式为中断方式呢?关于这个猜想还我还没有做下验证,打算过几天再验证。
nt!KiFastCallEntry函数地址的获取
下面说一下nt!KiFastCallEntry这个函数,无论通过何种系统调用的实现方式,都会调用这个函数来进行系统调用,所以这个函数也就成了一是十分重要的领地,就相当于兵家必争之地,无论是安全软件还是恶意软件。但是这个这个函数地址的获取还是有一点技巧的。
在这里先说一下XXX安全卫士的获取nt!KiFastCallEntry这个函数地址的方式,因为网上有人分析过它的实现方式,并且被大量转载,所以这里只是简单的提一下。我们已知的信息是,Windows的凡是涉及系统调用的API在内核中的调用顺序是:
快速系统调用:nt!KiFastCallEntry->Nt*
中断方式系统调用:nt!KiSystemService-> nt!KiFastCallEntry->Nt*
所以为了过滤系统调用,只要Hook住nt!KiFastCallEntry这个函数就一定能拦截所有的系统调用。XXX安全卫士的做法是先Hook住了一个nt!NtSetEvent函数,当进入这个函数的时候利用栈回溯,找到当前函数的返回地址,这个地址一定是在上层函数内的,得到这个地址就以后,就可以根据特征码搜索需要Inline Hook的目标地址了。借助nt!ZwCreateFile函数来动态跟踪分析,如下图:
(P20)
很多人说这种方法巧妙,通过栈回溯找到目标函数地址域内的一地址,然后特征码匹配,为何不直接找到函数入口然后直接特征码匹配呢?栈回溯的方法固然看起来很巧妙,可是如果内核中对nt!KiFastCallEntry函数启用了FPO(帧指针优化),那这种方法就完全被挫败了,所以这种方法并不能称为完美。
网上也有很多其他方法来获取nt!KiFastCallEntry函数入口地址,有暴力搜索的,也有通过MSR寄存器的。通过MSR寄存器读取的确是一种中规中矩的方法,但是看过不少代码,都是建立在一个假设上:nt!KiFastCallEntry的段基址一定是线性地址0x00开始。这些代码都直接忽略了对MSR 174号地址内的段选择子的处理,完整的做法应该利用本文中之前的方法计算段和偏移求得nt!KiFastCallEntry的入口地址,由于汇编语言处理这些计算相对方便,所以本人用汇编实现了一个得到nt!KiFastCallEntry函数地址的函数。代码如下:
- #define IA32_SYSENTER_CS 174H
- #define IA32_SYSENTER_ESP 175H
- #define IA32_SYSENTER_EIP 176H
- ULONG GetAddressOfKiFastCallEntry()
- {
- ULONG dwAddress = 0;
- __asm
- {
- jmp func_main
- vgdtr:
- _emit 0x00
- _emit 0x00
- _emit 0x00
- _emit 0x00
- _emit 0x00
- _emit 0x00
- _emit 0x00
- _emit 0x00
- func_main:
- push eax
- push ebx
- push ecx
- push edx
- mov ecx, 0x174
- rdmsr
- mov ebx, eax //Selector offset
- sgdt vgdtr
- mov edx, vgdtr
- add edx, 0x02
- mov eax, [edx] //GDT base
- add ebx, eax //Selector base
- mov edx, ebx
- add edx, 0x07
- mov eax, [edx]
- shl eax, 24;
- mov edx, ebx
- add edx, 0x02
- mov ecx, [edx]
- and ecx, 0x00FFFFFF
- add eax, ecx //Address CodeSegment
- mov ebx, eax
- mov ecx, 0x176
- rdmsr
- add eax, ebx
- mov dwAddress, eax
- pop edx
- pop ecx
- pop ebx
- pop eax
- }
- return dwAddress;
- }
#define IA32_SYSENTER_CS 174H
#define IA32_SYSENTER_ESP 175H
#define IA32_SYSENTER_EIP 176H
ULONG GetAddressOfKiFastCallEntry()
{
ULONG dwAddress = 0;
__asm
{
jmp func_main
vgdtr:
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
_emit 0x00
func_main:
push eax
push ebx
push ecx
push edx
mov ecx, 0x174
rdmsr
mov ebx, eax //Selector offset
sgdt vgdtr
mov edx, vgdtr
add edx, 0x02
mov eax, [edx] //GDT base
add ebx, eax //Selector base
mov edx, ebx
add edx, 0x07
mov eax, [edx]
shl eax, 24;
mov edx, ebx
add edx, 0x02
mov ecx, [edx]
and ecx, 0x00FFFFFF
add eax, ecx //Address CodeSegment
mov ebx, eax
mov ecx, 0x176
rdmsr
add eax, ebx
mov dwAddress, eax
pop edx
pop ecx
pop ebx
pop eax
}
return dwAddress;
}
测试用驱动代码及结果如下图:
(P21)
(P22)
这个结果和我们用Windbg查看得到的是一样的。
这篇文章写的有点长,而且知识有点砸碎希望各位看过之后能理解。