对操作系统有所了解的大概都听说过用户模式和内核模式,我们电脑中的大多数程序,都属于用户程序,处于用户模式,其代码只能访问 0~2G的用户空间,如果想访问 2G~4G系统空间的内容,就必须进入内核模式。换句话说,用户模式和内核模式之间仿佛间隔了一道高墙,用户模式的代码如果想拥有更大的权利,就必须穿过这道高墙。以用户模式调用的ReadFile函数为例,要从文件(背后隐藏的是硬盘)中读取数据,就不可避免的要进入内核模式,使用系统空间的硬盘驱动,它通常会调用如NtReadFile的系统调用进入内核完成任务。本文将重点讲述Windows是如何利用系统调用帮我们实现穿墙之术的。
一些基础知识
1)R3与R0
提到穿墙之术,我们就要将墙两边的情况摸清楚。Intel将CPU的运行状态分为四种:0级、1级、2级和3级。Windows和Linux不约而同的都只使用了0级和3级,窃以为其原因是如果使用4级的话,会大大增加系统的复杂度,效果未必见得有多好,似乎得不偿失。0级权利大,3级权利小,如图1所示。0级和3级对执行权限、内存访问范围和出错的影响都存在重大的区别。0级对应内核模式,内核的代码和数据都处于该模式下,可访问的内存范围为0~4G的空间,而3级则对应用户模式,可访问的内存范围为0~2G空间。通常3级下的程序出错,只需要简单的将进程停掉就可以了,而如果在0级出现问题,我们看到的只能是蓝屏。实际上,特权的概念与我们生活中的意思并无太大区别,越是核心的,掌握的资源越多,其出错导致的后果也就越严重。
2)全局描述符(Global Description Table,GDT)
如果要将全局描述符完全讲清楚,要引入N个门的概念,N个跳来跳去的关系,读者的大脑可能在进入正题前就有了快进的冲动。我们将抓住重点,只讲述与系统调用有关的一些基础知识。
事情要从Windows的寻址方式说起,Windows采用段页结合的寻址方式,比如类似001b:00414d05的地址,冒号前面的001b是一个16位的选择符,冒号后面的是一个32位的虚拟地址。顾名思义,选择符就类似于一个索引,索引对应着表,这个表就是全局描述符表(当然还有局部描述符表,就不多说了)。好,我们看看选择符和全局描述符表的庐山真面目,再来看看两者结合后是如何帮助我们完成寻址任务的。段选择符的结构图2所示。在段选择符中,前13位是真正的索引,指定了一个段在段描述符表中的编号,因为是13位,所以最多指定2^13=8192个段。第2位的表指示位说明了此段位于全局描述表(0)还是局部描述表(1)。第0-1位指明了当前特权级,表示该段是内核模式(0)还是用户模式(3)。
选择符的结构比较简单,下面我们看看全局描述符表的结构。前面提到段索引是 13位的,所以我们的这个表里也最多描述8192个段,而对于每个段,使用图3中的8字节结构进行描述。
我们看重点,在这8字节的结构中,分3段记录了共32个比特的基地址,这个基地址告诉大家,我所描述的段的起始地址在这里。在Windows中,全局描述符表的头几项是固定的,如图4中所描述的GDT,第0项空闲,第 1项描述内核态的 CS(code segment,代码段),第 2项描述的是内核态的 SS(stacksegment,堆栈段),第3项描述的是用户态的CS,第4项描述的是用户态的SS。另外需要补充一点的是,Intel专门有个GDTR寄存器,存放GDT表在内存中的起始地址。有了这些,我们再看看001b:00414d05这个地址是如何被解析的。001b变为二进制的形式,如段选择符中的内容,前面的 11表示取全局描述符表中的第 3项,因此是用户 CS(代码段),后面的 11表示该段的内容处于用户层,通过用户 CS描述符中的内容确定段的基地址,之后与001b:00414d05中的偏移 00414d05相加,得到最后的 32位线性地址。事情很简单,现在我们只要记住Windows采用的是段页式的寻址方式,一个地址以段地址:偏移地址的形式标记。
3)穿墙的跳板:共享页面
我们时常会有这样的需求,如果有一些变量或者函数指针在内核初始化时就已经固定了,现在需要每个进程都能在用户模式下访问到,如何实现呢?Windows采用共享页面的方式实现,即将这部分内容映射到每个进程的用户空间去,如图 5所示。以 0x7ffe0000(用户空间)起始的页面与0xffdf0000(内核空间)起始的页面实际上对应着相同的物理页面,每个应用程序加载时,都会将这个页面加载到固定的地址0x7ffe0000上,这样每个用户进程都可以直接访问这些在内核初始化期间就已经固定下来的内容。这个被同时映射的页面在系统地址空间称为KI_USER_SHARED_DAT,在用户空间称为MM_SHARED_USER_DATA_VA,包含了两个重要的成员变量:所有应用程序进入内核的唯一入口 SystemCall和相反方向的出口SystemCallReturn。
两条指令sysenter/sysexit
下面进入本文正题:系统调用。首先需要明确一下我们的目标:系统调用的穿墙之术究竟做了什么?其实就是完成了模式转换(用户模式到内核模式)。具体到操作,就是用户代码段与内核代码段(R3 CS←→R0 CS)的切换,用户堆栈段与内核堆栈段(R3 SS←→R0 SS)切换,用户程序指针与内核程序指针的切换(R3 EIP←→R0 EIP),用户堆栈指针到内核堆
栈指针的切换(R3 EIP←→R0 EIP)。
很多介绍系统调用的文章,一般都是将int2E与sysenter/sysexit指令同时进行讲解,先讲前者,再介绍后者。根据我的经验,一般硬着头皮读完 int 2E的堆栈变换,就会有高海拔缺氧的感觉,到了 sysenter/sysexit指令,就头晕眼花恨不得草草了事。实际上,sysenter/sysexit指令是在PetiumⅡ之后就支持的,并且sysenter/sysexit指令执行的效率要比int2E高很多,因此目前电脑中运行的机制都是使用sysenter/sysexit指令的,所以本文将主要介绍Windows是如何利用sysenter/sysexit指令实现穿墙之术的。
Intel提供的 sysenter/sysexit指令与以往不同,不涉及到堆栈的操作。因此,这两条指令不会自动把用户空间的堆栈指针保存在系统空间堆栈上,甚至也不将返回地址压入堆栈中,这些工作都需要操作系统配合完成,所以我们的讲解将分为Intel(硬件指令)为我们做了什么和Windows(软件代码)为我们做了什么。
下面介绍三个专门用于快速模式切换的寄存器,一个段选择符 IA32_SYSENTER_CS,指定了特权级0的代码段选择符,两个偏移量指针IA32_SYSENTER_ESP和IA32_SYSENTER_EIP。IA32_SYSENTER_ESP是内核栈指针的32位偏移,IA32_SYSENTER_EIP是目标例程的32位偏移。
好像有些眼熟,CS、EIP、ESP不正是我们要完成模式切换的四大内容之三吗?说的很对,独缺 SS。联想到前面讲述的,在 Windows中,内核 SS与内核 CS相邻,将IA32_SYSENTER_CS+8(还记得我们的段选择符的结构吗?实际上就是段索引+1)就能够得到内核SS的段选择符了。从另一个角度说,Intel的这种设计,节省了IA32_SYSENTER_SS、IA32_SYSENTER_RING3_CS和IA32_SYSENTER_RING3_SS三个寄存器,迫使操作系统在设计全局描述符表时,必须将内核 CS、内核 SS,以及后面还会提到的用户 CS、用户 SS的顺序固定排列。
1)Sysenter指令
有了IA32_SYSENTER_CS、IA32_SYSENTER_ESP和IA32_SYSENTER_EIP这三个寄存器,我们的任务好像变得简单了很多,不妨直接祭出 Sysenter指令的内部逻辑,当系统执行Sysenter指令,其背后完成了以下动作:
①将IA32_SYSENTER_CS和IA32_SYSENTER_EIP分别装载到CS和EIP寄存器中;
②将IA32_SYSENTER_CS+8和IA32_SYSENTER_ESP分别装载到SS和ESP寄存器中;
③切换到特权级0;
④清除eflags中的VM标志(虚拟8086模式);
⑤执行目标例程(实际上就是从现在的CS:EIP地址上开始执行)。其流程如图6所示,一切都很明了,不多说了。
2)sysexit指令
有了sysenter的基础,我们先把sysexit指令的内部逻辑和流程图罗列如图7所示,当执行sysexit指令时,完成了以下的动作:
①将IA32_SYSENTER_CS+16(用户CS)装载到CS寄存器;
②将EDX寄存器中的指针装载到EIP寄存器中;
③将IA32_SYSENTER_CS+24(用户SS)装载到SS寄存器中;
④将ECX寄存器中的指针装载到ESP寄存器中;
⑤切换到特权级3;
⑥执行EIP寄存器中指定的用户模式代码。
IA32_SYSENTER_CS+16与IA32_SYSENTER_CS+24,如前所述,是选择符(注意不是指针),分别用于获得全局描述符表中的用户CS段描述符以及用户SS段描述符。而为了正确返回用户空间,在步骤②和步骤④中使用了EDX和ECX,分别赋予EIP和ESP寄存器。不要问为什么,Intel就是这样规定的,反正你执行sysexit指令,我就将EDX和ECX放入EIP和ESP寄存器,而如何将进入内核空间前,用户空间的EIP和ESP保存,并在执行sysexit指令前放入EDX和ECX,则是操作系统(软件)的事了!
Windows的工作
Windows说:Intel老大,你就给了我两个指令,我需要完成参数的传递,定义内核模式和用户模式的入口例程,顺利完成sysenter/sysexit切换过程中EIP与ESP的设置,任务量巨大!下面来看看Windows是怎么做的。
首先在内核初始化时,会看到如下的代码:
Ke386WrMsr(IA32_SYSENTER_CS,KGDT_R0_CODE,0); Ke386WrMsr(IA32_SYSENTER_ESP,(ULONG)KeGetCurrentPrcb()-àDpcStack,0); Ke386WrMsr(IA32_SYSENTER_EIP,(ULONG)KiFastCallEntry,0);
即分别赋予三个快速系统调用寄存器初值,IA32_SYSENTER_CS被赋予KGDT_R0_CODE,即我们前面提到的为0x8,Windows中所有的内核代码段都共用这个描述符,实际计算得到的段基地址总是为 0。IA32_SYSENTER_ESP被赋予 KeGetCurrentPrcb()→DpcStack,而DpcStack指向一个独立的内核堆栈;IA32_SYSENTER_EIP被赋予KiFastCallEntry函数的地址,换句话说,当应用程序在用户模式下执行sysenter指令时,会跳转到KiFastCallEntry函数执行。
其次,系统在启动的时候会判断CPU是否支持快速系统调用功能,如果支持的话,将MM_SHARED_USER_DATA_VA页面的SystemCall成员赋予KiFastSystemCall函数地址,SystemCallReturn成员赋予KiFastSystemCallRet函数地址。
准备工作做完了,我们以ntdll文件中NtReadFile函数的执行为例:
Ntdll!NtReadFile: Mov eax,b7h //函数调用号 Mov edx,MM_SHARED_USER_DATA_VA+SystemCall Call [edx] Ret 24h 其中 EDX寄存器被赋予MM_SHARED_USER_DATA_VA+SystemCall,这应该就是 KiFastSystemCall函数地址,那么这个函数又做了什么呢? KiFastSystemCall: Mov edx,esp Sysenter KiFastSystemCallRet: Ret
很简单,就是将当前的用户模式ESP赋予EDX之后,执行Sysenter指令。进入内核模式后,如果想取得用户模式的所有参数,只需要直接movesp,edx就可以得到R3的堆栈了,完全不用拷贝,就能直接得到所有的参数。而KiFastSystemCallRet就是一个简单的返回函数。
通过前面的讲解知道,sysenter指令完成了内核模式 CS:EIP和 SS:ESP的设置,此时由于EIP=KiFastCallEntry函数地址,进入了KiFastCallEntry函数的地盘,这个函数又做了什么呢?为了保存进入前用户模式的一些信息,顺利返航,这段函数会执行一系列的压栈动作,将用户模式SS(0x23)、用户模式ESP(目前放于EDX中)、EFLAG、用户模式代码段选择符(0x1b)、用户模式EIP(此时为KiFastSystemCallRet)依次压入堆栈,如图8所示。
之后内核会通过一个叫做 SSDT(系统服务分发表)的东西,找到目标函数地址执行,我们会在将来的文章里专门讨论SSDT,在此就不赘述了。上述整体流程如图9所示。
当系统服务例程执行完毕之后,我们要准备返回了。如果例程检查到应该回到用户空间,则跳转到FastExit函数。FastExit函数包含下列代码:
Pop edx ;将EIP=KiFastSystemRet赋予EDX Add esp,4 ;跳过CS And dword ptr[esp],0xfffffdff ;设置EFLAG Popf ;赋予EFLAG Pop ecx Sysexit ;将ESP赋予ECX ;执行Sysexit指令
参考图8堆栈中的内容,就不难理解Windows为什么在这里让EDX和ECX分别被赋予返回用户空间的程序指针和堆栈指针,下面我们就可以放心执行sysexit指令了。此时程序会跳转到KiFastSystemCallRet函数,这个函数实际上就是一个ret语句,注意此时程序跳转到哪里:是函数Ntdll!NtReadFile中Call [edx]之后的Ret 24h,还是KiFastSystemCall函数中Sysenter指令之后的KiFastSystemCallRet标号呢?很显然,Sysenter指令并没有压栈动作,Call[edx]有压栈,而执行ret指令就是取堆栈中的内容作为返回地址,因此程序不会再执行sysenter指令后的代码,而是进入Ntdll!NtReadFile函数,直接执行ret24h继续返回。穿墙之术的返回流程如图10所示。
小结
本文对利用sysenter/sysexit指令实现快速系统调用的方式进行了讲解,最重要的是把两点弄清楚,第一点是sysenter/sysexit指令执行时究竟做了哪些动作,第二点是为了配合这些指令,Windows做了哪些精巧的设计,比如堆栈的变化,EDX的变化.(完)