Windows内核基础
ʕ •ᴥ•ʔ ɔ:
1 Windows 内核基础
本文具体实现过程可看cataLoc’s Blog
系统内核层,又叫零环(Ring0,简称R0
,与此对应的应用层叫3环,即Ring3,简称R3
),实际上是CPU的4个运行级别中的一个。CPU设计者将CPU的运行级别从内向外分为4个,依次为R0、R1、R2、R3,运行权限从R0到R3依次降低,也就是说,R0拥有最高执行权限,R3拥有最低执行权限。CPU 设计制造商在设计之初是让R0运行内核,R1、R2 运行设备驱动,R3运行应用程序。
操作系统设计者与开发商在设计操作系统(例如微软Windows 和开源社区的Linus 编写的Linux)的时候,为了让工作变得简单,并没有使用R1和R2两个级别,而是将设备驱动运行在与内核同一个级别的R0级。在AMD64 CPU诞生之后,CPU的设计者干脆也和操作系统保持一致, 只保留了R0和R3两个级别。特权级环如图7.1 所示。
1.1 Widows 内核版本
Microsoft在操作系统领域中的发展最早开始于MS-DOS,并于20世纪80年代后期开始按两个分支发展:
- 一是基于MS-DOS的Windows 开发平台,并发展成Windows 95/98/Me这一系列操作系统;
- 另一个分支则是以Windows NT为代表的操作系统系列,经历了Windows NT 3.1/3.5/3.51/4.0、Windows 2000、Windows XP/Server 2003,一直到 Windows Vista/Server 2008、Windows 7和Windows10。
Windows NT由微软和IBM联合研制,分为微软的Microsoft OS/2 NT与IBM的IBM OS/2。协作后来不欢而散,IBM继续向市场提供先前的OS/2版本,微软则把自己的OS/2 NT的名称改为Windows NT,即第一代的Windows NT 3.1。
Windows内核(由于是从Windows NT发展而来的。也称为NT内核)从一开始就有良好的设计,其结构具备很好的可扩展性和安全性。所以,Windows 内核在20年的发展历程中一直能够很好地适应硬件的发展 ,在Windows操作系统的各个版本中并没有根本性的变化。本文介绍Windows 操作系统的基本框架,这些内容完全适用于Windows XP/Server 2003及以后的版本。
WRK
WRK的全称是“Windows Research Kernel”,它是微软为高校操作系统课程提供的可修改和跟踪的操作系统教学平台。它给出了Windows这个成功的商业操作系统的内核大部分代码,可以对其进行修改、编译,并且可以用这个内核启动Windows操作系统。可让学生将操作系统基本原理和商业操作系统内核联系起来,进一步加深对操作系统整体的理解
NT 5.2版本是一个特殊的版本,其核心代码经过简单的改编之后 已经向教育科研领域公开。这份公开源代码的内核称为Windows Research Kernel(Windows 研究内核),简称WRK。它包括了Windows内核中最重要的组件,例如内存管理器、进程和线程管理、对象管理器、缓存管理器、配置管理器、安全引用监视器和V/O管理器等。 此内核源代码可以被编译成一个EXE可执行文件,然后安装到一个Windows Server 2003 SP1(x86系统)或Windows XP64位(AMD)系统中,替换其中的内核模块。因此,如果用户改变了源代码中的实现逻辑,则替换了内核模块之后的Windows Server 2003 SP1或 Windows XP 64位系统可以运行用户的代码逻辑。如果配置了调试环境,则还可以调试WRK内核和用户的代码。WRK是2006年7月份正式对外发布的,就当时而言,它代表了最新的Windows内核技术。
1.2 重要的概念
- Windows API函数:指Windows API中已被文档化的、可被调用的子例程(函数), 例如 CreateProcess、CreateFile 和GetMessage。
- 原生的系统服务(或者系统调用):指操作系统中未文档化的、可在用户模式下调用的底层服务。例如,NtCreateUserProcess是一个内部系统服务,Windows 的CreateProcess函数调用该服务来创建新的进程。
- 例程:即函数。
- 子系统DLL:简单理解为Windows API,已文档化的API,native API。
- Native API:NT中有很多为公布的API(已文档化),习惯上大家喜欢把他们称为Native API。
- 指令流:线程。
- 陷阱处理器,是指与某个特定的中断或异常相关联的函数。
搞懂子系统 执行体 ntdll.dll、ntoskrnl.exe
2 Windows系统结构
图2.2显示了Windows基本结构。Windows 采用了双模式(dual mode)结构来保护操作系统本身,以避免被应用程序的错误所波及。操作系统核心运行在内核模式(kemel mode)下,应用程序的代码运行在用户模式(user mode)下。每当应用程序需要用到系统内核或内核的扩展模块(内核驱动程序)所提供的服务时,应用程序通过硬件指令从用户模式切换到内核模式中,当系统内核完成了所请求的服务以后,CPU控制权又回到用户模式代码。
用户模式与内核模式指的是CPU处理器的访问模式。
内核模式:它允许访问所有的系统内存和所有的CPU指令。
虽然每个Windows进程都有自己私有的内存空间,但是内核模式的操作系统和设备驱动程序代码共享同一个虛拟地址空间。虚拟内存中的每一个页面都被标记了处理器必须在什么访问模式下才可以读和/或写该页面。系统空间中的页面只有在内核模式下才可以访问,而用户地址空间中的所有页面都可以在用户模式下访问。只读页面(比如那些包含静态数据的页面)在任何模式下都是不可写的。此外,在支持不可执行(no-execute)内存保护的处理器上,Windows将包含数据的页面标记为不可执行,从而防止数据区域被无意地或恶意地当作代码来执行。
对于在内核模式下运行的组件,32位Windows对它们所使用的私有系统内存并不提供读写保护。换句话说,一旦进入了内核模式,操作系统和设备驱动程序的代码可以完全访问系统空间的内存,也可以绕过Windows的安全机制直接访问对象。因为有大量的Windows操作系统代码运行在内核模式下。
有4种用户模式进程:
- 固定的系统支持进程,如登陆进程,会话管理器进程。
- 服务进程,宿纳了windows服务,如进程管理器和假脱机服务。
- 用户应用程序,有6个类型:windows32位,windows64位,windows3.1 16位,ms-dos 16位,posix32位或者OS/2 32位。
- 环境子系统服务进程,实现了操作系统环境的部分支持。这里的环境是指操作系统展示给用户或者程序员的个性化部分。
在windows下,用户程序不能直接访问原始的windows服务,要通过一个或者多个子系统动态链接库。
Windows内核组件(组成部分)包含:
- windows执行体,包含基本的操作系统服务,如内存管理,进程和线程管理,安全性,I/O,网络,跨进程通信。
- windows内核,是由一组底层的操作系统功能构成,如线程调度,终端和异常处理分发。以及处理器同步。提供了一组例程和基础对象。执行体的其他部分利用这些例程和对象实现更高层次的功能。
- 设备驱动程序,硬件设备驱动程序,也包含文件系统和网络驱动程序。其中硬件设备驱动程序将用户的I/O函数调用转化为特定的硬件设备请求。
- 硬件抽象层,指一层特殊代码,它把内核,设备驱动程序和windows执行体其他部分跟与平台相关的硬件差异隔离开来。
- 窗口和图形系统:实现了图形用户界面函数。
Windows子系统是Windows系统中一个不可缺少的组成部分,它与系统内核一起构成了用户应用程序的执行环境。Windows的原始设计是一个支持多环境子系统的操作系统,除了Windows子系统作为它的原生环境子系统,它还支持POSIX和OS/2环境子系统,为UNIX类应用程序和OS/2应用程序提供一个仿真执行环境。 随着Windows操作系统的发展,自Windows XP以后,只有Windows子系统随系统一起发行。 Windows 子系统既有内核模式部分(图形和窗口管理),也有用户模式部分。用户模式部分包括一个单独的子系统进程和一组链接到各个应用进程中的系统DLL。
2.1 CPU 模式
在Intel x86处理器上,段描述符有一个 2位长度的特权级:0表示最高特权级,3表示最低特权级。Windows 只使用0和3两种特权级(在有些资料上分别称为0环和3环)。通常特权级0表示CPU处于内核模式(kermel mode),3表示用户模式(user mdoe)。在任何时刻,处理器总是位于这两种模式之一。
处理器有许多指令只有在特权级0的模式下才可以使用,例如I/O指令、操纵内部寄存器(如GDT、IDT、MSR)的指令等。
操作系统保护状态:
- 当处理器位于用户模式时,它处于一种相对隔离的状态:能够执行的指令是受限制的,能够访问的内存也是受限制的。且越过这些限制,就会引发处理器异常,因而操作系统可以捕获到这些异常,并决定处理器是否继续执行。因此,操作系统可以有足够的能力来保护自己免受用户模式代码的影响。(保护模式)
- 当处理器位于内核模式时,这一层保护不复存在,任何一个未被捕捉和处理的指令错误都会引起系统崩溃。
内存访问权限:在Windows中,当处理器位于不同模式下时,它可以访问的内存地址空间也是不一样的。
- 用户模式下,处理器只能访问当前进程的地址空间(有时也称为用户地址空间);
- 内核模式下,处理器不仅可以访问当前进程的地址空间,还可以访问系统地址空间。 内核模式下的代码和数据都是共享的,所有的进程一旦其指令流进人到内核模式下,则系统地址空间中的代码和数据都是相同的(有个别例外)。
一个指令流(即线程)在执行时,在以下情况下会发生模式切换:
- 用户模式代码触发了异常,则控制流进入到内核模式,内核中的异常处理函数可以决定该控制流是否继续执行;
- 用户模式代码在执行时,被一个中断打断 (软中断或硬中断),则控制流进入特权模式,等中断处理例程完成以后,它若调用
iret/iretd
指令,则控制流恢复到用户模式下,执行特殊的模式切换指令,例如Intel x86的sysenter
指令,从用户模式切换到内核模式。而为了从内核模式切换到用户模式,通常简单地使用sysexit
、iret/iretd
这样的指令即可。由于系统空间是所有进程共享的,所以,任何一个进程在执行内核模式的代码时,实际上是在使用操作系统的服务。
在Windows体系结构中,内核模式向上有一个执行体API,尽管它并非文档化的API,但对于应用程序而言,这便是系统服务。Windows将这些系统服务组织成了一张表,称为SDT(Service Descriptor Table,服务描述符表)。
2.2 用户模式
(User mode)
2.2.1 Windows子系统
早期的Windows版本支持三个环境子系统:
- OS/2
- POSIX
- Windows
到了Windows XP以后,只有Windows子系统(或Win32)随Windows操作系统起发行,而且,在Windows系统中,即使是没有交互用户登录的服务器系统,Windows 子系统也是必须运行的。相反,另外两个子系统被配置成按需启动。
Windows子系统的两个关键功能部件:窗口管理和GDI(Graphics Device Interface,图形设备接口)。
Windows对应用程序的支持是通过Windows的环境子系统来做到的,任何一个用户应用程序都运行在特定的子系统环境中。
我们可以这样简单地理解:
- Windows 子系统是Windows操作系统不可分割的一部分,它在Windows内核的基础上,为应用程序提供了一个图形用户界面(GUI)环境;
- OS/2和POSIX则是为了兼容OS/2和UNIX应用程序而提供的模拟环境。
Windows子系统中既有用户模式部分,也有内核模式部分。
内核模式部分:核心是win32k.sys,虽然它的形式是一个驱动程序,但实际上它并不处理I/O请求,相反,它向用户代码提供了大量的系统服务。从功能上讲,它包含两部分:窗口管理(Window manager)和图形设备接口(GDI)。
- 其中窗口管理部分负责收集和分发消息,以及控制窗口显示和管理屏幕输出;
- 图形设备接口部分包含各种形状绘制以及文本输出功能。
用户模式部分:包括Windows子系统进程csrss.cxe以及一组动态链接库DLL。
- Csrss.exe进程主要负责控制台窗口的功能,以及创建或删除进程和线程等。
- 子系统DLL则被直接链接到应用程序进程中,包括kernel32.dIl、user32.dIl、gdi32.dIl和advapi.dll等,负责实现已文档化的Windows API函数。除了有些可以直接在用户模式中完成以外,很多API函数需要调用执行体API或win32k.sys模块提供的系统服务。
应用程序通常并不直接使用操作系统提供的系统服务,而是通过调用系统DLL所提供的API函数,来间接地使用各种系统服务。Windows 子系统也使用了类似的模块结构,供应用程序直接调用的API函数位于一组子系统DLL中,这些子系统DLL再根据需要调用内核模式组件(win32k.sys)的功能。
子系统Windows结构如下图(图来自《Windows内核原理与实现》):
Windows子系统的组件为:
用户模式部分:
- Windows子系统进程 Csrss.cxe
- 子系统DLL
内核模式部分:
- 内核模块 Win32k.sys
- 图形设备驱动程序
- Windows子系统进程 Csrss.cxe(运行在用户模式):Windows子系统进程维护了所有属于该子系统的进程和线程的列表,并且设置进程的异常端口和调试端口,以便接收该进程中发生的异常和调试事件。类似地,当线程或进程退出或终止时,Windows子系统进程也会被通知到,从而维护子系统内部信息的一致性。
它包含以下支持:
- 控制台窗口。
- 创建和删除进程和线程。
- 支持16位虚拟DOS机(VDM,Virtual DOS Machine)进程。
- 其他一些函数,比如GetTempFile、DefinedDosDevice、ExitWindowsEx,以及少量自然语言支持函数。
.子系统DLL:子系统DLL,例如user32.dIl、advapi32.dIl、gdi32.dll和kermel32.dlI,它们实现了已经文档化的Windows API函数,它们将已经文档化的Windows API函数,转译成Ntoskrnl.exe和Win32k.sys(两个内核模块)中恰当的且绝大多数未文档化的内核模式系统服务调用,甚至与环境子系统进程通信。
- 内核模块 Win32k.sys:虽然它的名称像是一个驱动程序,但实际上,win32k.sys并不遵从I/O管理器定义的程序模型,而仅仅是Windows内核的扩展而已。Win32k.sys包含两大功能组成部分:
- 窗口管理器(Window Manager):负责控制窗口显示、管理屏幕输出、收集来自键盘、鼠标和其他设备的输人,以及将用户消息传递给应用程序。
- GDI(Graphics Device Interface):这是一个针对图形输出设备的函数库,包含了有关线、文本和图形绘制,以及操纵各种图形的函数。
- 图形设备驱动程序:这是一些与硬件相关的显示驱动程序、打印驱动程序,以及视频小端口驱动程序。
用户应用程序并不直接调用Windows的系统服务,而是通过一个或者多个子系统DLL来进行。这些库导出的接口都有很好的文档说明,凡是链接(LoadLibrary函数)到该子系统的程序都可以调用这些接口。例如,Windows子系统DLL (比如Kernel32.dIl、Advapi32.dlI、 User32.dlI 和Gdi32.dI1)实现了Windows API函数。SUA子系统DLL (Psxll.dIl) 实现了SUA API函数。
2.2.2 子系统DLL
简单理解成子系统DLL为已经文档化的Windows API函数。
应用程序调用子系统DLL中的某个函数时,可能有下述三种情况之一 :
- 该函数完全是在该子系统DLL中实现的,在用户模式下运行。换句话说,该函数并没有给环境子系统进程(Csrss.exe)发送消息,也没有调用Windows执行体系统服务。该函数是在用户模式下完成的,运行的结果被返回给调用者。此类函数的例子有
GetCurrentProcess
(它总是返回-1,在所有与进程相关的函数中,-1被定义为代表当前进程)和GetCurrentProcessld
(对于一个正在运行的进程,进程ID不会改变,所以此进程ID可以从某个缓存的地方获取到,从而避免要调用至内核中)。 - 该函数要求调用Windows执行体一次或者多次。例如,Windows的
ReadFile
、WriteFile
函数分别要调用底层内部的(且无文档的) Windows I/O系统服务NtReadFile
和NtWriteFile
。 - 该函数要求在环境子系统进程(Csrss.exe)中完成某些工作(环境子系统进程运行在用户模式下,负责维护那些在其控制下运行的客户应用程序的状态)。在这种情况下,该函数通过消息的形式向环境子系统发送客户机/服务器请求,从而让子系统执行某个操作。然后子系统DLL等待应答,收到应答之后再返回调用者。
有些函数可以是以上列出的第2和第3项的组合,比如Windows的CreateProcess
和CreateThread
函数。
从应用程序的角度来看,它通过给子系统DLL发出的服务请求(即API调用)是如何被满足的。首先,由于这些子系统DLL被加载(LoadLibrary)到应用程序进程中,所以,这些服务请求是直接的函数调用(同一进程地址空间中跨模块的函数调用)。当子系统DLL接收到一个函数调用以后,根据该函数功能的复杂程度,可能有以下的处理方式:
- 直接由子系统DLL包揽所有工作。这一类函数总是在用户模式下完成,不涉及处理器模式切换。这类函数的典型例子有:
RtInRect
、IsRectEmpty
这样的简单函数,无须内核模块或Windows子系统进程的介人即可完成;GetCurrentProcess
函数,简单地返回一个伪句柄值(-1)代表当前进程;GetCurrentProcessld
函数,可以从一个缓存的数据结构中获得当前进程的ID,无须每次调用都进入到内核中,因为进程ID在进程的生命周期中保持不变。
- 需要调用Windows内核一次或多次。 在这种情形下,可能存在一次或多次模式切换,子系统DLL或者通过ndll.dll调用到Windows执行体,或者通过win32k.sys注册的系统服务表(
KeServiceDescriptorTableShadow
)调用到win32k.sys 中。这类API函数的例子有:ReadFile
和WriteFile
函数,调用底层的NtReadFile
或NtWriteFile
系统服务;PostMessage
和BitBIt
这样的窗口管理和GDI函数,调用win32k.sys 中的NtUserPostMessage
或NtGdiBitBIt
来完成其功能(它们也可能会调用Windows执行体函数,比如为了获取用以保护共享资源的锁)。
- 需要Windows子系统进程( Csrss.cxe)的协助来完成其功能。在这种情况下,子系统DLL向csrss.exe进程发送一个请求(以LPC消息的形式),然后等待应答消息,直至收到应答并完成所有功能之后再返回调用者。这类API函数最典型的例子是
CreateProccess
和CreateThread
,它们需要通知子系统进程,以维护子系统环境中进程和线程的状态。
以上三种可能的执行方式仅仅代表了子系统DLL在执行一个服务请求时可能的执行路径,并非简单地将API函数分成三类。
其他有关Windows子系统的窗口管理器和GDI详细细节,可参考《Windows内核原理与实现》9.2章节。
2.2.3 Ntdll.dll
2.2.2中所提到的应用程序调用子系统DLL时的第2种情况,即应用程序会调用到内核一次或多次,因为Windows API无法直接调用到内核服务,这个时候就会使用到ntdll.dll,ntdll.dll充当应用程序和内核服务的桥梁,即从R3到R0的桥梁。
Windows内核为用户模式代码提供了一组系统服务,供应用程序使用内核中的功能。 应用程序通常并不直接调用这些系统服务,而是通过一组系统DLL,最终通过ntdll.dll切换到内核模式下的执行体API函数中,以调用内核中的系统服务。Ntdll.dlI是连接用户模式代码和内核模式系统服务的桥梁。对于内核提供的每一个系统服务, 该DLL都提供个相应的存根函数,这些存根函数的名称以“Nt” 作为前缀。
Ntdll.dlI是一个特殊的系统支持库, 主要用于子系统DLL。它包含两种类型的函数:
- (执行体)系统服务分发存根(stub),这些存根函数(例程)会调用Windows执行体的系统服务。(此部分函数供应用程序从R3转入R0使用,这部分函数以
Nt
为前缀)。 - 系统内部支持函数, 供子系统、子系统DLL以及其他的原生映像文件使用。(此部分函数不支持模式R3到R0的切换)。
第一组函数为Windows执行体系统服务提供了接口,在用户模式下可以通过这些接口函数调用Windows执行体的系统服务。这样的函数超过了400个,比如
NtCreateFile
、NtSetEvent
等。 如前所述,这些函数的大多数功能可以通过Windows API来访问得到(然而,有些函数则不然,它们仅被用于操作系统内部)。对于每一个这样的函数,Ntdll包含 了一个同名的入口点。函数内部的代码包含了与处理器体系架构相关的模式切换指令,通过该指令可转换到内核模式下,从而调用系统服务分发 器(system service dispatcher)。系统服务分发器在检验某些参数以后,再调用真正的内核模式系统服务,其中包括Ntoskrml.exe内部的实际代码。对于NtdlI中的内部(系统)支持函数,比如:
- 映像加载(以
Ldr
开头的函数); - 堆管理器;
- Windows子系统进程通信函数(以
Csr
开头的函数); - 一般性的运行库例程(以
Rtl
开头的函数); - 对用户模式调试调试函数(以
DbgUi
开头的函数); - Windows事件跟踪的支持函数(以
Erw
开头的函数); - 用户模式异步过程调用(APC,Asynchronous Procedure Call)分发器和异常分发器;
- C运行库(CRT)例程的子集,仅限于字符串和标准库中的一些例程,比如memcpy、 strepy,ioa,等等。
- 映像加载(以
本文主要讲第一种(具体参考《Windows内核原理与实现》8.1.2)
在Windows的系统结构中,内核提供的服务都通过ntdll.dll模块被应用程序使用。 Windows应用程序调用一组系统 DLL中的API函数,间接地通过ntdll.dll中的存根函数来调用内核提供的系统服务。
Windows内核中的执行体层暴露了大量的功能供应用程序使用,那么,应用程序如何调用这些功能呢?譬如,NtCreateFile
是Windows内核中的“创建文件”服务例程(函数),它运行在处理器的内核模式下,而应用程序的代码运行在用户模式下,所以,应用程序为了调用此“创建文件”服务,必须将处理器从用户模式切换到内核模式下。当然,模式切换工作并不需要由应用程序自已来完成,Windows 提供了一个系统模块ntdll.dll,已经实现了所有系统服务的模式切换工作。这一模式切换依赖于硬件体系结构。
ntdll.dll是Windows系统从ring3到ring0的入口。位于Kernel32.dll
和user32.dll
中的所有win32 API 最终都是调用ntdll.dll中的函数实现的。ntdll.dll中的函数使用SYSENTRY
进入ring0,函数的实现实体在ring0中。
ntdll.dll中的大部分函数都是在MSDN中找不到描述的,因为这些函数介于Windows API与内核API之间,微软并
未公开全部的内核函数。
Windows中应用程序与与Windows内核打交道的过程如下图:
Windows应用程序调用系统DLL中的函数,这是大量已经文档化的API函数;这些系统DLL函数可能又进一步调用ntdll.dll中的系统函数,这些系统函数要么是操作系统提供的支持函数,要么是一些系统服务存根( stub)。系统服务存根函数利用模式切换指令进入到内核模式,调用内核提供的系统服务来完成应用程序的请求。举例:
- Windows应用程序调用Windows API函数
CreateFile
来创建文件,此API函数实际上是CreateFileW
,位于kernel32.dll模块中。 CreateFileW
函数又进步调用ntdll.dll中的NtCreateFile
函数。Ntdll.dll中的NtCreateFile
函数是一个存根函数,它只是简单地将创建文件的请求转交给内核中的NtCreateFile
函数。为了做到这一点,它通过ntdll.dll中的KiIntSystemCall
或KiFastSystemCall
函数执行int 2e或sysenter指令,以便切换到内核模式下,然后由内核模式的系统服务分发函数KiSystemService
来调用NtCreateFile
系统服务。- 待
NtCreateFile
系统服务执行完成以后,KiSystemService
调用KiServiceExit
函数,最终通过iretd或sysexit指令返回到用户模式ntdll.dl模块中。
2.3 内核模式
(Kernel mode)
2.3.1 内核结构
Windows内核分为三层:
- 执行体层
- 内核层
- 硬件抽象层
执行体层和内核层位于同一个二进制模块中,即内核基本模块,其名称为ntoskrnl.exe
- 硬件抽象层(Hardware Abstraction Layer,简称HAL):与硬件直接打交道的这一层称为,这一层的用意是把所有与硬件相关联的代码逻辑隔离到一个专门的模块中,从而使上面的层次尽可能做到独立于硬件平台。
- 内核层:HAL之上是内核层,有时候也称为微内核(micro-kernel),这层包含了基本的操作系统原语和功能,如线程和进程、线程调度、中断和异常的处理、同步对象和各种同步机制。
- 执行体层:在内核层之上则是执行体(executive)层,这一层的目的是提供些可供上层应用程序或内核驱动程序直接调用的功能和语义。Windows内核的执行体包含一个对象管理器,用于一致地管理执行体中的对象。
执行体层和内核层位于同一个二进制模块中,即内核基本模块,其名称为ntoskrnl.exe。内核层和执行体层的分工是:
- 内核层实现操作系统的基本机制,而所有的策略决定则留给执行体。
- 执行体中的对象绝大多数封装了一个或者多个内核对象,并且通过某种方式(比如对象句柄)暴露给应用程序。
Windows内核的详细组成结构如下图:
Ntdll.dll
Windows内核为用户模式代码提供了一组系统服务,供应用程序使用内核中的功能。应用程序通常并不直接调用这些系统服务,而是通过一组系统DLL,最终通过ntdll.dll切换到内核模式下的执行体API函数中,以调用内核中的系统服务。Ntdll.dlI是连接用户模式代码和内核模式系统服务的桥梁。对于内核提供的每一个系统服务, 该DLL都提供个相应的存根函数,这些存根函数的名称以“Nt” 作为前缀,例如NCreateProcess NtOpenFile 和NtSetTimer。
另外,ntdll. dll还提供了许多系统级的支持函数:
- 以
Nt
为前缀:ntdll. dll存根函数的名称 - 以
Ldr
为前缀:映像加载器函数 - 以
Csr
为前缀:Windows子系统进程通信函数 - 以
Dbg
为前缀:调试函数 - 以
Etw
为前缀:系统事件函数 - 以
Rt
为前缀:一般的运行支持函数 - 字符串支持函数等。
执行体API函数
执行体API函数接收的参数来自于各种应用程序,因此,为了确保系统的健壮性, 以及抵抗来自用户模式的恶意攻击,所有的执行体API函数必须保证参数的有效性。这意味着它们必须在恰当的时刻检查参数的值,若是指针的话,还必须保证调用者可以访问指针所指的内存。通常,执行体系统服务函数会在其开始处,对所接收的参数逐一探查它们的可访问性。
2.3.2 内核及Ntoskrnl.exe
Windows内核的执行体包含一个对象管理器,用于一致地管理执行体中的对象。执行体层和内核层位于同一个二进制模块中,即内核基本模块,其名称为ntoskrnl.exe。执行体层是ntoskrnl.exe的上层部分,内核层是ntoskrnl.exe的下层部分。
内核层和执行体层的分工是:内核层实现操作系统的基本机制,而所有的策略决定则留给执行体。执行体中的对象绝大多数封装了一个或者多个内核对象,并且通过某种方式(比如对象句柄)暴露给应用程序。
Windows的内核(层)按照面向对象的思想来设计,它管理两种类型的对象:分发器对象(dispatcher object)和控制对象。
- 分发器对象实现了各种同步功能,这些对象的状态会影响线程的调度。Windows内核实现的分发器对象包括:
- 事件(event)
- 突变体(mutant)
- 信号量(semaphore)
- 进程(process)
- 线程(thread)
- 队列(queue
- 门(gate)
- 定时器(timer)
- 控制对象被用于控制内核的操作,但是不影响线程的调度,它包括:
- 异步过程调用(APC)
- 延迟过程调用(DPC)
- 中断对象等。
关于Windows研究内核
Windows并非一个开放源代码的操作系统,Micosoft 开放了一份以Windows XP x64和Windows Server 20003 SPI为基础的内核源代码,它可以编译和运行,作为教育科研机构的教学实践和研究的平台使用,称为WRK(Windows Research Kernel,Windows研究内核)。除了这份源代码本身,WRK还提供了其他一些材料。主要包含以下:
- WRK内核源代码,涉及进程、线程、内存管理、执行体、对象管理器、缓存管理器、本地过程调用(LPC)、注册表、I/O管理器、安全引用监视器,以及线程调度、APC(异步过程调用)、DPC(延迟的过程调用)、中断以及异常处理等。随源代码起提供的还有相应的编译工具,因此,无须额外的编译器即可将WRK编译成Windows Server 2003 SP1的可执行内核。
- NT设计文档。这是一组早期的文档,尽管其内容已不完全适用于现在的Windows操作系统以及WRK中的代码,但是,通过阅读这些文档一方面可以清楚地理解 Windows NT背后的原始设计思想,另一方面也可以看出Windows在这十多年中是如何发展和进化的。这些文档涵盖了Windows操作系统的方方面面,甚至包括文件系统设计大纲和内核的调试结构等。
其他内容于《Windows内核原理与实现》2.3.1中。
Windows的内核模块文件是ntoskrnl.exe,位于Windws\Sytem32目录下,它包含了Windows体系结构中的执行体和内核(或微内核)部分。WRK提供的源代码可以编译得到这一内核模块文件。
WRK包含了编译ntoskrnl.exe内核模块所需要的绝大部分代码,未公开部分的代码主要包括即插即用设备管理、电源管理、设备驱动程序检验器和虚拟DOS机的实现。为了编译WRK源代码以得到实际可运行的内核模块,缺失的这部分被以二进制目标代码的形式包含在了WRK中,该目录还包含了其他些需要静态链接的目标文件。
内核模块内部的每个组件都提供了一些接口函数供其他组件调用,也有一些函数供该组件内部使用。有一些组件内部函数也有规律可循:前缀第一个字母后面跟一个i
,或者在前缀后面跟一个p
。
i
:internal
,即内部的;p
:private
,即私有的;Ki
:微内核;Mi
:内存管理器的内部函数;Halp
:HAL的内部函数;Psp
:进程和线程管理组件的内部函数;Iop
:I/O管理器的内部函数。
表2.3列出了一些常用的标识性前缀。
2.3.3 执行体
Windows执行体是Windows内核体的上层接口,包含了基本的操作系统服务。这些系统服务由不同的组件组成,执行体包含以下组件:
- 进程和线程管理器。负贵创建进程和线程,以及终止进程和线程。在Windows中,对于进程和线程的底层支持是在内核层提供的,执行体在内核层的进程和线程对象的基础上,又提供了一些语义和功能。
- 内存管理器。此组件实现了虚报内存管理,既负责系统地址空间的内存管理,又为每个进程提供了一个私有的地址空间,并且也支持进程之间内存共享。内存管理器也为缓存管理器提供了底层支持。
- 安全引用监视器(SRM,Security Reference Monitor)。该组件强制在本地计算机上实施安全策略,它守护着操作系统的资源,执行对象的保护和审计。
- I/O管理器。它实现了与设备无关的输入和输出功能,负责将IO请求分发给正确的设备驱动程序以便进步处理。
- 缓存管理器。它为文件系统提供了统一的数据缓存支持, 允许文件系统驱动程序将磁盘上的数据映射到内存中,并通过内存管理器来协调物理内存的分配。
- 配置管理器。它负责系统注册表的实现和管理。
- 即插即用管理器。它负责列举设备,并为每个列举到的设备确定哪些驱动程序是必需的,然后加载并初始化这些驱动程序。当它检测到系统中的设备变化(增加或移除设备)时,负责发送恰当的事件通知。
- 电源管理器。它负责协调电源事件,向设备驱动程序发送电源I/O通知。当系统电源状态变化时,通知设备驱动程序处理设备的电源状态。即插即用设备的管理和电源的管理也可以看做是IO管理器的扩展功能。
- ……
执行体包含以下5种类型的函数:
可在用户模式下调用的导出函数。这些函数称为系统服务,对这些函数的调用接口位于Ntdll模块中,即通过Ntdll导出。可分为:
- 应用程序可通过Windows API来间接地调用这些函数;
- 应用程序无法通过Windows API来调用的函数,直接链接ntdll.dll来完成。如LPC(Local Procedure Call,本地过程调用)函数、各种查询函数(如
NtQueryInformation<Xxx>
),以及一些专用的函数,比如NtCreatePagingFile
等。
可通过
DeviceIoControl
函数来调用的设备驱动程序函数。这为从用户模式到内核模式提供了一个通用的接口,因而在用户模式下可以调用设备驱动程序中并不与读或者写操作关联的函数。并且在Windows DDK中有关于这些函数的文档。只能在内核模式下调用的导出函数。已经被文档化的,这些函数可以被设备驱动程序调用。
在内核模式下调用,未被导出的函数,供执行体组件之间相互调用,但未被文档化的函数。这包括执行体内部使用的一组支持函数。
属于一个组件的内部(模块)函数。
此外,执行体还包含4组主要的支持函数,供以上这些执行体组件调用。差不多有1/3的支持函数可以在Windows DDK中找到相应的文档,因为设备驱动程序也要调用它们。这4类支持函数如下所列:
- 对象管理器。它负责创建、管理和删除Windows执行体对象,以及用于表达操作系统资源的抽象数据类型,比如进程、线程和各种同步对象。
- LPC设施。LPC设施负责在同一台机器上的客户进程和服务器进程之间传递消息。 LPC是RPC(Remote Procedure Call,远程过程调用,关于网络上客户进程和服务器进程之间通信的工业标准)的一个优化版本。
- 一组运行时库函数。其功能广泛,涵盖字符串处理、算术运算、数据类型转换以及安全结构处理等。
- 执行体支持例程。例如系统内存分配(换页内存池和非换页内存池)、互锁的内存访问,以及对两种特殊类型同步对象(资源和互斥体)的支持。
2.3.4 系统机制
- 系统机制
- 陷阱分发
- 系统服务分发
- 陷阱分发
Windows操作系统提供了一些基本的机制供内核模式的组件(比如执行体、内核和设备驱动程序)使用。本节将介绍下面的系统机制中的陷阱(其他机制可查看《深入解析Windows操作系统第6版》第3章):
- 陷阱分发, 包括
- 中断;
- 延迟的过程调用(DPC);
- 异步过程调用(APC);
- 异常分发;
- 系统服务分发。
- 执行体对象管理器。
- 同步,包括自旋锁、内核分发器对象、等待是如何实现的,以及一些专门针对用户模式的同步原语(它们不同于传统的同步对象,可避免切换至内核模式)。
- 系统辅助线程
- 其他的机制,比如Windows全局标志。
- 高级的本地过程调用(ALPC)。
- 内核事件跟踪。
- Wow64。
- 用户模式调试。
- 映像加载器。
- 超级管理器(Hyper-V)。
- 内核事务管理器(KTM)。
- 内核补丁保护(KPP)。
- 代码完整性。
2.3.5 陷阱分发
中断和异常是导致处理器转向正常控制流之外代码的两种操作系统条件。硬件或者软件都可以检测到这两种条件。
术语陷阱(trap)指的是这样种机制:当异常或者中断发生时,处理器捕捉到一个执行线程,并且将控制权转移到操作系统中某一固定地址处。在Windows中, 处理器会将控制权转给陷阱处理器(trap handler)。所谓陷阱处理器,是指与某个特定的中断或异常相关联的函数。
陷阱分发:陷阱处理器用以区分和确认硬件或者软件产生的陷阱属于中断还是异常或是系统服务。然后转由相应的中断分发、异常分发或系统服务分发去处理后续事项。同时分发任务由相应的处理器去完成,处理器实际上就是一些函数(例程)。
图3.1显示了一些能激活陷阱处理器的条件:
导致陷阱的条件有如:中断、异常、系统服务调用(分发)、DPC及APC等。
关于中断和异常:
中断:中断分为异步中断和同步中断。
- 异步中断,也叫硬中断、外部中断、中断。是指由于外部设备事件所引起的中断。中断由外因引起。
- 同步中断,也叫内中断、异常。是指由于 CPU 内部事件所引起的中断。异常由CPU本身原因引起。同时,软中断属于异常中的一种。同步中断就属于异常。
硬中断就是外部设备(比如IO,时钟设备)的中断,软中断就是INT指令,异常就是CPU内部的中断,比如除零异常。
硬件的中断可分为上半部分和下半部分,下半部分也叫做软中断。上半部在屏蔽中断的上下文中运行,用于完成关键性的处理动作,然后产生软中断。下半部就是软中断处理程序,对时间要求不是非常紧急,通常比较耗时的,因此不在硬中断服务程序中执行。
异常的分类: 处理器探测到的异常、编程异常(也称软中断)。
- 处理器探测到的异常:
- 故障(Fault)
- 陷阱(Trap)
- 异常终止(Abort)
- 编程异常(也称软中断):
- int指令
对于中断和异常的处理,主要是利用中断描述符表IDT中的中断描述符。
陷入中断(trap interrupt), 也称软中断(soft interrupt),系统调用(system call)简称trap:在程序中使用请求系统服务的系统调用而引发的事件。陷入是由在 cpu 上运行的当前进程导致的。如:
- 除零错;
- 地址访问越界等。
参考:
- 《中断,异常,陷阱,软硬中断,同异步中断??》
- 《同步中断(异常)和 软中断》
- 《硬中断,软中断,信号,异常》
- 《面试考点——中断和异常的区别》
- 《信号和中断的比较 + 中断和异常的比较》
- 《异常、中断、陷阱》
中断和异常的区分(内核按照下面的方法来区分中断和异常。):
- 中断是一个异步事件(可以在任何时候发生),并且与处理器当前正在执行的任务毫无关系。中断主要是由I/O设备、处理器时钟或者定时器产生的,并且可以被启用(打开)或者禁用(关闭)。
- I/O设备;
- 处理器时钟;
- 定时器。
- 异常是一个同步条件,它往往是一个特殊指令执行的结果。(中止(abort)-- 比如机器检查,是一种典型的不与指令执行有关联的处理器异常。)在同样的条件下用同样的数据第二次运行程序可以重现原来的异常。异常的例子有(内核把系统服务调用看作是异常,不过,从技术上讲,它们是系统陷阱。):
- 内存访问违例;
- 特定的调试器指令;
- 除零错误;
- 系统服务调用。
无论是硬件还是软件都能够产生异常和中断。例如,总线错误异常是由于硬件问题引起的,而除零异常则是软件错误的结果。同样,I/O设备可以产生中断,内核本身也可以发出软中断(比如 APC 或者 DPC)。
(硬中断)当硬件异常或者中断产生时:
- 内核模式下:处理器将在被中断的线程的内核栈中记录下足够多的机器状态信息,因而可以回到控制流中的该点处继续执行,就好像什么也没有发生过一样。
- 用户模式下:如果该线程在用户模式下执行,那么 Windows 就切换到该线程的内核模式栈。然后,Windows 在被中断线程的内核栈上创建一个陷阱栈帧(trap frame),并且把线程的执行状态保存到陷阱帧里。陷阱帧是一个线程的完整执行环境的一个子集,在内核调试器中输入
dtnt!_trap_frame
就可以看到陷阱帧的定义。(即从用户模式切换到内核模式,同时栈帧也从用户模式切换到内核模式)。
(软中断,即异常)
内核在处理软中断时,或者将软中断当作硬中断处理的一部分,或者当线程调用与软中断相关的内核函数时以同步方式(即异常)进行处理。
陷阱分发:
在大多数情况下,内核安装了前端陷阱处理函数(陷阱分发处理器),在内核将控制权转交给与特定陷阱相关的处理函数之前或者之后,由这些前端陷阱处理函数来执行一些常规的陷阱处理任务(陷阱分发)。例如:
- 如果陷阱条件是一个设备中断,则内核的硬件中断陷阱处理器(函数)将控制权转交给一个由设备驱动程序提供给该中断设备的中断服务例程(ISR,interrupt service routine)。
- 如果陷阱条件是因为调用一个系统服务而引发的,那么,通用的系统服务陷阱处理器将控制权转交给执行体中指定的系统服务函数(系统服务分发器)。
- 内核也会为它不希望看到的或者根本不处理的陷阱安装陷阱处理器。这些陷阱处理器一般的做法是执行系统函数
KebugCheckEx
,当内核检测到可能导致数据破坏的有问题行为或者不正确行为时,该函数会停止计算机
2.3.6 系统服务分发
系统服务分发(System Service Dispatching):内核中接收到合法的应用程序(用户模式的ntdll)调用时,根据ntdll.dll
中的系统服务存根函数指定的系统服务号,在内核模式下,KiSystemServiceRepeat
根据此系统服务号,知道该调用内核中哪个系统服务例程,以及从用户栈拷贝多少数据到内核栈中,然后执行该系统服务。
- 接收系统服务号的是系统服务分发器,然后根据系统服务号决定系统去执行哪一个系统服务。
处理器一旦被中断,就会询问中断控制器以获得此中断请求(IRQ, Interrupt request)。中断控制器将该 IRQ 转译成一个中断号,利用该编号作为索引,在一个称为中断分发表(IDT,Interrupt dispatch table)的结构中找到一个IDT项,并且将控制权传递给恰当的中断分发器(例程)。在系统引导的时候,Windows会填充IDT,其中包含了指向负责处理每个中断和异常的内核器(例程)的指针。
Windows将硬件IRQ映射至IDT中的中断号上,同时利用IDT来为异常配置陷阱处理器,然后进行陷阱分发。
硬中断和异常都会产生中断号,直接对应于IDT中的表项。
在x86和x64处理器上,所有的异常(包括系统服务调用)都有预定义的中断号,直接对应于IDT中的表项,而每个表项又指向某个特定异常的陷阱处理器。表3.6显示了x86定义的异常,以及为它们分配的中断号。因为IDT中前面的表项是用于异常的,所以,硬件中断被分配了后面的表项。
系统服务分发:
系统服务分发顺序:
$$中断号45 –> 查IDT表第46项 –> 系统服务分发器 –> 从EAX获取系统服务号 –> 查SDT –> 得到XXX系统服务。$$
CPU从用户模式切换到内核模式,需要一个进行一个陷阱处理,能触发陷阱的是ntdll.dll中的int 2E
和sysenter
两个指令,产生一个中断号45。Windows填充IDT的46号表项,使其指向系统服务分发器。
部分1:int 0x2E 和 Sysenter
int 0x2esysenter在Pentium I之前的x86处理器上,Windows使用int 0x2e指令(十进制是46),它会导致一个陷阱。
- 该陷阱导致执行线程转换到内核模式中,并且进入系统服务分发器。
- 在处理器的EAX寄存器中传递的数值参数指明了所请求的系统服务号,由ntdll.dll的存根函数指定服务号。
- EDX寄存器指向调用者传递给该系统服务的参数列表地址。
- 要回到用户模式,系统服务分发器需要使用
iret
指令(即中断返回指令)。
在x86 Pentium II 及更高级的处理器上,Windows使用专门的sysenter指令(32位使用sysenter指令,x64使用syscall指令,IA64使用epc指令。),这是InteI特别为快速系统服务分发而定义的指令。
- 为了支持这一指令, Windows在引导时将内核的系统服务分发器例程的地址保存在与该指令相关联的3个MSR(machine specific register)寄存器中。
- 该指令一旦被执行,就会导致变换到内核模式下,并且执行系统服务分发器。
- 系统服务号是通过处理器的EAX寄存器来传递的,而EDX寄存器则指向调用者参数的列表地址。
- 为了返回到用户模式,系统服务分发器通常执行
sysexit
指令。
ntdll.dll中的KiIntSystemCall
函数执行int 2e
,KiFastSystemCall
执行sysenter
指令,以便切换到内核模式下,然后由内核模式的系统服务分发函数KiSystemService
或KiFastCallEntry
(系统服务分发器)来调用系统服务函数。待系统服务函数执行完成以后,KiSystemService
调用KiServiceExit
函数,最终通过iretd
或sysexit
指令返回到用户模式ntdll.dll模块中。
- 用户模式下:
SystemCall
-->KiIntSystemCall
-->int 2e
;SystemCall
-->KiFastSystemCall
-->sysenter
。
- 内核模式下:
KiIntSystemCall
-->int 2e
-->KiSystemService
--> 系统服务函数 -->KiServiceExit
-->iretd
--> 返回用户模式;KiFastSystemCall
-->sysenter
-->KiFastCallEntry
--> 系统服务函数 -->KiServiceExit2
-->sysexit
--> 返回用户模式。
SystemCall
和SystemCallReturm
成员:
- 这是两个函数地址,
SystemCall
成员指示了从用户模式切换至内核模式的函数地址;SystemCallReturn
成员指示了从内核模式返回至用户模式的函数地址。 - 当处理器支持快速系统调用时,SystemCall成员指向
KiFastSystemCall
函数,SystemCallReturn
成员指向KiFastSystemCallRet
函数;否则,SystemCall
成员指向KiIntSystemCall
函数。
在切换至内核模式以前,edx寄存器指向用户栈中的某个位置。显然,edx的用意是向内核模式的系统服务函数传递有关参数的信息。内核中哪个函数被执行呢(指定哪个系统分发器)?
- 如果是由
int 2e
指令进入的,则KiSystemService
(系统服务分发器)函数获得控制权,这是在IDT的0x2e
项中指定的; - 如果是借由
syenter
指令跳转过来的,则KiFastCallEntry
(系统服务分发器)函数获得控制权,这是由MSR寄存器IA32_SYSENTER_EIP
指定的。
部分2:原先模式
系统服务分发器将调用者的参数从线程的用户模式栈中复制到内核模式找中(所以,当内核在访问参数时,用户不能改变它们),然后执行该系统服务。内核知道要从栈中复制多少字节,因为它使用了第二个表, 称为参数表SSPT。参数表是一个字节数组(而并非像服务分发表那样是一个指针数组),每一项描述了要复制的字节数。在64位系统上,Windows通过一个称为系统调用表缩紧(system call table compaction)的过程,将这一信息实际编码在服务表内部。如果传递给一个系统服务的参数指向了用户空间中的缓冲区,那么,内核模式的代码在复制数据到这些缓冲区中,或者从缓冲区中复制数据以前,必须要先探查这些缓冲区是否是可以访问的。只有当线程的原先模式(previous mode)属性被设置为用户模式,才会执行缓冲区探查工作。
原先模式:是内核在执行陷阱处理器时保存在线程中的一个值(内核或用户),代表了这一进来的异常、陷阱或系统调用是从哪个特权级别(R3/R0)进来的。作为一项优化措施:- 如果一个系统调用来自于驱动程序或内核本身,则对参数的探查和异常捕获可以忽略,所有的参数都假定指向有效的内核模式缓冲区(而且,访问内核模式数据也是允许的)。调用者已经在内核模式下了,不需要
中断
或sysenter
操作CPU已经在正确的特权级上了,而且,驱动程序和内核一样, 应该只能直接调用所请求的函数。- 但如果直接像调用API一般直接调用
NtOpenProcess
之类的系统服务函数时,内核保存的原先模式值仍然是用户模式(进内核之前当然是用户模式咯~),但又检测到传递来的地址是一个内核模式地址(因为在当前内核模式下调用),于是会导致调用失败(STATUS_ACCESS_VIOLATION
)。
- 但如果直接像调用API一般直接调用
- 如果原先模式为用户模式,在给系统服务传递的参数指向了用户空间缓冲区时,内核模式代码在操作该缓冲区前会检查是否可以访问该缓冲区。
部分3:SSDT 和 Shadow SSDT
ntdll.dll中的系统服务存根函数调用int 2e
或sysenter
产生中断(中断号),同时存根函数指定一个系统服务号。一个存根函数对应一个系统服务号。
系统服务分发过程中有两个重要的系统服务分发表,在 NT 4.0 以上的 Windows 操作系统中(Windows 2000),默认就存在两个系统服务描述表,这两个调度表对应了两类不同的系统服务,这两个调度表为:
SSDT:KeServiceDescriptorTable
ShadowSSDT:KeServiceDescriptorTableShadow
KeServiceDeseriptorTable
定义了Ntoskrnl.exe
中实现的核心执行体系统服务。KeSericeDescriptorTableShadow
包含了在Windows子系统的内核模式部分Win32k.sys
中实现的Windows USER和GDI服务。- 针对Windows执行体服务的系统服务分发指令位于系统库ntdll.dll中。子系统DLL调用 Ntdll.dll中的函数来实现其已文档化的函数。只有Windows USER和GDI函数例外,在这些函数中,系统服务分发指令是直接在User32.dII和Gdi32.dll中实现的,没有涉及Ntdll.dll。
- 在32位和IA64版本的Windows 上,当Windows线程第一次调用Windows USER或GDI服务时,该线程的系统服务表的地址被改变成指向个包含Windows USER和GDI服务的表。KeaddSystemerviceTable 函数使Win32k.sys可以增加一个系统服务表。
- win32k.sys只有在GUI线程中才加载,一般情况下是不加载的,所以要Hook KeServieDescriptorTableShadow的话,一般是用一个GUI程序通过IoControlCode来触发。
Windows 操作系统共有4个系统服务描述符。其中只用了两个,第一个是SSDT,第二个是ShadowSSDT。SSDT与ShadowSSDT的结构如下(参考《Undocument Windows 2000 Secretes》第二章):
1 | // KSYSTEM_SERVICE_TABLE 和 KSERVICE_TABLE_DESCRIPTOR |
说明:
KeServiceDescriptorTable
由ntoskrnl.exe导出和KeServiceDescriptorTableShadow
没有导出,二者都是数组;系统使用的基本 SSDT,即
KeServiceDescriptorTable[0]
,KeServiceDescriptorTable[1]
元素不使用。是在KiInitsystem
函数中被初始化的,此 SSDT 的原始数据分别来自于内部变量KiserviceTable
、KiserviceLimit
和Kiargumenttable
,其中Count
成员被初始化为NULL
,参见下图。KeServiceDescriptorTableShadow
是一个内部数组,它的第 2 个元素为ShadowSSDT,即KeServiceDescriptorTableShadow[1]
表项,专门用于 Windows 子系统,其他的表项与KeServiceDescriptorTable
完全相同。KeServiceDescriptorTable[0]
为SSDT,但是不使用。
SSDT、SSPT表都属于
KeServiceDescriptorTable
结构的第一和第四个元素。Shadow SSDT属于KeServiceDescriptorTableShadow
结构的第二个元素。SSDT的函数是Native API。SSDT是
KeServiceDescriptorTable
的第一个元素ServiceTableBase
,且为一个数组或指针,指向ntoskrnl.dll中系统函数的入口地址。某个系统服务函数的地址的计算方法为:
$$Address = KeServiceDescriptorTable.ServiceTableBase + 4 * 系统服务号$$
KeServiceDescriptorTableShadow
包含4个子结构,其中第一个就是ntoskrnl.exe ( native api ),不用,我们真正需要获得的是第二个win32k.sys (gdi/user support),第三个和第四个一般不使用。两者的区别是,
KeServiceDescriptorTable
仅有ntoskrnel一项,KeServieDescriptorTableShadow
包含了ntoskrnel以及win32k。一般的Native API的服务地址由KeServiceDescriptorTable
分派,gdi.dll/user.dll
的内核API调用服务地址由KeServieDescriptorTableShadow
分派。SSDT中系统服务函数的个数随着Windows发展在不断增加,且不同版本中的系统服务函数对应的系统服务号一般不同。
1)对照KeServiceDescriptorTable结构,Windbg查看其结构:
1 | lkd> dd KeServiceDescriptorTable |
2)对照KeServiceDescriptorTableShadow结构,Windbg查看其结构:
1 | kd> dd KeServiceDescriptorTableShadow |
同时还发现,在XP系统下,KeServiceDescriptorTableShadow表位于KeServiceDescriptorTable表下方,偏移0x40处。
结论:
ShadowSSDT在KeServiceDescriptorTableShadow[1]中第二个元素,而KeServiceDescriptorTableShadow[0]第一个元素为SSDT。
参考:
由于KeServiceDescriptorTableShadow表属于未导出,因此我们需要定位地址。
定位未导出函数和结构的思想就是利用已导出函数和结构,暴力搜索内存空间。
方法一、依据KeServiceDescriptorTable的地址和两者之间的偏移
方法二、搜索KeAddSystemServiceTable导出函数
方法三、搜索线程的ServiceTable指向
方法四、MJ提出的搜索有效内存地址
部分4:系统服务号
系统服务号用来定位所要寻找的系统服务表的函数。
系统服务号只有低13位是有用的
- 下标12:判断去查服务表,0去查第一张表SSDT;1去查第二张表ShadowSSDT
- 下标0~11:表内索引,函数地址表的索引,范围0H ~ FFFH。
- SSDT函数服务号范围0x00000000 ~ 0x00000FFF;
- ShadowSSDT服务号范围0x00001000 ~ 0x00001FFF。
部分5:系统服务分发
以函数WriteFile函数为例,从R3到R0的调用过程:
- 如图3.17所示,位于Kernel32.dll中的Windows WriteFile函数导入并调用
API-MS-Win-Core-File-L1-1-0.dll
中的NtWriteFile函数,这是一个MinWin重定向DLL; - 这里的NtWriteFile函数又调用
KernelBase.dll
中的WriteFile函数,这才是真正的实现所在。 - KernelBase.dll.WriteFile函数对子系统相关的参数做了检查以后,再调用
Ndll.dll
中的NtWriteFile
函数,然后NtWriteFile函数又执行适当的指令以引发一个系统服务陷阱并且把代表NtWriteFile
的系统服务号传递过去。 - 系统服务分发器(即Ntoskml.exe中的
KiSystemService
函数)然后调用真正的nt!NtWriteFile
来处理该IO请求。 - 对于Windows USER和GDI函数,系统服务分发器调用Windows子系统的可加载内核模式部分(Win32k.sys)中的函数。
总结为:
Kernel32.dll.WriteFile --> API-MS-Win-Core-File-L1-1-0.dll.NtWriteFile --> KernelBase.dll.WriteFile --> Ndll.dll.NtWriteFile --> KiFastSystemCall --> sysenter --> KiFastCallEntry(系统服务分发器) --> 查找SSDT
过程如下:
上图参考《SSDT 及SSDT Shadow 完全解析》(可下载)
部分6:Nt* 和 Zw*
nt!Zw*函数是nt!Nt*的一个stub(存根)函数,只是mov系统调用号到eax中,转而直接调用nt!KiSystemService去从SSDT中找到相应号码的函数再调用之,真正的实现都在Nt*函数中。
ntdll!Zw*
仅仅是ntdll!*
函数的别名而已- 内核模式下Nt系列API将直接调用对应的函数代码,而Zw系列API则通过KiSystemService,SSDT最终跳转到对应的NT函数代码。
- 两种不同的调用对内核中previous mode(原先模式)的改变,如果是从用户模式调用Native API则previous mode是用户态,如果从内核模式调用Native API则previous mode是内核态。previous为用户态时Native API将对传递的参数进行严格的检查,而为内核态时则不会。
- 调用用户模式Nt API时不会改变previous mode的状态,调用内核模式Zw API时会将previous mode改为内核态,因此在进行Kernel Mode Driver开发时可以使用内核模式Zw系列API可以避免额外的参数列表检查,提高效率。
- 内核模式下Zw*函数会把PreviousMode设置为KernelMode 然后再调用Nt*函数,因此在Nt*函数中就不会进行参数检查。而如果直接调用Nt*函数的话 , 必须自己将PreviousMode设置为KernelMode,否则PreviousMode很可能仍然是UserMode,这样的话Nt*函数就会认为对它的调用来自用户态,从而做一些检查,这时就会产生问题了。因此要自己调用Nt*的话必须先将PreviousMode设为KernelMode。
- R3下无论如何调用,均无法绕过SSDT HOOK,R0下若在驱动中直接调用Nt系列函数,调用Nt*可以绕过SSDT HOOK。
参考: