近俩天的学习-Windows内核基础的学习笔记。算是一个概述,毕竟每一个小点单拉出来都可以讲好久。学了这俩天,对内核的理解算是清晰了那么一点点了,持续学习ing…欢迎师傅一起交流讨论私信andwx都欢迎。感谢。
参考文献:
《加密与解密》Windows内核基础
《Windows内核原理与实现》系统总述
《深入理解Windows操作系统》
Chat Gpt…
MSDN文档。
一些零零散散的大佬的blog。感谢。
系统内核层,又称零环(Ring0,简称R0;与此对应的是3环,R3,应用层);实际上是CPU的4个级别,CPU在设计时将CPU的运行级别从内到外分为4个层级R0-R3,R0权限最高依次降低(实际上,现在只区分R0和R3,并没有使用R1和R2)。
分级的目的是为了保护系统的稳定性和安全性。通过限制某些操作只能在高权限级别下执行,操作系统可以防止用户级应用程序意外或恶意地修改关键系统资源,进而导致系统崩溃或安全漏洞。
Windows 体系结构简图:
从上图可以看到,Windows使用双模式来保护操作系统本身,用户模式对应的就是R3,内核模式即R0。在这种架构下,应用程序的代码只能运行在用户模式下,每当它需要使用到系统内核或内核的扩展模块(内核驱动程序)所提供的服务时,应用程序通过硬件指令从用户模式切换到内核模式中;当系统内核完成了所请求的服务以后,控制权又回到用户模式代码。
Windows中,用户代码和内核代码有各自的运行环境,并且它们可以访问的内存空间也不同。
以x64为例,在4G的虚拟内存空间中,WIndows系统的内存分为内核空间和应用空间,每部分各占2GB。
其中用户空间占用低地址(00000000 ~ 7FFFEFFF),内核空间占用高地址(7FFF000 ~ FFFFFFFF);若是开启了大地址空间模式的程序(LARGE_ADDRESS_AWARE),则内存空间布局会变成3GB 的用户空间,和 1GB 的内核空间。
如图,Windows内核中主要可以分成三层:硬件抽象层(HAL),内核层(也称微内核micro-kernel),执行体层。
内核层实现操作系统的基本机制,而所有的策略决定则留给执行体。执行体中的对象绝大多数封装了一个或者多个内核对象,并且通过某种方式(比如对象句柄)暴露给应用程序。
Windows内核为用户模式提供了一组系统服务,供应用程序使用内核中的功能。应用程序通常并不直接调用这些系统服务,而是通过一组系统DLL,最终通过ntdll.dll切换到内核模式下的执行体API函数中,以调用内核中的系统服务。
Ntdll.dll是链接用户模式代码和内核模式系统服务的桥梁。对于内核提供的每一个系统服务,该DLL都提供一个相应的存根函数,这些存根函数的名称以“Nt”作为前缀,例如NtCreateProcess
、NtOpenFile
等。另外ntdll.dll还提供了许多系统级的支持函数,比如映像加载器函数(以“Ldr”为前缀)、系统时间函数(以“Etw”为前缀),以及一般的运行支持函数(以“Rtl”为前缀)和字符串支持函数等。
执行体API函数接收的参数来自于各种应用程序,因此为了保证系统的安全以及抵抗来自用户模式的恶意攻击,所有的执行体API都必须保证参数的有效性。通常执行体系统服务函数会在其开始处,对所接收的参数逐一探查它们的可访问性。例如如下代码:
PreviousMode = KeGetPreviousMode();
if (PreviousMode != kernelMode){
try{
ProbeForWrite(InputInformation,InputInformationLength,sizeof(ULONG));
if (ARGUMENT_PRESENT(ReturnLength)){
ProbeForWriteUlong(ReturnLength);
} except(EXCEPTION_EXECUTE_HANDLER){
return GetExceptionCode();
}
}
}
解析:
KeGetPreviousMode
函数来获取当前的执行模式(内核模式or用户模式)if
判断是否是内核模式(kernelMode)try{...}except(){...}
异常处理,try中包含一些堆内存的探测(Probe)操作ProbeForWirte
是一个检查内存可写性的函数,用于检查InputInformation
指向的内存区域是否可以安全写入数据,第二三个参数分别是数据的长度和数据的大小。ReturnLength
参数,如果传递了就进一步检查内存可写性GetExceptionCode
获取错误码Windows内核中的关键组件:
硬件抽象层(Hardware Abstraction Layer,HAL),这一层把所有与硬件相关联的代码逻辑隔离到一个专门的模块中,为操作系统的上层提供一个抽象的、一致的硬件资源模型。这使得上层的模块无须考虑硬件的差异,它们通过HAL而不是直接访问硬件。
在Windows中,HAL是一个独立的动态链接库。HAL提供了一些例程供其他内核模块或设备驱动程序调用,这使得一个驱动程序可以支持同样的设备在各种硬件平台上运行。HAL不仅涵盖了处理器的体系结构,也涉及了中断控制器、单处理器或多处理器等硬件条件。
这是大内核中的小内核,也称微内核。它是内核模块ntoskrnl.exe的下层部分(上层为执行体),最接近HAL层,负责线程调度和中断、异常的处理。对于多处理器系统,它还负责同步处理器之间的行为,以优化系统的性能。
Windows的内核实现了抢占式线程调度机制,按照优先级顺序将线程分配到处理器上,并且允许高优先级的线程中断或抢占低优先级的线程。每个线程有一个基本优先级值(base priority)和一个动态优先级值。根据这俩个值,内核根据调度规则来切换线程,让系统更快响应用户的动作,以及在系统服务和其他低优先级进程之间平衡处理器资源的分配。
Windows内核按照面向对象的思想来设计,它管理俩种类型的对象:分发器对象和控制对象。分发器对象实现了各种同步功能,这些对象的状态会影响线程的调度。Windows内核实现的分发器对象包括事件(event)、突变体(mutant)、信号量(semaphore)、进程(process)、线程(thread)、队列(queue)、门(gate)和定时器(timer)。控制对象被用于控制内核的操作但是不影响线程的调度,它包括异步过程调用(APC)、延迟过程调用(DPC),以及中断对象等。
执行体是内核模块ntoskrnl.exe的上层部分,它包含5种类型的函数:
NtCreateFile
、NtReadFile
等函数。ExAlloctePool
:分配内核池内存、KeSetEvent
:设置一个事件对象的状。ObOpenObjectByPointer
:根据对象指针打开对象、 ExCreateCallback
:创建一个回调对象。SeAccessCheck
:检查访问权限。IoWriteErrorLogEntry
:写入错误日志条目。关于函数前缀,这些函数都以固定的前缀开始,分别属于内核中不同的管理模块:
与应用层函数不同,在windows操作系统中调用内核函数必须要关注中断请求级别(IRQL,Interrupt Request Level)。
IRQL 是一个表示中断优先级的数字,用于确保处理器在处理不同的任务时维持正确的操作顺序和安全性。操作系统内核使用不同的 IRQL 来管理对硬件资源的访问,以及处理不同级别的中断和异常。
在调用内核函数时,必须要确保当前的IRQL与被调用函数所要求的级别相符。不遵守这一规则可能导致系统崩溃或数据损坏。IRQL的级别如下:
开发内核模式驱动程序时,合理地管理IRQL至关重要。如果一个函数要求在低IRQL下运行,而当前IRQL较高,就不能直接调用那个函数;反之亦然。不正确的IRQL处理可能导致系统不稳定或蓝屏(BSOD)。
执行体中除了函数组成,还包含了如图中的多个重要的组件,以下:
在内核中除了内核模块ntoskrnl.exe和HAL以外,其他模块几乎都以设备驱动程序的形式存在。
Windows中的设备驱动程序,并不一定对应物理设备;它既可以创建虚拟设备,也可以与设备无关,它仅仅是内核的扩展模块。从软件结构角度而言,可以认为设备驱动程序是Windows内核的一种扩展机制,系统通过设备驱动程序来支持新的物理设备或者扩展功能。
设备驱动程序是可以加载到系统中的模块,其文件扩展名为**.sys**,其格式是标准的 PE文件格式。驱动程序中的代码运行在内核下,尽管它们可以直接操纵硬件,但理想的情况是,调用HAL中的函数与硬件打交道,因此,驱动程序往往用C/C++语言来编写,从而可以方便地在Windows所支持的体系结构之间进行源代码层次上的移植。
根据设备驱动程序的功能和行为可以将设备驱动程序分为三类:
即插即用驱动程序,也可以称为WDM(Windows Driver Model)驱动程序。WDM是一种设备驱动模型,它提供了一个统一的框架,使驱动程序可以在不同版本的WIndwos操作系统上运行。
WDM通常分为三个层次:
在WDM中,每个硬件设备都有一个设备驱动程序栈(简称设备栈),其中包含一个总线驱动程序和一个功能驱动程序,以及零个或多个过滤驱动程序。
在现代操作系统中,文件系统是外部存储设备的标准接口,它为应用程序使用这些设备中的数据提供了统一的抽象,多个应用程序和系统本身可以共享使用这些设备。
在Windows中,文件系统的接口部分由I/O管理器定义和实现,但文件系统的实现部分位于专门的一类驱动程序中。但文件系统接收到I/O请求时,它会根据文件系统格式规范,将这些请求转变为更底层的对于外部存储设备的I/O请求,通过它们的设备驱动程序来完成原始的I/O请求。
因此,文件系统的驱动程序定义了外部存储设备中数据的逻辑结构,使得这些数据可直接被操作系统和应用程序使用。
这些文件系统驱动程序负责管理磁盘上的文件和目录,处理文件的创建、读取、写入和删除操作,提供了文件存储和访问的基本功能。
常见的文件系统:
文件系统的底层是对存储设备的管理。大容量存储设备以“分区(Partition)”和“卷(volume)”来管理整个存储空间。
分区是指存储设备上连续的存储区域(连续的扇区),而卷是指扇区的逻辑集合。一个卷内部的扇区可能来自一个分区,也可能来自多个分区,甚至来自不同的磁盘。文件系统则是卷内部的逻辑结构。
在Windows操作系统中,网络是由一系列网络驱动程序和网络协议栈组成。
Windows内核层中的网络相关组件:
Windows为应用程序提供了多种网络API:
这些网络API都提供了用户模式的动态链接库(DLL)。当应用程序通过这些DLL发出网络I/O请求时,它们将这些请求传递给内核中相应的驱动程序。通常,这些网络API要么通过专门的系统服务切换到内核模式,比如命名管道和邮件槽就有专门的系统服务;要么通过标准的系统服务接口,比如NtReadFile
、NtWriteFile
和NtDeviceIoControlFile
,由I/O管理器和对象管理器将网络请求转送至对应的驱动程序中。
Windows子系统是Windows操作系统的组成部分,用于支持不同类型的应用程序和环境在Windows平台上运行。每个子系统专门设计用于处理特定类型的应用程序和操作环境。
Windows提供了多种子系统,同时在PE文件格式中的SubSystem域指示了可执行文件的子系统类型,即程序应在何种环境下运行;SubSystem域通常包含一个数字值,代表不同的子系统类型。
以下是一些常见的SubSystem及其对应的子系统:
Native (0):
Native子系统表示该PE文件是一个本地的执行文件,通常是驱动程序或操作系统内核组件。这些文件在操作系统内核模式下运行。
Windows GUI (2):
Windows GUI子系统表示该PE文件是一个图形用户界面(GUI)应用程序。它运行在Windows桌面环境中,通常有用户界面和窗口。
Windows CUI (3):
Windows CUI子系统表示该PE文件是一个字符用户界面(CUI)应用程序。它通常是命令行应用程序,没有图形界面,用户通过控制台来与之交互。
OS/2 CUI (5):
OS/2 CUI子系统表示该PE文件是一个OS/2字符用户界面应用程序。这种类型的应用程序通常用于运行在IBM OS/2环境中。
Posix CUI (7):
Posix CUI子系统表示该PE文件是一个POSIX兼容的字符用户界面应用程序。它适用于在Windows上运行UNIX/Linux应用程序。
Windows子系统中既有用户模式部分,也有内核模式部分。内核模式部分的核心是win32k.sys,虽然它的形式是一个驱动程序,但实际上它并不处理I/O请求,相反,它向代码提供大量的系统服务。从功能上讲,它包含俩部分:窗口管理和图形设备接口。其中窗口管理部分负责收集和分发消息,以及控制窗口显示和管理屏幕输出;图形设备接口部分包含各种形状绘制以及文本输出功能。
Windows子系统的用户界面管理有一个层次结构,通常应用程序只是在一个默认的桌面上运行。
每个子系统会话都有自己的会话空间,属于某一个会话的资源将会从该会话空间中分配。当用户登录到Windwos中时,操作系统为该用户建立一个会话;即使用户通过远程桌面或者终端服务连接到一个系统中。系统也会为该用户建立一个单独的会话。
在一个会话中,有一个交互式窗口站,可能还有非交互式窗口站。在交互式窗口站中通常有三个桌面:登录桌面、默认桌面和屏幕保护桌面。交互式窗口站有独立的剪贴板、键盘、鼠标、显示器等,在它的三个桌面中,任一时刻只有一个是激活的,输入输出设备归激活的桌面所有。
在每个桌面,都有一个顶级窗口列表,这些窗口往往可以相互重叠,有系统菜单、最大化/最小化按钮和滚动条等。通常各个图形界面应用程序的主窗口属于当前桌面的顶级窗口。在Windows中,窗口可以有子窗口,子窗口占据父窗口的客户区域。因此,桌面上的窗口形成了一个层次结构。一个窗口下总是可以创建它自己的子窗口。
Windows为常用的窗口定义了一些窗口类(window class)。窗口类规定了其对象将如何响应各种信息,包括系统发送给它的消息和用户触发的消息。
Windows子系统会话有一个RIT(Raw Input Thread)线程,负责从输入设备读取原始的输入时间,生成消息寄送到正确的线程消息队列。
Windows的图形引擎有俩方面特点:
上图是Windows子系统定义的图形体系结构。win32k.sys通过DDI(显示设备驱动程序接口)与现实驱动程序打交道,而显示驱动程序通过ENG(图形引擎接口)调用Win32k.sys中图形引擎的功能。
视频端口驱动程序实际上是一个动态链接库,用于辅助视频小端口驱动程序实现一些公共的、与图形有关的功能,以及为小端口驱动程序提供一个与系统内核和执行体打交道的环境。视频小端口驱动程序则直接负责的硬件资源管理和控制。
系统线程则是一些特殊线程,与普通用户线程不同,系统线程不属于任何特定的用户进程,它直接运行在内核模式下。
一些常见的系统线程:
系统线程中还有一组系统辅助线程(system worker thred),它们代表操作系统或者其他的应用进程来完成一些特殊的工作。实际上,系统辅助线程是一个线程池,Windows在系统初始化时创建了一定数量的辅助线程,而且随着辅助线程的负载的变化,执行体也会动态地创建一些辅助线程,以满足系统负载的变化需求。
在Windows操作系统中,有一些重要的系统进程,它们负责管理和控制操作系统的不同方面。
一些常见的系统进程:
Windows内核中的各个组件并非单纯的独立模块,相反地,组件之间不可避免地包含了复杂的依赖关系,甚至存在交叉引用。下面是一些Windows内核中的基本概念。
在Intel x86处理器上,段描述符有一个2位长度的特权级:0表示最高特权级,3表示最低特权级。也就是CPU的权限级别0环和3环,分别对应内核模式和用户模式。
处理器有许多指令只有在零环内才可以使用,例如I/O指令、操纵内部寄存器指令等,当处理器处于用户模式时,它处于一种相对隔离的状态:能够执行的指令有限,能够访问的内存也是有限的(用户代码和内核代码有各自的运行环境)。一旦越过这些限制,就会引发处理器异常,此时操作系统会捕获这些异常,并决定处理器是否继续执行。
用户模式下,处理器只能访问用户地址空间,而在内核模式下,处理器不仅可以访问用户地址空间,也可以访问系统地址空间。在内核模式下的代码和数据都是共享的,所有的进程一旦其指令流进入到内核模式下,则系统地址空间的代码和护具都是相同的。
一个指令流(即线程)在执行时,在以下情况会发生模式切换:
iret/iretd
指令,则控制流恢复到用户模式下。sysenter
指令,从用户模式切换到内核模式。若想从内核模式切换到用户模式1,通常使用sysexit
、iret/iretd
这样的指令。由于系统空间是所有进程共享的,所以,任何一个进程在执行内核模式的代码时,实际上是在使用操作系统的服务。在Windows体系结构中,内核模式向上优一个执行体API,对于应用程序而言,这便是系统服务。
Windows将这些系统服务组织成了一张表,称为SDT(Service Descriptor Table,服务描述符表)。
任何一个进程都定义了它自己完整的4GB地址空间(虚拟内存空间),在内存空间分布一图中,将其划分成2GB内核空间以及2GB应用空间,换句话说,内核空间是所有进程共享的,也称作系统地址空间,剩下的2GB空间才是它自己私有的,也叫进程地址空间。
为了有效管理2GB的系统地址空间,Windows将2GB划分成了一些固定的区域,主要包括:内核模块映像、PFN数据库、换页内存池、非换页内存池、会话空间、系统缓存区、系统视图以及页表等。
分页机制:
Windows使用分页机制管理虚拟内存和物理内存。它将虚拟内存和物理内存划分成固定大小的页面,通过映射这些页面来实现虚拟地址到物理地址的转换。
通常操作系统将内存划分为大小固定的页面,通常为 4KB、8KB 或其他大小。这些页面是虚拟内存和物理内存的基本单位;之后操作系统将进程的虚拟地址空间也划分成页面大小的块。当程序访问进程的虚拟地址时,操作系统将虚拟地址转化成相应的物理地址。
为了实现虚拟地址到物理地址的转换,需要使用到页面表。
当程序访问进程的虚拟地址时,MMU负责将这个虚拟地址通过页面表转化成物理地址。
如果虚拟页已经在物理内存中,则直接获取物理地址。如果虚拟页不在物理内存中,就需要进行页面调度。
页面调度:
如果虚拟页不在物理内存中,会先引发一个 缺页异常。这时,操作系统需要根据页表中的信息确定要将哪一页加载到物理内存中。
然后操作系统会将当前没用的物理页写入磁盘中,将需要的虚拟页加载入物理页。
从内存中获取数据的过程:
? 至于数据在物理内存中还是虚拟内存中是没有规律的,取决于数据使用的频繁程度。
内存页面管理算法
在系统地址空间中,不同的区域使用并不完全相同的内存页面管理算法,较为典型的有以下三种:
以上这些内存区域按照页面粒度来管理其分配情况,Windows执行体在这些系统内存区域管理的基础上,还提供了一组更小的粒度(8B的倍数,最小位8B)的内存管理,包括执行体换页内存池和执行体非换页内存池。这些内存池通过空闲链表记录下每个已申请页面中的空闲内存块;当释放内存时,自动与相邻的空闲块合并以构成更大的空闲内存块。内核其他组件或驱动程序通过执行体暴露的API函数(例如ExAllocatePoolWithTag
和ExFreePoolWithtag
)来使用这些内存池。
进程地址空间是随进程一起被创建的,每个进程都有它自己的页目录页面,其中有一半的**页目录项(PDE)**是共享的,即系统地址空间部分,余下一半初始化为零。随着进程中的映像文件(包括.exe文件和各DLL文件)被加载进来,以及各个模块的初始化代码被执行,进程地址空间将被建立起来。
进程地址空间按照其虚拟地址是否被分配或保留来进行管理,用户模式代码通过Windows API函数 VirtualAlloc
和 VirtualFree
来申请或释放地址范围,而内核中的虚拟内存管理器则通过一颗平衡二叉树来管理进程地址空间被使用的情况。数中的每个节点为VAD(虚拟地址描述符,Virtual Address Descriptor),描述了一段连续的地址方位。
在VAD树中,有一种重要的节点类型为内存区对象(section object),它是Windows平台上俩个或多个进程之间共享内存的一种方式。
除了对系统地址空间和进程地址空间的管理,内存管理器另一个重要的任务是管理有限的物理内存。在WIndows的系统地址空间中,专门保留了一个称为**PFN数据库(Page Frame Number Database,页帧编号数据库)**的区域。
当系统中的进程需要使用大量内存时,内存管理其如何将有限的物理页面分配给那些需要使用内存的进程?利用Windows工作集管理器。工作集(working set)是指一个进程当前正在使用的物理页面的集合。Windows系统中除了进程工作集,还有系统工作集(即系统空间中动态映射的页面集合)和会话工作集(即会话空间中的代码和数据区)。
工作集管理器运行在一个称为平衡管理器(balance set manager)的线程中,它的作用除了触发工作集管理器,还定期触发进程/栈交换器(process/stack swapper)。进程/栈交换是另一个单独的线程,一旦被唤醒,就会将满足特定条件的进程和栈换入内存或换出内存。
**进程(process)**定义了一个执行环境,包括它自己的私有空间、一个句柄表、以及一个安全环境;线程(thread)是一个控制流,有自己的调用栈(call stack),记录它的执行历史。
一个进程包含一个或多个线程。
如图,用户模式下的进程只能访问进程地址空间,若在内核模式下,就可以访问真个地址空间。
在Windows内核结构中,进程和线程的核心机制是在微内核中实现的,而管理机制是在执行体中实现的。例如,微内核负责线程调度,而线程进程的创建和初始化由执行体完成。
windows实现了抢占式线程调度,每个线程都有一个基本优先级和动态优先级。本质上每个线程都处于俩种状态之一:满足继续执行的条件,正在排队或已经执行;不满足继续执行的条件,处于等待状态,或者它的调用栈升职所处的进程已经被换出内存。在前一种情况下,线程按照优先级排队执行;对于多处理器系统,排队过程要更为复杂,不仅要处理多个队列,还要考虑每个处理器的就绪线程队列的平衡程度。还需要考虑处理器亲和性。
关于作业和迁程,**作业(job)**是一个执行体支持的内核对象,它使得一个或多个进程被当做一个整体来加以管理和控制。管理程序通过Windows API 可以控制一个作业小号系统资源(CPU或内存)的各种限制,例如用户模式CPU时间的限制、进程的处理器亲和性、工作集的最大值和最小值、虚拟内存的使用限制等。**迁程(fiber)**是一种用户线程,它对于内核是不可见的,由kernel32.dll实现。应用程序可以在一个线程环境中创建多个迁程,然后手动控制它们的执行。迁程不会被自动执行,应用程序必须显式地选择某个迁程来执行,而且一旦迁程运行起来,要么一直运行到它退出,要么运行到它显式地切换至另一个迁程。
**中断(interrupt)**是指处理器外部事件(如硬件设备)触发的信号,它会中断当前的处理器活动。**异常(Exceptions)**指程序执行过程中出现的非正常或意外情况,由处理器内部事件(如执行了错误指令)触发。
尽管中断和异常的触发来源和方式不同,但Intel x86处理器内部使用同一套陷阱机制来处理中断和异常,它利用IDT(Interrupt Descriptor Table,中断描述符表),将每个中断或异常与一个处理该中断或异常的服务例程联系起来,因而一旦发生异常或中断,该相应的服务例程将被执行。Windows在此基础上,添加了一种更灵活的机制,允许设备驱动程序为特定的中断向量添加它的中断服务例程(ISR,Interrupt Service Routine)。一个中断向量允许连接多个中断对象(interrupt object),这里中断对象是一种封装了中断服务例程的内核对象。当中断发生时,这些中断对象中的服务例程都有机会处理该中断。通过中断对象机制,设备驱动程序可以在不操纵IDT的情况下加入它们的中断服务例程;另一方面,多个硬件设备也可以共享同样的硬件中断向量。
执行体层中介绍过中断请求级别IRQL。
异常是程序指令流执行过程中的同步处理过程,既可以由处理器硬件产生,也可以由指令流软件产生。Windows为所有需要处理的异常都提供了异常处理器(exception handler,即异常处理例程)。
在现代操作系统中,由于多处理器、多核或者中断各种并发性(concurrency)因素的存在,同样的代码可能被并发执行,而数据也可能被并发访问。在这种情况下,对于可能被并发访问的数据进行必要的同步(synchronization)保护是一种常见的编程实践。
一些同步中会遇到的概念:
Windows根据执行环境中的IRQL大于APC_LEVEL 或者等于PASSIVE_LEVEL,将同步机制分为“不依赖线程调度的同步机制”和“基于线程调度的同步机制”。
不依赖线程调度的同步机制主要是在IRQL的高优先级下执行的,通常用于中断处理程序和内核模式代码中,以确保在处理中断或执行关键内核代码时,不会被其他线程打断。常见的不依赖线程调度的同步机制包括:
另一种基于线程调度的同步机制:当一个线程的执行条件不满足时,该线程进入等待状态,系统将控制权交由其他满足执行条件但没有得到处理器资源的线程;以后,当该线程的执行条件满足时,它又有机会继续执行。这里的执行条件正是Windows提供的线程同步机制中的语义。Windows定义了统一的机制来支持各种线程同步原语:分发器对象(dispatcher object),其数据结构头部为DISAPATCH_HEADER。
常见的分发器对象:
**互斥锁(Mutex)**是一种基于线程调度的同步机制,它用于确保在同一时刻只有一个线程能够访问共享资源。当一个线程获得互斥锁的所有权后,其他线程必须等待,直到该线程释放锁。
关于互斥锁和自旋锁:互斥锁基于线程调度,而自旋锁不依赖线程调度;还有一个区别是线程尝试获取锁时,如果该锁被占用,线程是被挂起还是一直自旋;互斥锁适用于长时间的临界区和资源竞争激烈的情况,而自旋锁适用于非常短暂的临界区。
Windows在上述基础上,实现了同步语义更为丰富的一些同步机制,包括:快速互斥体(fast mutex)、守护互斥体(guarded mutex)、执行体资源(executive resource)和推锁(push lock)。
windows内核中一些常见的数据结构。
内核对象是Windows内核中一种重要的数据结构管理机制。应用层的进程、线程、文件、驱动模块、事件、信号量等对象或者打开的句柄在内核中都有与之对应的内核对象。
如图,一个Windows内核对象可以分为对象头和对象体俩部分。在对象头中至少有一个OBJECT_HEADER和对象额外信息。对象体紧接着对象头中的OBJECT_HEADER。一个对象指针总是指向对象体而不是对象头。如果要访问对象头,需要将对象体指针减去一个特定的偏移值,以获取OBJECT_HEADER结构,通过OBJECT_HEADER结构定位从而访问其他对象结构辅助。对象体内部一般会有1个type和1个size成员,用来表示对象的类型和大小。
Windows内核对象可以分为如下3种类型:
Dispatcher对象
这种对象在对象体开始位置了一个公共数据结构DISPATCHER_HEADER,其结构代码如下:
包含了DISPATCHER_HEADER
结构的内核对象都以字母“K”开头,表明这是一个内核对象,例如KPROCESS、KTHREAD,但以字母“K”开头的内核对象不一定是Dispatcher对象。包含该结构的内核对象都是可以等待的(waitable),也就是说,这些内核对象可以作为参数传给内核的KewaitForSingleObject()
和KeWaitForMultipleObjects()
函数,以及应用层的WaitForSingleObject()
和WaitForMultipleObject()
函数。
I/O对象
I/O对象在对象体开始位置没有DISPATCHER_HEADER结构,但通常会放置一个与type和size有关的整型成员,以表示该内核对象的类型(例如文件内核对象的类型为26)和大小。常见的I/O对象包括DEVICE_OBJECT、DRIVER_OBJECT、FILE_OBJECT等。
其他对象
除了Dispatcher对象和I/O对象,剩下的都属于其他内核对象。其中有俩个常用的内核对象,分别是进程对象(EPROCESS)和线程对象(ETHREAD)。
EPROCESS用于在内核中管理进程的各种信息,如进程ID、进程状态、内存管理信息等。所有进程的EPROCESS内核结构都被放入一个双向链表,R3在枚举系统进程的时候,通过遍历这个链表获得了进程的列表。因此有的Rookit会试图将自己进程的EPROCESS结构从这个链表摘掉,从而达到隐藏自己的目的。
EPROCESS结构中的一些关键数据如下:
调用下面俩个内核函数可以获得进程的EPROCESS结果。
PsLookupProcessByProcessId()
函数,根据进程PID拿到进程的EPROCESS结构;PsGetGurrentProcess()
函数,直接获取当前进程的EPROCESS结构。
ETHREAD结构是线程的内核管理对象。它代表Windows中的一个线程,包含了线程的执行上下文、调度信息。
EPROCESS、KPROCESS、ETHREAD、KTHREAD结构之间的关系如下图。
可以看出,EPROCESS和ETHREAD结构都是通过双向循环链表组织管理的。一个EPROCESS结构中包含一个KPROCESS结构,而在一个KPROCESS结构中又有一个指向ETHREAD结构的指针。在ETHREAD结构中,又包含了KTHREAD结构成员。
**系统服务描述表(System Services Descriptor Table,SSDT)**其在内核中的实际名称是 “KeServiceDescriptorTable”。
它用于处理应用层通过kernel32.dll下发的各个API操作请求。ntdll.dll中的API是一个简单的包装函数,当kernel32.dll中的API通过ntdll.dll时,会先完成对参数的检查,再调用一个中断(int 2Eh
或者SysEnter
指令),从而实现从R3层进入R0层,并将要调用的服务号(也就是SSDT数组中的索引号index值)存放到寄存器EAX中,最后根据存在EAX中的索引值在SSDT数组中调用指定的服务(Nt*系列函数)。如下图:
SSDT表的结构定义:
#pragma pack(1)
typedef struct ServiceDescriptorEntry
{
unsigned int *ServiceTableBase; //表的基地址
unsigned int *ServiceCounterTableBase;
unsigned int NumberOfServices; //表中服务的个数
unsigned char *ParamTableBase;
} ServiceDescriptorTableEntry_t,
*PServiceDescriptorTableEntry_t;
#pragma pack()
其中较为重要的俩个成员为ServiceTableBase
(SSDT表的基地址)和NumberOfServices
(表示系统中SSDT服务函数的个数)。SSDT表实际上是一个连续存放该函数指针的数组。
SSDT表的导入方法:
__declspec(dllimport) ServiceDescriptorTableEntry_t KeServiceDescriptorTable;
由此可以知道SSDT表的基地址和SSDT函数的索引号(index),从而求出对应的服务函数地址。在x86平台上,它们之间满足如下规则:
FuncAddr = KeServiceDescriptorTable + 4 * index
与x86平台上直接在SSDT中存放SSDT函数地址不同,在x64平台上,SSDT中存放的是索引号锁对象的SSDT函数地址和SSDT表基地址的偏移量左移4位的值,因而计算公式变为:
FuncAddr = ([KeServiceDescriptorTable + index * 4] >> 4 + KeServiceDescriptorTable)
通过这个公式,只要知道SSDT表的基地址和对应函数的索引号,就可以将对应位置的服务函数替换成自己的函数,从而完成SSDT Hook过程了。
操作系统会为每个进程设置一个数据结构,用来记录进程的相关信息。该结构就是PEB(Process Environment Block,进程环境块),PEB存在于用户地址空间中,记录了进程的相关信息。
在NT中,PEB位于进程空间的FS:[0x30]
处。同时,TEB中的 ProcessEnvironmentBlock
就是PEB结构的地址,其结构的0x30偏移处是一个指向PEB的指针。
因此,访问PEB有俩种方法:
直接获取:
mov eax, dword ptr fs:[30h] ; fs:[30]里存放即是PEB地址
通过TEB获取:
mov eax, dword ptr fs:[18h] ;此时eax里为TEB的指针
mov eax, dword ptr [eax+30h] ;此时eax里为PEB的指针
PEB结构(部分):
其中,BeingDebugged
成员用于指定该进程是否处于被调试状态,该值为0时进程未处于调试状态,若该值为非零值,则进程处于调试状态。(可以使用Windows API,如IsDebuggerPresent
、CheckRemoteDebuggerPresent
函数来访问该成员)
Ldr字段也是一个很重要的成员,该字段指向的结构记录了进程加载进内存的所有模块的基地址,通过Ldr指向的三个链表就可以找到kernel32.dll
的基地址。
**TEB(Thread Environment Block,线程环境块)**同样位于应用层之中。它包含了系统频繁使用的一些与线程相关的数据,进程中的每个线程都有一个自己的TEB。一个进程的所有TEB都存放在从0x7FFDE000开始的线性内存中,每4KB为一个完整的TEB。
在NT中,FS:[0]
的地址指向了TEB结构,这个结构的开头是一个NT_TIB结构,具体(部分)如下:
NT_TIB结构的0x18偏移处是一个Self指针,指向这个结构自身,也就是TEB结构的开头。TEB结构的0x30偏移处是一个指向PEB的指针。
在TEB结构的0xE10偏移处有个字段TlsSlots[]
,是一个无类型的指针数组(TLS存储槽),它的大小是40h字节。也就是说,一个线程同时存在的动态TLS不能超过64项。
可以通过NtCurrentTeb函数调用和FS段寄存器俩种方法来访问TEB结构:
NtCurrentTeb函数调用
FS段寄存器访问
mov eax, dword ptr fs:[18h]
Windows注册表也可以被视为一种特殊的数据结构,它是一个层次结构数据库,用于存储和组织系统配置信息、应用程序设置和其他系统相关的数据。
Windows提供了一些API供应用程序访问注册表,例如RegOpenKeyEx
、RegCreateKeyEx
等。这些API运行开发人员在应用程序中读取、写入、编辑和删除注册表中的键值和数据。
在Win10中通过Win+R输入regedit即可查看注册表。
如上图,Windows注册表是一个树状(层次)结构:
在内核之中,执行体包含了一个称为“配置管理器”的组件,它是注册表的真正实现。