第一章 Windows内核概述
第一章 Windows内核概述
这一章节描述了Windows内核知识中最重要的几个概念,这些话题在这本书之后会有更详细的描述,那些会与当前的主题密切相关。要确保你理解这个章节的概念,因为这些概念构成了任何驱动甚至用户底层模式的基础。
在这章中:
- 进程(processes)
- 虚拟内存(Virtual Memory)
- 线程(Threads)
- 系统服务(System Services)
- 系统架构(System Architecture)
- 句柄与对象(句柄和对象)
进程
进程是一个正在运行程序的容器和管理器,这经常被引用的术语“process runs”是不正确的。进程不会运行 - 进程负责管理。 线程是执行的代码并且在技术上运行。从一个更高的角度来讲,一个进程拥有如下的几条:
- 一个可执行程序,它包含着原始代码与数据,其被用于在进程中执行代码;
- 一个私有的虚拟内存,被内存中的代码以任何理由分配内存;
- 一个主令牌(a primary token),它是一个存储着进程默认的安全上下文,被用于在线程中执行代码(除非线程通过模拟采用不同的令牌);
- 一个存储着可执行对象的私有句柄表,例如事件,信号量或者文件;
- 一个或多个可执行线程,一个正常用户模式的进程被一个线程所创建(执行 classic main/ WinMain 函数).一个没有线程的用户进程大多数是无用的,在正常环境下会被内核所销毁。
这些进程的基本概念如下图 1- 1
一个进程是唯一被它的进程ID所标识的,只要内核进程存在,进程ID是保持独一无二的。一旦进程被销毁,这相同ID可能被新进程所拒绝."认识到一个可执行文件本身不是进程的唯一身份"是非常重要的。例如,这儿有五个notepad.exe的实例运行在同一时间。每一个进程有它自己的地址空间,线程,句柄表以及它独一无二的ID。这五个进程是由相同的文件(notepad.exe)所映射的,有着相同的初始化代码和数据。1-2展示了系统快照所显示的任务管理器细节 - 展示了五个notepad.exe实例,每一个有它自己的属性。
虚拟内存
每一个进程有它自己的虚拟,私有,线性的内存,这些地址空间一开始是空的(或者接近于空,因为可执行文件和ntdll.dll首先被映射进去,紧接着更多子系统的Dlls)。一旦主线程(main)开始执行,内存更有可能被分配,更多的Dlls被加载。这地址空间是私有的,这意味着其他进程无法直接访问它。这地址空间从0开始(虽然从技术上来讲,这最开始的64KB地址不能以任何方式被分配),一直到一个最大位 (其取决于进程和操作系统的位数),如下:
- 对于32位的进程和操作系统,这进程地址默认为2GB;
- 对于32位的进程和32位设置了用户地址增加的操作系统(LARGEADDRESSAWARE 标志在PE头部),这进程地址空间可以达到3GB(依赖于正确的设置)。为了得到额外的地址空间,这程序被创建时必须有一个 LARGEADDRESSAWARE链接器标志在它的头上。如果没有,这仍然将被限制在2GB。
- 对于64位进程(运行在一个64位操作系统上),这个地址空间为8TB(Windows 8 或者更早期)或者128TB(Windows8.1或之后)。
- 对于运行在64位系统上的32位进程,如果有LARGEADDRESSAWARE标志,其空间为4GB,否则仍然为2GB。
每一个进程有它自己的地址空间,这将让任何进程的地址空间是相对的,而不是绝对的。例如,当尝试判断0x20000的内容时,这地址本身是不够的;这个地址空间所相对的进程必须被指明。
这个进程本身是被称为虚拟的(virtual),这指的是这里地址与一个正确的物理地址(RAM)之间存在一个间接的关系。在一个进程中的缓冲区可能被映射到物理地址,或者它可能暂时存储在一个文件中(比如一个页文件)。这词"virtual"指的是“从一个执行的角度来看,这不需要知道这内存是否被存储进RAM中”,如果这内存确实被映射进RAM中,这CPU可以直接访问数据;否则,这CPU将引发一个页异常,这将使内存管理器的页错误处理程序从正确的页文件中拷贝到RAM中,在缓冲的PTE中进行所需要的更改,之后CPU再次尝试运行一下程序。1-3展示了两个进程的物理内存的映射。
内存管理的单位被称为页(page),与内存相关的属性总是在一个页的颗粒度上,比如它的保护。一个页的大小是由CPU所决定的(在一些处理器中,它是可配置的),在任何情况下,内存管理器必须依照其这样做。Windows所有支持的架构中,正常情况下页大小是4KB(被称之为小页)。
除了正常(小)页表。Windows也支持大页表,大页表的大小时2MB(x86/x64/ARM64)和4MB(ARM).这是基于使用PDE来映射大页而不是使用PTE。这可以更快速地进行转化,最重要的是,其更好的使用了TLB缓存技术(Translation Lookaside Buffer) - 由CPU维护的近期所转换的页。在大页的情况下,一个单独的TLB目录能显著地映射更多的内存,相比小页来讲。
页状态
在虚拟内存中的每一个页都会有如下三种状态:
- Free - 这种页不允许以任何方式被分配;这什么也没有。任何试图访问该页的都会造成一个异常访问冲突。在新创建的进程中大部分页都是Free属性的。
- Commited - Free的相反,一个已分配的页,可以在没有任何保护属性下成功访问(例如,写入一个只读页会造成访问冲突)。提交的页常常是有RAM或一个文件(例如一个页文件)所映射的。
- Reserved - 这个页没有被提交,但是这个地址范围被保留,可能用于以后的提交。从CPU的角度来看,它是等同于Free - 任何尝试访问的都会造成一个异常访问冲突。然而,尝试使用VirtualAlloc函数(或者NtAllocateVirtualMemory,这种比较原生的Api)来进行空间的分配将不会在保留区分配。在后面的章节“Thread Stacks” 中描述了使用保留内存来维护连续虚拟地址空间的比较经典的例子。
系统内存
内存空间的低地址被进程所使用。然而一个指定的线程正在执行时,它所附属的进程从0地址到上一节描述的上限都是可见的。操作系统,然而,必须也位于某个地方 - 该地方是操作系统所支持的高地址空间,其详细描述如下:
- 在32位没有设置用户空间增长的操作系统中,这操作系统位于高2GB的虚拟空间中,从地址0x80000000到0xFFFFFFFF。
- 在32位配置了用户空间增长的操作系统中,这操作系统位于剩余的地址空间中。例如,如果这系统被配置成每个进程3GB的用户空间(最大值),这OS占据最高的1GB(从0xC0000000到0xFFFFFFFF),受这种空间地址减少的是文件缓存系统。
- 在64位Win8,Server 2012以及更早的操作系统上,OS占据了高8TB的虚拟地址空间。
- 在64位Win8.1,Server 2012 R2 和之后的操作系统上,OS占据了高128TB的虚拟空间。
系统空间不是相对于进程的 - 毕竟,相同的“系统”,相同的内核,相同地服务于每一个进程的进程(例外的是一个基于会话的系统内存,但是在本章中并不重要)。系统中的任何地址是绝对的而不是相对的,因为它对于每个进程内容看起来是相同的。当然,从用户模式去实际地访问系统空间会导致一个异常访问冲突。
系统空间是内核本身、硬件抽象层(Hareware Abstraction Layer HAL)和内核驱动加载时所存在的地方。因此,内核驱动自动地保护来免于用户模式的直接访问。这也意味着他们存在一个整个系统的潜在影响。例如,一个内核驱动泄漏内存,这块内存不能被释放即使驱动卸载。用户模式的进程,另一方面,无法泄漏任何东西当超出其生命周期。这内核是负责关闭和释放一个死进程的任何私有东西(所有句柄关闭,全部的私有内存被释放)。
线程
执行代码的真正实体是线程,一个线程包含进一个进程,使用进程所暴露的资源来工作(例如虚拟空间和内核对象的句柄),一个线程拥有如下最重要的信息:
- 当前访问模式,用户或者内核。
- 执行体内容,包含着CPU寄存器和执行状态。
- 一个或两个栈,用于本地变量分配和调用管理。
- 本地存储线程(TLS)数组,它提供了使用统一语义来存储私有线程数据的方法。
- 基础优先级和一个当前(动态)的优先级。
- 处理器亲核性(Affinity),其表示该线程被允许运行在哪个处理器上。
一个线程有如下几个最重要的状态:
- Running - 当前的执行代码运行在一个(本地)处理器上。
- Ready - 等待执行的分配,因为所有相关的处理器都是在忙碌或者不可使用的。
- Waiting - 在处理前等待事件发生,一旦事件发生,这线程将变为就绪状态。
1-4展示了这些状态的状态图。括号里的数字表示了状态序号,这可以用一个例如Performance Monitor工具所查看。注意就绪状态有一个被称为 Deferred Ready的同级状态,与之非常类似,是实际存在的用于最小化某些内部锁定的。
线程栈 Thread Stack
当执行时,每一个线程都有一个它所使用的的栈,被用于存储本地变量,传递函数的参数以及在函数调用前存储的函数调用地方。一个现场至少有一个栈位于系统(内核)空间,它是非常小的(默认是12KB在32位操作系统,24KB在64位操作系统)。一个用户模式的线程可以拥有第二个栈在它的用户空间,并且相对是比较大的(默认可以增长到1MB)。在1-5中展示了三个用户模式线程和它们的栈的例子,在1-5中,线程1和2在进程A,线程3在进程B。
内核栈总是存在于RAM中当线程处于运行和就绪状态,这理由是非常微妙的,将在之后的章节被讨论。用户模式的栈,另一方面,是被写出去的,就像其他用户模式的内存。
就用户模式栈的大小而言,它的处理是与内核栈不同的。它以一个非常小的提交的内存开始(可能只有一个页这么大),剩余的空间是被保留的内存,这意味着不能以其他任何方式分配。这种设计思想能够在进程需要更多栈空间的情况下让栈增长。为了满足这种需求,在已经提交部分的下一个页(有时更多的页)被标记为一种特殊的称为PAGE_GUARD的保护 - 这是一个保护页。如果这个线程需要更多的栈空间,它将写入这个页来抛出一个被内存管理器所处理的异常。这内存管理器之后移除这个页保护然后将提交到页,并且将其下一个页置为保护页。在这种方式下,如果需要,这栈将会增长,这整个栈内存并不会全部被提交上。1-6展示了用户模式的栈增长的方式。
一个线程用户模式的栈大小由如下所决定的:
- 这个可执行映像文件有一个栈提交和保留的值在它的PE头中。如果一个线程并不会指定替代值这将会被标记为默认的。
- 当一个线程被CreateThread(或相似的函数)所创建时,调用者可以执行它所需要的栈大小,预先提交的大小或者保留的大小(但两者不可同时设置),这依赖于函数所提供的标志位;如果指定为零则会和上面的默认值一致。
系统服务(系统调用)
应用需要执行多种多样的非纯计算性操作,例如分配内存,打开文件,创建线程等。这些操作只能由最终运行在内核模式的代码执行。因此用户模式的代码是如何执行这些操作的呢?让我们举一个经典的例子:一个运行Notepad程序的用户使用文件才来提出打开文件的请求。Notepad的代码通过调用Windows文档中CreateFile的API作为回应。CreateFile是在kernel32.dll中实现的,Kernel.dll一个Windows子系统Dlls。这个函数仍然运行在用户模式,因此没有办法直接打开一个文件。在之后的一些错误检查中,它调用NtCreateFile,一个执行在Ntdll.dll中的函数,Ntdll.dll是运行被称为“原生API”的基础DLL,其是用户层最底层的代码。这(官方未文档化的)Api转换到了内核模式。在实际转换之前,它放入寄存器中一个数字,被称为系统调用号(EAX on Intel/ADM 结构中),之后它使用一个CPU指令(syscall on x64 or sysenter on x86)来完成实际的内核转换,其跳入一个预先设计好的被称为系统服务派发函数的例程中。
这系统服务派发函数,相反,使用存储在EAX寄存器中的值,作为一个进入SSDT表的索引。利用这张表,代码跳入系统服务(系统调用)中。对于我们的NotePad例子中,SSDT入口将指向 I/O 管理器的NtCreateFile函数,注意这个函数和在Ntdll.dll中有着相同的名字和相同的参数。一旦系统服务被完成,这线程将通过执行指令 sysenter/systemcall来返回用户模式。1-7描述了这一系列事件。
系统总体架构
1-8展示了Windows的总体结构,由内核模式和用户模式所组成。
下面是出现在图1-8中的快速摘要:
- User Processes: 正常的进程都依赖于映射文件,执行在系统上,正如Notepad.exe、cmd.exe、explorer.exe实例等。
- Subsystem Dlls: 子系统DLLs是运行子系统API的动态链接库,一个子系统是内核能够被公开的特殊的视图。从技术上来讲,从Windows8.1开始,这儿有一个单独的子系统 - Windows子系统。这个子系统Dll是包含着很多知名的文件,比如kernel32.dll,user32.dll,gdi32.dll,advapi32.dll,combase.dll或者其他更多。这包含着Windows文档化的绝大多数API。
- Ntdll.dll:一个全系统的DLL,运行着Windows的原生API。这是运行在用户模式最底层的代码。它最重要的作用是通过系统调用来转换成内核模式。Ntdll也运行着堆管理器,映像加载和一部分用户模式的线程池。
- Service Processes:服务进程是普通的Windows进程,它与服务控制管理器(SCM,运行在Services.exe中)进行通讯,并且允许在他们的声明周期中进行一些控制。SCM可以开始、停止、暂停和发送消息向这些服务中。执行在一个特殊的Windows账户中的典型服务 - 本地系统、网络服务和本地服务。
- Executive:执行体是NtOskernl.exe的上层,它掌管绝大多数的内核代码。这包含了绝大数的各种管理器 - 对象管理器,内存管理器,I/O管理器,即插即用管理器,电源管理器,配置管理器等,它比底层内核要大得多。
- Kernel:这内核层执行了大多数基础的,内核模式下时间敏感的OS代码。这包含了线程调度,重点和异常已发以及各种内核原语(互斥体和信号量等)。一些内核代码是用特定的CPU机器语言所编写的,以提高效率并可以直接访问CPU特定的细节
- Device Drivers:设备驱动是可加载的内核模块,它们的代码执行在内核模式并且有着全系统的权限,这本书致力于写特定类型的内核驱动。
- Win32k.sys:“Windows子系统的内核模式组件”,本质上这是一个内核模块(驱动)来处理Windows用户界面部分和一个比较典型地图形设备(GDI)APIs。这意味着所有的窗口操作(CreateWindowEx,GetMessage,PostMessage等)是由这个组件所处理的。这其余的系统很少涉及UI的相关知识。
- HaderWare Abstraction Layer(HAL):这HAL是一个硬件之上的抽象层。它允许设备驱动程序使用不需要中断控制器或DMA控制器等详细和特定知识的api。通常来讲,这层是对于驱动设备处理硬件设备最有用的一层。
- System Processes:系统进程是一种概括性属于,用来描述进程通常“就在那儿”,在那些无法直接进行通讯的地方做着他们的事情。尽管如此,它们还是非常重要的,事实上对于操作系统的良好运行显得至关重要。中断它们中的一些可能是指明的,造成一个系统崩溃。一些系统进程是原生进程,意味着他们仅适用一些原生API(由Ntdll执行的API),例如smss.exe、Lsass.exe、Winlogon.exe、Services.exe以及其他进程。
- Subsystem Process:Windows的子系统进程,运行着Csrss.exe的映像,它可以被视为在Windows系统下内核进程管理的助手。他是一个非常重要的进程,这意味着如果被杀死,这系统将会崩溃。正常来说每一个会话都存在一个Csrss.exe实例,因此在一个一般系统中存在着两个实例 - 一个用于会话0,另外一个用于用户登录会话(典型的为1)。虽然Csrss.exe是Windows子系统管理器,它的重要程度远不仅仅于此。
- Hyper-V Hypervisor:这Hyper-V系统管理程序存在于Win10和Server2016以及之后的系统,如果他们支持虚拟化基础安全(VBS),VBS提供一个额外的安全层,这儿实际上是由Hyper-V控制的虚拟机。VBS超出了本书的范围,对于更多信息,你可以查阅《windows Internals》这本书。
句柄与对象
Windows内核暴露了各种类型的对象来让运行在用户模式的进程、内核本身以及内核模式的驱动所使用,这些类型的实例是在系统空间的数据结构,当由用户或内核模式代码请求时,其创建由对象管理器(Executive的部分)。对象被引用计数 - 仅有当最后一个引用的句柄被释放之后,这个对象被销毁,并从内存中释放。
因为这些对象位于系统空间,他们不能被用户模式直接访问。用户模式必须使用一个被称为句柄的间接访问机制。一个句柄是存储在进程句柄表中的实体,该实体逻辑上指向一个存储在系统空间的内核对象。有各种 Create* 或者 Open* 函数来创建/打开内核对象并且检索句柄背后的这些对象。例如,这 CreateMutext 用户模式的函数允许创建或打开一个互斥体(依赖于这个对象是否被命名或者存在),如果成功,这函数返回一个这个对象的句柄。如果返回值为零则意味着这是一个无效句柄(这个函数调用失败)。这 OpenMutex 函数,另一方面,试图打开一个已经被命名的互斥体,如果这个命名的互斥体不存在,这函数调用失败返回NULL(0)。
内核(驱动)代码也可以使用一个句柄或者直接指向对象的内核指针。这选择通常基于你希望调用的API。在一些情况下,一个从用户模式传入驱动的句柄必须通过ObReferenceObjectByHandle函数来转换成一个指针。我们将在之后的章节中讨论这些细节。
【大多数函数在失败的情况下返回零,但有些不是。值得注意的是,CreateFile函数返回INVALID_HANDLE_VALUE(-1)如果失败的情况下。】
句柄值是4的倍数,第一个有效的句柄值为4;0从来不会是一个有效的句柄值。
当创建/打开对象时,内核模式代码可以使用句柄,但它们也可以直接使用指向内核的指针。当某个指定的API需要它时,这会被完成。内核模式代码可以使用ObReferenceObjectByHandle函数来得到一个指向对象的指针。如果成功,这对象的索引值会被增加,因此“如果一个用于句柄的用户模式代码决定关闭它,然而内核模式代码使用者一个指向对象的指针”是没有危险的,这不会造成内核的指针指向的对象被释放。不管句柄拥有者是谁,这个对象可以被安全的访问,直到内核代码调用ObDerefenceObject,这会将引用计数减少;如果内核代码忘记调用这个,那会操作资源泄漏,直到下次操作系统重启才会被解决。
所有的内核对象都被计数,这对象管理器维护了句柄数值和对于这个句柄总共的引用次数。一旦这个对象不再需要,它的客户端应该关闭句柄(如果一个句柄被用于访问对象)或者不引用这个对象(如果内核客户端使用指针)。从这点来讲,这个代码应该考虑到它的句柄/指针无效的。对象管理器将会销毁这个对象当它的引用计数到达零时。
每一个对象指针指向一个对象类型,它拥有者这个类型本身的信息,这意味着每一种类型对象都存在着单一的对象的类型。这些变量也作为导出的全局内核变量所公开,它们中的一些在内核头中被定义,在某些特定的情况下这是非常有用的,在之后的章节中我们会看到。
Object Names
有一些类型的对象时有名字的,这些名字被用于使用合适的Open函数通过名字来打开对象。注意并不是所以的对象都有名字;例如,进程和线程并没有名字 - 它们有IDs。这就是为什么OpenProcess和OpenThread函数需要一个进程/线程的标识符(一个数字)而不是一个字符串名称。另一个有点奇怪的没有名字的对象是一个文件。文件名并不是一个对象名 - 它们是不同的概念。
从用户模式的代码,调用一个带有名字参数的Create函数,如果这名字不存在,它将创建一个内核对象,但是如果它存在,则将打开这个内核对象。在之后的例子中,调用GetLastError返回ERROR_ALREADY_EXISTS,这表明这并不是一个新对象,这返回句柄已经是一个指向一个已存在对象的新句柄。
提供给Create函数的名称实际上不是对象的最终名称。它的前缀是\Sessions\x\BaseNamedObjects,其中x是调用者的会话ID。如果这个会话为0,这个名字以\BaseNamedObjects为前缀。如果调用发生在一个AppContianer中(通常是一个普通的Windows平台程序),则这个前缀字符串会更加负责,由一个唯一的AppContainer SID所组成:\Sessions\x\AppContainerNamedObjects\{AppContainerSID}。
上面所有的都意味着对象名称是相对于会话的(在某些情况下是与AppContainer-package相关联的)。如果一个对象一定要被跨会话共享,它可以在Session0中创建,通过以Global\前缀的名称;例如,以Global\MyMutex为名字来通过CreateMutex函数创建一个互斥体,这将创建在\BaseNamedObjects下。注意AppContainers没有使用Session0对象命名空间的权力。可以使用Sysinternals WinObj 工具来查看这层次结构,如1-9所展示的。
图1-9展示了对象管理的命名空间,这由被命名的对象层次结构所组成。这整个结构存储在内存,由对象管理器(Executive的部分)所操控。注意未命名的对象不是这个结构的部分,这意味着WinObj无法查看全部的存在的对象,而是那些被创建出来有名字的对象。
每一个进程都存在一个存储着内核对象(无论是否被命名)的私有句柄表,这可以被查看通过Process Explorer或者 handles Sysinternals 工具。如图1-10,Process Explorer的屏幕快照所展示了在进程中的句柄。在句柄视图中默认展示的列只有对象类型和名字。然而,如图1-10,其他有效的列也被展示出来。
默认来说,Process Explorer仅展示了带有名字的对象句柄(至于Process Explorer's名字的定义,我们之后再来讨论)。如果想显示一个进程中的所有句柄,可以在 Process Explorer's View 菜单选择 Show Unnamed Handles and Mappings。
在句柄视图中的各个列提供了每个句柄的更多信息,这些句柄值和对象类型是自己所解释的。这名字列是非常复杂的。它展示了对于互斥体、信号量、事件、节区、ALPC端口、任务、计时器或者其他较少被使用的对象的真正名称。然而,其他所展示出来的与其真正的名称对象存在着不同的含义:
- 进程和线程,其所展示的是其唯一的ID。
- 对于文件对象它展示了文件名(或者设备名)的文件对象的指针,这不是和对象名称相同的,因为没有办法通过文件名来获取这个指向文件对象的句柄 - 只能通过创建一个新的文件对象来访问这相同的底层的文件或设备(假设文件对象允许共享它)。
- (注册)键值对象的名称是用来展示这个注册值的路径。这不是一个名字,理由与文件对象一致。
- 目录对象展示了这个路径,而不是一个真正的对象名称。一个目录不是一个文件系统对象,而是一个对象管理器目录 - 这些可以非常容易通过Sysinternals WinObj 工具所看到。
- 令牌对象名称是展示了这个令牌所存储的使用者名称。
Accessing Existing Objects(访问已存在的对象)
在Process Explorer的句柄视图中,Access列展示了被用于创建或打开这个句柄的访问掩码。这访问掩码是用于指定句柄是否允许执行某些操作。例如,如果客户端代码想终止一个进程,它必须首先使用 PROCESS_TEMINATE访问掩码,来调用OpenProcess函数来获取一个句柄,否则这无法使用一个句柄来终止线程。如果调用成功,之后调用TerminateProcess一定会成功。这里有一个通过进程ID来终止进程的例子:
已解码的Access列提供了访问掩码的文本描述(对于某些类型),使用它可以很容易识别出一个实际句柄的正确的访问权限。
双击这个句柄入口展示了这个句柄属性,如图-11展示了一个Event对象的属性。
图1-11中的属性包含了对象的名称,它的类型,一个描述,在内核对象的地址,被打开的句柄次数和某些特殊的句柄信息,比如这个状态和事件句柄信息。注意References并没有执行该对象实际的未完成的引用次数。一个正确查看某个对象实际的引用次数是使用内核调试器的 !trueref 命令,效果如下:
我们可以更能清楚地看到对象的属性,这内核调试器在之后的章节会介绍。
现在让我们开始写一个简单的驱动,来展示和使用我们之后在该书中之后需要用到的工具。