上下文环境
应用层应用程序工作在用户模式,内核驱动程序工作在内核模式。这里的用户模式和内核模式是基于CPU的特权环来定义的,CPU提供了0环~3环(ring 0 ~ ring 3)共四个特权环,Windows操作系统使用了其中的0环和3环,0环为内核模式,3环为用户模式。不同环之间的代码特权不同,访问地址空间也不同。如对0环的指令来说,可以执行特权指令,访问内核模式的地址空间范围。
Privilege rings for the x86 available in protected mode
应用程序有独立进程的概念,比如我们开发一个exe程序,当这个程序运行时,我们清楚地知道程序的代码运行在哪一个线程。
但是对于内核开发来说,进程的概念显得相当模糊,初学者往往不清楚自己的驱动代码具体运行在什么进程或线程中,但搞清楚这些细节是驱动入门的重要途径。
上下文(Context)泛指CPU在执行代码时,该代码所处的环境与状态。这些环境状态包括但不限于:当前代码所属线程、中断请求级别、CPU寄存器各状态等。
在前面的MyFirstDriver示例代码中,里面涉及了两个函数DriverEntry和DriverUnload,这两个函数都是由系统调用的,那么这两个函数被调用时处于哪一个进程中呢。我们使用PsGetCurrentThreadId函数输出当前进程的Id。
1 #include<ntddk.h> 2 3 VOID DriverUnload(PDRIVER_OBJECT DriverObject) 4 { 5 if (DriverObject != NULL) 6 { 7 DbgPrint("[%ws]Driver Unload,Driver Address:%p,CurrentProcessId = 0x%p\n", __FUNCTIONW__, DriverObject, PsGetCurrentProcessId()); 8 } 9 return; 10 } 11 12 extern "C" NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) 13 { 14 if (DriverObject != NULL) 15 { 16 DriverObject->DriverUnload = DriverUnload; 17 } 18 19 if (RegistryPath != NULL) 20 { 21 22 } 23 24 DbgPrint("[%ws]Driver Entry,CurrentProcessId = 0x%p\n", __FUNCTIONW__, PsGetCurrentProcessId()); 25 26 return STATUS_SUCCESS; 27 }
运行结果如下:
可以看到无论是驱动入口函数还是卸载函数 ,都隶属于进程Id为4的进程。在资源管理器中可以看到这个Pid为4的进程名为System。
System进程其实是操作系统虚拟出来的一个进程,代表系统内核。一般来说内核代码都处于SYSTEM进程空间中,但是驱动对象的派遣例程一般工作在发起请求的进程中。
与上下文概念相关联的是地址空间
对32位系统来说,应用层程序有独立的2GB低地址空间,这2GB地址是虚拟地址,不同进程之间相互独立,互不影响,而高地址的2GB是内核共享的地址空间。
64位系统与32位系统类似,在64位Windows中,虚拟地址空间的理论大小为2^64字节,但实际仅使用2^64字节范围的一小部分,范围从0x00000000000至0x7FFFFFFFFFF的8TB用于应用层空间,范围从0xFFFF080000000000至0xFFFFFFFFFFFFFFFF的248TB用于内核空间。
关于独立的应用层地址空间与共享的内核地址空间的区别
比如有两个进程P1和P2,在各自进程空间内修改了0x00001234处的内容
对于应用层:P1只能看到自己修改后的内容,P2也只能看到自己修改后的内容,相互不影响
对于内核层:内核空间是共享的,所以对于两个驱动程序来说,驱动P1修改内核某个地址的内容,驱动P2可以读取到驱动P1修改后的内容。
所以我们在书写代码时必须清楚自己的代码在运行时所对应的上下文,以免造成驱动异常。这一点很重要
驱动异常
我们刚开始学习内核开发时,难免会遇到由于代码编写不合规而引发系统崩溃的问题,系统崩溃具体表现为蓝屏(BSOD)。
蓝屏是Windows系统遇到无法处理的异常或错误时,为了避免错误进一步扩大而触发的保护机制。
蓝屏发生时系统将无法继续运行,业务中断,还可能会产生磁盘文件被破坏等一系列问题。
Windows XP 蓝屏界面
驱动异常的原因有很多,常见的有:高IRQL死锁、内存访问违例、函数堆栈不平衡等等。
不管是由哪种原因导致的蓝屏,系统都会报告一个异常码。
如上图中:0x0000008E为异常码,0x0000008E后面括号内的四个值为附加参数,不同异常码和附加参数
我们可以根据这些信息来初步定位异常的类型与发生的大致原因。具体的细节可以参考WDK文档。
错误检查代码参考 - Windows drivers | Microsoft Learn
除了蓝屏界面显示的异常码,若条件允许,系统会在系统目录生成一个DUMP文件,这个DUMP文件内部保存着蓝屏时刻的异常信息,包括内存、寄存器、异常记录等,我们可以根据DUMP文件中提供的蛛丝马迹来回溯问题。
可以参考下面的文章:
https://www.cnblogs.com/zhaotianff/p/15150244.html
字符串操作
在用户模式编程中,使用的字符串主要是Unicode和Ascii
在内核模式编程中,大部分是使用的Unicode。
但与应用层不同的是,内核层一般不直接使用WCHAR类型的Unicode字符串,而是使用UNICODE_STRING类型来表示Unicode。
UNICODE_STRING是内核中表示字符串的结构体,定义如下:
1 typedef struct _UNICODE_STRING { 2 USHORT Length; 3 USHORT MaximumLength; 4 PWSTR Buffer 5 }UNICODE_STRING, *PUNICODE_STRING
其中Buffer为一个指针,指向一个UNICODE类型的字符串缓冲区;
MaximumLength表示Buffer所指向缓冲区的总空间大小,一般等于Buffer被分配时的内存大小,单位为字节
Length表示Buffer所指向缓冲区中字符串的长度,单位也是字节。
注意:Buffer指向的字符串,并不要求以'\0'作为结束。
UNICODE_STRING的常用操作
初始化
RtlInitUnicodeString,这个函数的作用是把一个以'\0'结尾的WCHAR类型的Unicode字符串初始化成UNICODE_STRING类型的字符串
1 VOID 2 NTAPI 3 RtlInitUnicodeString( 4 _Out_ PUNICODE_STRING DestinationString, 5 _In_opt_z_ __drv_aliasesMem PCWSTR SourceString 6 );
参数:
DestinationString:指向要初始化 的UNICODE_STRING 结构的指针
SourceString:指向以 null 结尾的宽字符字符串的指针。 此字符串用于初始化 DestinationString 指向的字符串。
下面是RtlInitUnicodeString的简单使用示例:
1 UNICODE_STRING str{}; 2 RtlInitUnicodeString(&str, L"HelloWorld"); 3 DbgPrint("String:%wZ", &str);
RtlInitUnicodeString函数的使用非常简单,但需要注意的是,RtlInitUnicodeString函数并没有为str.Buffer申请内存,而是令str.Buffer指向字符串L"HelloWorld"的首地址,所以使用RtlInitUnicodeString初始化后,在使用str期间,要保证SourceString有效。
拷贝操作
使用RtlUnicodeStringCopyString函数,可以实现UNICODE_STRING的拷贝操作。
RtlUnicodeStringCopyString函数原型如下:
头文件:ntstrsafe.h
1 NTSTRSAFEDDI 2 RtlUnicodeStringCopyString( 3 _Inout_ PUNICODE_STRING DestinationString, 4 _In_ NTSTRSAFE_PCWSTR pszSrc)
RtlUnicodeStringCopyString函数把以'\0'结尾的字符串pszSrc拷贝到DestinationString中。
虽然这个函数的功能看起来与前面的RtlInitUnicodeString函数功能类似,但是两者存在本质上的区别:
RtlInitUnicodeString函数内部只是简单地使DestinationString.Buffer指向函数的第二个参数SourceString,没有任何的拷贝操作。
RtlUnicodeStringCopyString函数会把pszSrc字符串拷贝到DestinationString所指向的内存中。
下面是RtlUnicodeStringCopyString的简单使用示例
1 WCHAR strBuffer[128]{}; 2 UNICODE_STRING str{}; 3 RtlInitEmptyUnicodeString(&str, strBuffer, sizeof(strBuffer)); 4 RtlUnicodeStringCopyString(&str, L"HelloWorld"); 5 DbgPrint("%wZ", &str);
RtlUnicodeStringCopyString返回NTSTATUS类型,只能在PASSIVE_LEVEL级别的IRQL中运行。
链表
链表是内核开发中常见的数据结构。主要分为单向链表和双向链表。
单向链表:只有一个链表节点指针,该指针指向后面一个链表节点
两向链表:有两个链表节点指针,分别指向前一个链表节点以及后一个链表结点 。
这里重点学习双向链表(后续直接简称为“链表”)。
WDK中的链表定义如下:
1 typedef struct _LIST_ENTRY { 2 struct _LIST_ENTRY *Flink; 3 struct _LIST_ENTRY *Blink; 4 } LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;
这不同于我们在数据结构中学习的链表结构,因为它不包含数据字段。
我们通过下面的例子来对链表进行深入的了解。
假设我们定义了一个ListPos结构体,用于存储位置信息。
1 typedef struct _ListPos 2 { 3 int m_nX; 4 int m_nY; 5 }ListPos, * PListPos;
现在需要把ListPos做为一个链表结点,具体的做法是把LIST_ENTRY作为ListPos的一个成员,如下:
1 typedef struct _ListPos 2 { 3 int m_nX; 4 int m_nY; 5 LIST_ENTRY m_listEntry; 6 }ListPos, * PListPos;
说明:m_listEntry可以放在结构体的任意位置,没有强制要求。
在x64系统下,ListPos结构体的内存布局如下:
ListPos结构体在x64系统下的内存布局
多个ListPos结构体的关系
在实际情况下,为了方便操作,会定义一个链表头结点,头节点不包含任何内容,只是一个LIST_ENTRY结构,如下图所示:
带头节点的链表
下面我们看一下链表的使用方法
头节点初始化
链表头节点没有任何内容,只表示链表的头部(永远都是起始位置),对链表的所有操作都是从头部开始的。
当链表只有一个头节点而没有其它节点时,该链表就是一个空的链表。Flink和Blink指向头节点自身。
使用头节点初始化函数InitializeListHead来对头节点进行初始化,初始化的作用就是修改Flink和Blink的值,使其指向自身。
InitializeListHead定义如下:
1 FORCEINLINE 2 VOID 3 InitializeListHead( 4 _Out_ PLIST_ENTRY ListHead 5 ) 6 7 { 8 9 ListHead->Flink = ListHead->Blink = ListHead; 10 return; 11 }
使用方法如下:
1 LIST_ENTRY lst_Header{}; 2 InitializeListHead(&lst_Header);
节点插入
节点的插入可以分为两种
1、将节点插入到链表头部位置,使用的是InsertHeadList函数,声明如下:
1 FORCEINLINE 2 VOID 3 InsertHeadList( 4 _Inout_ PLIST_ENTRY ListHead, 5 _Out_ __drv_aliasesMem PLIST_ENTRY Entry 6 );
2、将节点插入到尾部位置,使用的是InsertTailList,声明跟上面的函数声明类似,仅仅是函数名不一样。
这里再次需要注意的是,头节点仅表示头部,节点的插入永远都是在头节点的后面(它的身体部分),这跟我们以前在数据结构里学习的链表有一点不同。
下面看一下简单的使用:
1 LIST_ENTRY lst_Header{}; 2 InitializeListHead(&lst_Header); 3 4 ListPos p_A{}; 5 p_A.m_nX = 0; 6 p_A.m_nY = 0; 7 8 ListPos p_B{}; 9 p_B.m_nX = 1; 10 p_B.m_nY = 1; 11 12 ListPos p_C{}; 13 p_C.m_nX = 2; 14 p_C.m_nY = 2; 15 16 InsertHeadList(&lst_Header, &p_B.m_listEntry); 17 InsertHeadList(&lst_Header, &p_A.m_listEntry); 18 InsertTailList(&lst_Header, &p_C.m_listEntry);
这里先对头节点进行初始化,把节点B插入到链表中,在插入前链表为空,插入后链表中存在一个节点B,紧接着使用lnsertHeadList函数把节点A插入到链表最前面,此时链表中的节点为:头节点→节点A→节点B,最后使用InsertTailList函数,把节点C插入到链表的最后面,插入完成后的链表:头节点→节点A→节点B→节点C。(头节点永远在前面)
链表遍历
其实到这里我们还是有疑问的,因为链表是串连起来了,但是这个数据怎么去取呢。
我们接着往下看。
我们从头节点向后遍历,代码如下:
1 PLIST_ENTRY pListEntry = lst_Header.Flink; 2 while (pListEntry != &lst_Header) 3 { 4 PListPos pListPos = CONTAINING_RECORD(pListEntry, ListPos, m_listEntry); 5 DbgPrint("ListEntry = %p,ListPos = %p,x = %d y = %d", 6 pListEntry, pListPos, pListPos->m_nX, pListPos->m_nY); 7 pListEntry = pListEntry->Flink; 8 }
遍历步骤如下:
1、定义pListEntry指针变量用于遍历。把ListHeader.Flink的值赋给pListEntry,此时pListEntry指向链表中的第一个节点。
2、在 while循环中,首先访问节点的数据,然后让pListEntry指向下一个节点,循环结束的条件是pListEntry ==&ListHeader。
3、通过CONTAINING_RECORD宏把 m_ListEntry的地址转换成结构体ListPos的首地址。
(pListEntry指向的地址是ListPos结构体中的m_ListEntry地址,而m_ListEntry成员的地址并不是这个结构体的首地址)
第3步是获取数据的关键步骤,主要通过CONTAINING_RECORD宏完成,CONTAINING_RECORD宏的用法如下:
1 #define CONTAINING_RECORD(address, type, field)
Address:表示LIST_ENTRY的地址,就是上面代码中pListEntry指向的地址
Type:表示类型,ListPos,就是我们前面定义的带数据和节点信息的结构体。
Field:表示结构体中LIST_ENTRY成员的名字。就是上面代码中的m_ListEntry。
CONTAINING_RECORD宏通过Type 与Field这两个成员,计算出Field成员距离结构体顶部的内存距离,然后结合具体的Address成员,算出最终的结构体首地址,WDK对CONTAINING_ RECORD宏的定义如下:
1 #define CONTAINING_RECORD(address, type, field) ((type *)( \ 2 (PCHAR)(address) - \ 3 (ULONG_PTR)(&((type *)0)->field)))
运行上面的代码,输出 结果如下:
可以看到ListEntry和ListPos的距离(m_ListEntry与结构体首地址的内存差距)为8字节,和实际定义的情况一致。
节点移除
移除节点有三种方式:
1、移除链表中的第一个节点,使用RemoveHeadList,函数声明如下:
1 RemoveHeadList( 2 _Inout_ PLIST_ENTRY ListHead);
ListHead:表示头节点
返回值:成功移除返回从链表移除的节点指针,如果无节点可以移除,返回NULL
使用方法如下:
1 RemoveHeadList(&lst_Header);
2、移除链表中特定的节点,使用RemoveEntryList函数,声明如下:
1 RemoveEntryList( 2 _In_ PLIST_ENTRY Entry 3 )
Entry:表示 要移除的链表节点指针。
返回值:Boolean,如果链表在节点移除后,变成了空链表,就返回TRUE,否则返回FALSE
如果需要判断一个链表是否为空(只有头节点),可以使用IsListEmpty函数,声明如下:
1 IsListEmpty( 2 _In_ const LIST_ENTRY * ListHead 3 );
ListHeader:表示头节点
返回值:Boolean,True表示链表为空,FALSE表示链表非空
3、移除链表中的最后一个节点,使用RemoveTailList,这个函数的声明和使用与RemoveHeadList类似。
参考资料
https://en.wikibooks.org/wiki/Windows_Programming/User_Mode_vs_Kernel_Mode#:~:text=Ring%200%20(also%20known%20as,has%20restricted%20access%20to%20resources.
https://stackoverflow.com/questions/6710040/cpu-privilege-rings-why-rings-1-and-2-arent-used