由API-系统调用模式探析windows NT内核
0、最常见的疑惑
这个程序是怎么运行的?为什么一双击exe文件就开始运行了呢?程序和操作系统什么关系呢?
其实很多人都有这些问题,如果有人能回答,说不一定这个回答的人也不一定真正明白。其实,可以毫不夸张的说,如果你明白了从双击可执行文件到出现一个窗口的具体过程,那么你对操作系统已经算入门了。
1、运行程序的方式
在windows下运行程序的方式有两种:1、通过桌面双击可执行文件;2、从命令行输入文件名、参数来运行。第二种情况主要用来运行16位的dos程序,当然也可以32位程序。其实我们最常用的还是双击这种方式。请注意一点,排除16位的dos程序外,其他32位的程序都windows定义的可执行文件,它必须满足PE(Portable Excutable)文件的格式。而PE文件运行的环境被称作子系统(subsystem),之前的win2000支持3个子系统(Windows、POSIX、OS/2),但是XP之后就只支持Windows子系统了,而且这个子系统是运行32为的windows程序,所有也被称为Win32子系统(其实也可以通过dos虚拟机运行16位程序,这是商业化中产品兼容的体现)。
关于POSIX和OS/2,以及它们与Windows的渊源,在这里我就不说了,大家可以去搜索一下。
我们下载可以这样总结一下:满足PE格式的可执行文件在windows操作系统的Win32子系统中运行。
2、程序运行前夕
2.1、CSRSS.EXE
当你双击的可执行程序后,又发生了什么呢?我们一步一步地来。
双击可执行程序,这个双击是将发出一个消息,而这个消息会被explorer.exe程序的窗口(就是我们通常看到的桌面)捕获到,放入消息循环队列中。当explorer.exe开始解析消息时,就会调用相应的消息处理函数。当explorer.exe发现是要执行一个可执行程序时,它会请求CSRSS.EXE(Client/Server Runtime Subsystem,explorer.exe是其子进程)的帮助。CSRSS是Win32程序运行的一个运行时子系统,它是一个服务器,它要响应客户端程序的请求,为客户端的程序做一些运行环境的准备工作。CSRSS需要完成的工作主要是映射可执行映像来准备一个将要执行的新进程、创建命令行参数结构、维护一些与客户进程相关的状态等。参考Linux、WRK、NT 4.0、ReactOS和EOS,很容易联想到Linux中的execve系统调用,以及程序加载器,但是它的工作只是很少的一部分,基本上只是创建一些与进程相关的数据结构(这些数据结构的集合在很多时候,我们就叫做环境。),例如PCB、运行参数等。但它还没有真正创建一个进程,因进程必须要读到内存中,由程序加载器完成,而加载这些操作需要更底层的I/O管理器来完成。
所以,简单地说,我们双击可执行文件后,CSRSS.EXE响应了,它创建了映射该可执行程序的进程的执行环境。有了这个执行环境,系统就可以认为:这里有个新进程了。从CSRSS的功能我们也可以理解为什么它叫客户/服务运行时子系统了。
而完成这些工作所依赖的机制是窗口和消息循环机制,这个机制可以认为是win32程序设计的一个基础性机制,没有它就没有所谓的win32程序了。
2.2、NTDLL.DLL
虽然现在有了一个进程了,但是只是名义上是一个进程,还没有实际的内容。比如运行的地址空间、主线程等等。为进程开辟地址空间,就是申请内存需要I/O管理和操作的支持,创建主线程更是要进程线程管理器的直接参与。因此,下面就要涉及系统调用了。但是微软作为商家,它要保护它自己的知识产权系统调用也算,有点抠所以它要将系统调用封装一下,不要它暴露出来,所以有了NTDLL.DLL这个系统级的动态链接库。
现在的情况就是这样了,CSRSS调用NTDLL.DLL中的函数,NTDLL.DLL调用系统调用,NTDLL.DLL中的函数仅仅是个中介。从Mark Russinovich之类内核大神的文章中,我们了解到NTDLL.DLL中的函数基本上对系统调用没什么改变(函数名和参数都一样,如NtReadFile是系统调用,NtReadFile也是NTDLL.DLL中的函数,参数也一样。),甚至完全就是为了隐藏而封装的。在各种文献中,将NTDLL.DLL中的函数的这种特性成为stub(端),NTDLL.DLL这个系统DLL成为中介层。
这样,CSRSS通过NTDLL.DLL,然后NTDLL.DLL再通过系统调用真正完成进程运行环境的建立,包括:PCB(包括了地址空间)、主线程、堆栈、对象、句柄等等。其实系统调用还会调用内核函数和设备驱动,这里就不说了。
3、基本的操作系统概念
为了进一步的理解下面的内容和windows的内核,我们需要最准确地理解以下操作系统的概念。
3.1、Windows的架构

上面这张图是windows的架构示意图,从《windows internals》中摘的,我认为学windows编程、系统级编程、操作系统及相关的人,都应该好好看看这本书,非常有用。
其实我并不非常认同用这种结构来表示windows的架构(我不敢怀疑Russinovich和 Solomon)。只是有时觉得这个图让人迷惑:首先,在用户态,看不出子系统的位置和作用,让人感觉只要有DLL就可以运行用户程序了(其实差很远);再者,在内核态,用这种分层的方法确实可以表示之间的相互调用关系,但是在内核态时,大家都运行在一个空间中,基本上都是平等的,没有所有的层次之分了,区别只是你的地址和我的地址不一样罢了。但是如果没有这张图,或许我们会更迷糊,在找不到更好的说明模型的情况下。这应该是最重要最权威的参考了。
3.2、用户空间和系统空间
Windows NT是标准意义上的现代操作系统,现代操作系统有以下三个标准:1、操作系统受到保护,免受应用程序的干扰与侵袭;2、支持多进程多线程,各个应用进程之间相互隔离(进程共享的东西除外),不能干扰与侵袭彼此;3、进程在物理内存中的位置可以任意变动。第三条其实不重要了,但是第一二条的核心就是保护,即security。Windows在最初的设计目标中就要求要达到C2安全标准。
Windows平台下,我们可以拥有4GB(32位寻址)的线性空间。但是微软强制规定,低2GB归应用程序你编程时能够引用的地址范围从0-2GB(一般的可执行文件不会超过2GB);高2GB归系统即2GB-4GB空间中的地址是系统的地址,不能随便引用。这个线性地址空间的模型只适用于应用程序开发人员,应该他们不用管实际的物理地址。说不定你程序中的0x1000000处的指令就在物理地址的0xF0000000处。
一般地讲,我们将低2GB称为用户空间,高2GB称为系统空间。编程的人需要注意到这个,实际上这种划分没有其他的作用。
还有一种关于用户空间和系统空间的划分,这一般常见于操作系统方面的文章中。这时,用户空间指的是拥有用户态执行权限的所有物理地址空间的集合,系统空间指的是拥有内核态执行权限的所有物理地址空间的集合。这种划分就是从执行权限出发,对物理地址的划分。
这里的执行权限,就是能够使用的CPU指令的范围。
3.3、用户态和内核态
CPU的指令是分等级的,比如mov指令就是非常普通的指令,而jmp指令一般就需要一定的权利才能执行。这些CPU的指令的权限是怎么决定的呢,或者说通过什么来标识呢?CPU指令的权限是从硬件上实现的,实现方法是在段寄存器中使用了隐藏位,一个指令在执行时,会由硬件判断隐藏位。x86的CPU分4个权限等级:R0、R1、R2、R3。而微软的windows NT使用并实现了R0和R3,表现在数据结构上就是GDT表中的2个权限位。
我们知道GDT会指向LDT,LDT指向进程地址空间,因此GDT中的权限位就反应到了进程地址空间中的指令。从而决定了该进程地址空间的归属:要么属于用户空间,要么属于系统空间。而且我们将拥有R3权限的状态成为用户态,可以说指令是用户态指令、进程是用户态进程等等;拥有R0权限的状态自然就是内核态。
这种用户态和内核态的划分,为操作系统实现了现代操作系统标准1中的保护提供了硬件基础,而标准2中的保护,由操作系统通过数据结构和机制来实现。
3.4、int 0x2e和sysenter
用户空间中处于用户态的进程如何使用操作系统提供的服务呢?比如I/O服务。这些服务,换一句简单地话说,就是含有权限比较高的指令的函数。因此肯定会涉及到函数的调用。但是现在的问题是,我是用户态的函数,怎么可能调用你内核态的函数呢?你可以调用,但是呢,你要符合规范,不能乱调用,这些规范的接口就是API函数。
因此,API函数规定了你使用操作系统服务的方式,你只能按着API的方式来使用服务。这也算霸王条例吧,因为你只能用API,而且还只能用微软的API,而且参数你要按着API的参数来准备。确实很“霸王”啊!这就是API的本质。
从用户态到内核态的转换,是在API中实现,而且转换方式只有两种:int 0x2e和sysenter。其实什么中断、异常、陷阱等等,都是用int指令来实现的,对于我们来讲,只用关注int 0x2e就行了,0x2e是系统调用的中断号。Sysenter是快速系统调用,它不像int 0x2e那样通过堆栈来传递参数,它用寄存器来传递参数。
4、程序开始运行
如果第二节所述,我们假设通过双击可执行文件explorer.exeCSRSS.EXENTDLL.DLL系统调用内核函数这个流程,进程的各种环境都已经准备好了,而现在CPI中的IP寄存器内容正是进程主线程的地址。那么现在该程序就开始运行了。
4.1、API和系统DLL
一个应用程序可以不使用库函数或API,我就自己在自己的地址空间中捣鼓。这样也可以,但是可以想见它能完成的功能。它没有界面、没有输入输出、没有文件操作,更不可能有其他多媒体的功能,基本上它没有用。所以现在所有的程序都会使用库函数和API函数,从#include这条语句我们就能明白。
其实一部分库函数也是调用API函数来实现各种操作的,而另一部分库函数在用户空间中自己捣鼓,完成一些简单的功能供程序中用户自定义的函数(包括main)使用。
因此,你的程序要使用操作系统提供的服务,那么就必须要调用API函数。操作系统能提供什么服务呢?随便看一本操作系统方面的教材吧。
Windows提供的API分三类:1、基本服务API,包括进程线程创建、I/O操作、内存管理、同步等(其实这就是操作系统的基本功能,操作系统的教材上都是这么分章节的);2、窗口和消息API,这是建立在windows窗口和消息循环机制上的API,主要包括创建窗口、发送消息等等;3、图形API,这是微软最值得骄傲的成就,包括画图、画线等。这三类API分别都封装到kernel32.dll、user32.dll、gdi32.dll。从这些API的功能可以看得出这三个DLL的命名是非常符合逻辑的。
这三个DLL中的API会调用NTDLL.DLL中的假系统调用(前面已经说过NTDLL.DLL对真正的系统调用的封装。),但是NTDLL.DLL也是非常关键的,因为它里面的函数都调用了int 0x2e或sysenter来进入内核态,进而调用真正的系统调用。NTDLL.DLL能导出函数,所以可以直接使用NTDLL.DLL中的函数,而不用kernel32.dll、user32.dll、gdi32.dll中的API(这不是微软编程的规范)。
加入我们的的应用程序中这么一条语句,status=ReadFile(…),显然这是调用API函数ReadFile。这时CPU的控制权的转移流程是:我们启动的进程(kernel32.dll、user32.dll、gdi32.dll)NTDLL.DLLNTOSKRNL或Win32K.sys。NTOSKRL就是windows的内核映像,它里面包含了执行体(executive,里面有各种管理器,如进程管理器)、微内核、HAL。
当内核完成进程所请求的服务后,从CPU控制权从内核空间返回到用户空间,应用程序继续执行下一条语句,如if(0==status){…}。
如果在遇到API函数,执行方式如上。
4.2、Win32K.sys
Kernel32.dll提供的基本服务,由内核ntosknrl中的系统调用和内核函数来完成,但是user32.dll、gdi32.dll提供的窗口、消息和图形方面的服务,就通过NTDLL.DLL转向Win32K.sys。从.sys后缀可以看得出,它是一个驱动文件,因此可以直接和硬件打交道,所以windows在图形方面表现不错,这比Linux强。因为Linux中对图形的响应是由X Server进程完成的,而X Server是个用户空间的进程,它还需要调用底层的系统调用、内核支持函数、驱动才能完成图形操作。
可以说Win32K.sys是微软的一个骄傲。
4.3、Nt和Zw系统调用
系统调用的实现部分在ntoskrnl或win32k.sys中,它们被ntdll.dll中的函数调用;而ntdll.dll中的函数被kernel32.dll、user32.dll、gdi32.dll调用,在这些dll之上还有其他更多的dll,所有这些dll就组成了系统dll的集合,它们虽然不是内核的组成部分,但是是操作系统正常运行不可缺少的部分。
内核中的系统调用函数名很多都是以Nt为前缀,还有一部分是以Zw为前缀,如NtReadFile和ZwReadFile。但是用查看PE依赖性的程序打开ntoskrnl,发现NtReadFile和ZwReadFile的地址是一样的,而且资料显示它们的参数也一样。那到底有什么不同呢?
毛德操在《windows内核情景分析》中说,它们的不同点事Nt函数是用户态程序使用系统服务时调用的,Zw是内核态组件使用系统服务时调用的。但是我看了很多资料后,发现他的说法也不正确。在国外一些大牛写的文章中,我总结出来:Nt函数和Zw函数的主要区别在于传递的参数的验证。
Nt函数是用户态程序使用,用户程序调用它时会传递一大堆参数,但是问题在于这些参数不是不没有问题。因此Nt函数需要验证用户传递的参数的正确性,而Zw是内核态组件使用系统服务时调用的,它不用进行参数正确性的验证。其实从中可以看出一点:写操作系统的人不相信写应用程序的人。意思就是说,操作系统代码没问题,所以Zw函数的调用不用验证参数;但是应用程序不敢保证没有问题,所以要用Nt函数验证一下参数的正确性。
Russinovich说过他写过很多让操作系统崩溃(直接蓝屏)的程序,而他写这些程序的出发点就是在于对系统调用的参数缺少验证,因此在调用API函数时,故意传递一些容易出错的参数,如边界值等。结果由于NT的系统调用已经内核支持函数缺少或没有完善对参数的验证,结果让NT操作系统频频崩溃。微软发现后,用Service Pack的方式将这些漏洞填补,其实这些补丁很多都是在完善API函数和系统调用在参数方面的验证。
不知道现在通过API参数还能不能攻击NT,目前主要用缓冲区溢出的方法,不过这些就是我的能力范围之外了。
4.4、Win32子系统
Windows NT中有个Win32子系统的概念,其实我们把CSRSS、gdi32.dll等系统dll、Win32K.sys等组合起来,就构成了Win32子系统。其中CSRSS扮演的是Win32子系统中应用程序服务器的角色,是Win32子系统的用户态部分;kernel32.dll等系统dll连接Win32子系统的内核态部分,是Win32子系统用户态部分的最底层;而Win32K.sys则是Win32子系统的内核态部分。
还有一个NTDLL.DLL,是用户态的最底层,按理说它应该是Win32子系统的一部分。但是微软却认为它不是Win32子系统的一部分,可从国外非微软的开发人员那里了解到,他们认为NTDLL.DLL是Win32子系统的最底层,因此他们在调用API函数时,不调用kernel32.dll或user32.dll中的函数,而是直接调用NTDLL.DLL中的函数。这违反了微软的编程规范,但提高了效率。
5、总结
我学习NT才一段时间吧,但是NT给我的感觉始终是庞大。API-系统调用这种模式,只反映了微软一部分关于应用程序与操作系统关系的理念或看法。这种模式的特点是在API和系统调用中添加了中介层,而不是直接暴露系统调用的函数入口。这种方式是保护了内核、知识产权,还能在一定程度上对操作系统达到C2安全标准提供帮助。微软或许在最开始就不相信用户程序,所以有了NTDLL.DLL和Zw函数。
可是话说回来,系统调用只是应用程序和内核打交道的方式,并没有涉及真正的实现方法。所以以上那几千字废话只介绍了一种途径,让应用程序知道“那就是内核”,但是这在途径NT操作系统中,却升华为一种模式,反映了微软的操作系统的种种观点。
NT确实是很牛的技术,从HAL、对象、句柄、API-系统调用模式、子系统、对称多处理器等等,每一项都是能人叹服的创造力!
以后随着NT学习的深入,可能还会和大家交流一些NT的其他技术与实现。

(声明:由于文章写得草率,而且本人能力有限,有语句错误或技术性错误,望见谅。2009-9-2)