驱动开发

环境配置

开发环境

在开发驱动程序之前,我们需要配置好开发环境, 首先安装好VS IDE(这里自己选择版本),其次因为我们需要开发驱动程序所以需要安装WDK(WDK下载地址:https://docs.microsoft.com/zh-cn/windows-hardware/drivers/other-wdk-downloads),在我们安装WDK时候需要注意其版本应与SDK的版本一致。我们可以通过控制面板-程序卸载,找到当前VS IDE安装的SDK版本,如下所示,我的系统上SDK的版本是17763:

images/download/attachments/1015847/image2022-9-1_10-43-35.png

接着你需要在WDK的下载地址中选择对应系统的安装包,由于我当前是Windows 10 21H2版本,但是页面中并没有对应的版本选择,所以我把所有Windows10的WDK安装包都下载下来了:

images/download/attachments/1015847/image2022-9-1_10-45-40.png

安装包依次打开,就找到了与SDK对应版本的WDK安装包,接着按步骤安装即可:

images/download/attachments/1015847/image2022-9-1_10-47-28.png

项目配置

安装好开发环境之后我们打开VS2017,新建项目并选择VC++下的Wimdows Drivers,创建Empty WDM Driver项目:

images/download/attachments/1015847/image2022-9-1_15-13-20.png

创建完项目之后,进入到项目属性,按如下图所示进行配置:

images/download/attachments/1015847/image2022-9-1_15-19-12.png

images/download/attachments/1015847/image2022-9-1_15-19-26.png

images/download/attachments/1015847/image2022-9-1_15-19-42.png

images/download/attachments/1015847/image2022-9-1_15-19-51.png

至此,我们所有的配置工作就搞定了。

Hello Driver

驱动程序代码

当我们配置好驱动开发环境及项目之后,可以创建代码文件,但需要注意的是我们在学习阶段时候建议代码文件使用C语言,这样编译器就不会进行太多的优化,也便于我们调试:

images/download/attachments/1015847/image2022-9-1_15-22-35.png

接着我们将项目代码的警告配置修改一下:

images/download/attachments/1015847/image2022-9-1_15-31-11.png

然后我们开始写自己的第一个驱动程序代码,最基本的格式就是包含ntddk.h头文件,以及写好驱动程序入口函数DriverEntry:

#include <ntddk.h> // 必须要包含的头文件
 
// 自定义的驱动程序卸载函数
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
DbgPrint("Bye. \n");
}
 
// 驱动程序入口函数
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
DbgPrint("Hello Driver. \n");
 
// 设置一个卸载函数,当驱动停止时触发
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}

最后我们编译代码,在项目的Debug目录下找到.sys文件,这就是我们编译出来的驱动程序:

images/download/attachments/1015847/image2022-9-1_16-56-23.png

使用驱动程序

将编译好的驱动程序、KdmManager、DebugView拖到XP虚拟机中(在虚拟机中去调试驱动比实体机中方便),使用KdmManager加载驱动,按下图所示步骤依次进行,于此同时也要打开DebugView,同时开启监听内核的选项:

images/download/attachments/1015847/image2022-9-2_10-51-43.png

images/download/attachments/1015847/image2022-9-2_10-52-52.png

当我们依次进行注册、运行、停止、卸载,可以清晰的在DebugView中观察到我们驱动程序所输出的内容,并且我们也知道了函数执行的流程:当点击Run按钮时进入驱动程序入口函数,当点击Stop按钮时进入自定义的驱动程序卸载函数:

images/download/attachments/1015847/image2022-9-2_10-54-23.png

调试驱动程序

调试环境

调式驱动程序不像应用程序一样简单(直接在OD之类的调试工具中下断点),要想调试驱动程序就需要使用双机调试,在虚拟机中运行驱动程序,在实体机上使用0环调试器(也就是WinDbg)进行调试。

之前我们进行双机调试配置时,需要手动修改文件然后重启,这个步骤相对来说比较繁琐,我们可以通过VirtualKD(开源项目地址:https://github.com/4d61726b/VirtualKD-Redux)程序来简化整个步骤。

如下图所示就整个程序的目录结构,target32/64目录下的文件就是根据虚拟机操作系统的类型选择并复制进虚拟机的,vmmon64.exe就是主程序,直接实体机打开:

images/download/attachments/1015847/image2022-9-2_13-51-56.png

我的虚拟机系统是Windows XP 32位,所以我将target32目录放入虚拟机中,并且按如下步骤操作:

双击运行vminstall.exe-点击Install按钮-点击“是”-系统重启-选择VKD-Redux启动

images/download/attachments/1015847/image2022-9-2_13-56-23.png

于此同时打开vmmon64.exe,选择好对应的Windbg程序路径(一般情况下程序会寻找默认Windbg程序路径):

images/download/attachments/1015847/image2022-9-2_13-59-16.png

最后进入调试模式,管道连接上会自动打开Windbg程序:

images/download/attachments/1015847/image2022-9-2_14-0-32.png

PDB文件

无论是OD还是Windbg,都需要符号文件的加持才能在逆向时获得到更多的信息,例如你用OD去调试程序时经常可以看到一些Windows API是以函数名称的形式存在于反汇编里的,这就是符号文件起的作用。

PDB文件也就是符号文件,我们在编译程序时候,只要不取消调试信息的输出,默认情况下是可以在编译输出的目录中找到所编译程序的PDB文件的,例如我们上文中编译的驱动程序的输出目录下就有对应的PDB文件。

images/download/attachments/1015847/image2022-9-6_22-37-49.png

我们要想在Windbg中调试驱动程序就需要这个PDB文件,但是在这之前我们需要先在驱动代码内写上内联汇编,用于断点:

images/download/attachments/1015847/image2022-9-6_22-42-50.png

编译之后将文件放入虚拟机,以及在Windbg重载符号文件,按如下图操作填入PDB文件所在路径即可:

images/download/attachments/1015847/image2022-9-6_22-58-51.png

接着在虚拟机中用KdmManager加载并运行驱动程序,运行时(KdmManager软件中点击Run按钮)就会在Windbg中断下来,如下图在Windbg中新建了一个窗口(左边的窗口)展示当前驱动程序断点的位置,并且都是以源代码形式展示给我们的,这种效果与我们在调试应用程序时用VS是一样的:

images/download/attachments/1015847/image2022-9-6_22-45-35.png

基础内容

内核API的使用

在应用层编程时我们可以通过包含Windows.h这个头文件来使用Windows提供的API,但是在内核编程时我们不可以使用应用层的API,而要使用内核专用的API,所以我们需要包含的头文件就变成了ntddk.h(需要安装好WDK)。

images/download/attachments/1015847/image2022-9-7_15-38-23.png

与应用程序开发一样,我们想要去了解某个内核API的信息,也需要查阅文档,老版本的WDK安装之后会自带文档信息,较新一点的WDK不会自带,我们需要去官网查看:https://docs.microsoft.com/zh-cn/windows-hardware/drivers/ddi/

images/download/attachments/1015847/image2022-9-7_15-48-5.png

未导出函数的使用

WDK文档里只包含了导出的函数信息,对于未导出的函数是查阅不到相关资料的(我们也可以将其称之为未文档化函数)。如果我们想使用未导出的函数,需要定义一个函数指针,并且为函数指针提供正确的函数地址就可以使用了。未导出函数的地址可以通过特征码搜索、解析内核的PDB文件来找到。

基本数据类型的使用

在内核编程的时候,必须要遵守WDK的编码习惯,例如无符号类型不要在类型前加上关键词unsigned,而是要遵循WDK自己的类型:

WDK的写法

表达的意思

ULONG

unsigned long

UCHAR

unsigned char

UINT

unsigned int

VOID

void

PULONG

unsigned long *

PUCHAR

unsigned char *

PUNIT

unsigned int *

PVOID

void *

如果你按后者去写,在不同的平台上移植代码可能会导致数据宽度不同,要修改到相同宽度就需要修改代码,而按WDK的写法就可以减少这种不必要的情况。

返回值

大部分内核函数的返回值都是NTSTATUS类型,它本质是一个宏,里面包含的类型有很多,如下三个就是常见的返回值:

宏名称

实际值

含义

STATUS_SUCCESS

0x00000000

成功

STATUS_INVALID_PARAMETER

0xC000000D

参数无效

STATUS_BUFFER_OVERFLOW

0x80000005

缓冲区长度不够

当你调用的内核函数返回结果不是STATUS_SUCCESS,那就说明函数在执行时遇到了问题,具体的问题可以根据返回值在ntstatus.h头文件中去寻找:

images/download/attachments/1015847/image2022-9-7_16-7-36.png

通过宏的名称也能知道个大概,如果仍然不知道问题的含义,可以在微软的WDK文档中去搜索相关宏名称。

内核中的异常处理

在内核中一个小小的错误就可能导致蓝屏,例如我们去读写一个无效的内存地址。为了让自己的内核程序更加健壮,在编写内核程序时要使用到异常处理。

在Windows下提供了结构化异常处理机制,编译器普遍都支持,如下就是代码使用方法:

__try
{
// 填入可能要出错的代码
}
__except (filter_value)
{
// 填入出错后要执行的代码
}

如上示例中的filter_value,就是当内核程序出现异常时决定程序如何执行的,一般有这三种情况:

宏名称

实际值

含义

EXCEPTION_EXECUTE_HANDLER

1

进入except代码块执行

EXCEPTION_CONTINUE_SEARCH

0

不处理异常,由上一层调用函数处理

EXCEPTION_CONTINUE_EXECUTION

-1

继续执行错误处的代码

常用的内核内存函数

编写内核程序是无法避免去操作内存的,对内存的使用主要就是申请、设置、拷贝、释放,常用的内核函数如下(应用层与内核层的对比):

应用

内核

malloc

ExAllocatePool

memset

RtlFillMemory

memcpy

RtlMoveMemory

free

ExFreePool

内核字符串种类

在内核中我们的字符串种类变多了,有CHAR、WCHAR、ANSI_STRING、UNICODE_STRING。前两个就是char和wchar_t在内核的写法,但是一般不建议使用这种写法而使用后两个(后两个即前两个的升级版),因为在内核中使用内存是要非常小心的,如果使用前两个类型字符串是没有控制长度的,即字符串到0x0或两个0x0就截止,但如果读写字符串时没有遵循这个规则读取越界了就会导致蓝屏。

ANSI_STRING字符串的定义如下:

typedef struct _STRING {
USHORT Length;
USHORT MaximumLength;
#ifdef MIDL_PASS
[size_is(MaximumLength), length_is(Length) ]
#endif // MIDL_PASS
_Field_size_bytes_part_opt_(MaximumLength, Length) PCHAR Buffer;
} STRING;
 
typedef STRING ANSI_STRING;

UNICODE_STRING字符串的定义如下:

typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
#ifdef MIDL_PASS
[size_is(MaximumLength / 2), length_is((Length) / 2) ] USHORT * Buffer;
#else // MIDL_PASS
_Field_size_bytes_part_opt_(MaximumLength, Length) PWCH Buffer;
#endif // MIDL_PASS
} UNICODE_STRING;

我们从这两个结构体的定义就可以看到他们的结构是一样的,我们去读写字符串时就可以根据Buffer+Length这两个成员精确的读取字符串,避免出现读取越界导致蓝屏的问题。

内核字符串常用函数

字符串的操作就是创建、复制、比较、转换,但由于编码问题在内核中也有不同的函数表达:

ANSI_STRING字符串

UNICODE_STRING字符串

含义

RtlInitAnsiString

RtlInitUnicodeString

创建字符串

RtlCopyString

RtlCopyUnicodeString

字符串复制

RtlCompareString

RtlCompareUnicoodeString

字符串比较

RtlAnsiStringToUnicodeString

RtlUnicodeStringToAnsiString

编码转换

内核空间与模块

内核空间

在之前的学习中我们反复提到一个进程有4GB大小的内存空间,低2G是程序自己的,高2G是共享的,也就是内核空间。现在我们已经知道一个简单的驱动程序从开发到运行的流程,可以做一个实验来论证“高2G是共享的”。

我们可以先编写一个驱动程序,主要作用是定义一个全局变量,然后运行时输出其地址:

images/download/attachments/1015847/image2022-9-7_21-37-9.png

接着我们在虚拟机中运行该驱动程序获取全局变量的地址:

images/download/attachments/1015847/image2022-9-7_22-13-38.png

得到地址为0xBAB63000,然后在Windbg中使用!process 0 0随便找个程序,进入它的内存空间,查看该内存空间中的地址0xBAB63000内容:

images/download/attachments/1015847/image2022-9-7_22-17-12.png

images/download/attachments/1015847/image2022-9-7_22-17-26.png

最终结果如上所示,我们看到在Dbgview.exe进程的内存空间中,其地址0xBAB63000内容存储着我们驱动程序定义的内容,也就证实了4GB空间中高2G是共享的,这高2G的空间就是的内核空间

内核模块

由于硬件的种类很多,导致系统内核无法完全兼容所有的迎接,所以微软提供了接口,让开发人员按规定格式编写驱动程序来支持自己的硬件;这些驱动程序每一个我们都可以当作是一个模块,也就可以称之为内核模块,它们遵循PE结构,并且可以加载到内核中。

我们之前编写驱动程序的时候必须要写好两个函数,一个是入口函数,一个是卸载函数。在入口函数中,有两个形参,分别是PDRIVER_OBJECT、PUNICODE_STRING。

我们来看一下PDRIVER_OBJECT,它是一个结构体,我们称之为驱动对象,可以通过VS或者Windbg去查看这个结构体的成员:

images/download/attachments/1015847/image2022-9-9_14-47-22.png

这个结构体里主要包含驱动程序的一些基础信息,具体的可以看如下代码注释:

typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart; // 结构体对应的驱动程序在内核空间的位置
ULONG DriverSize; // 结构体对应的驱动程序的大小
PVOID DriverSection; // 指针,指向_LDR_DATA_TABLE_ENTRY结构体
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName; // 结构体对应的驱动程序的名字
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload; // 定义驱动程序卸载函数的地址
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
 
} DRIVER_OBJECT;
typedef struct _DRIVER_OBJECT *PDRIVER_OBJECT;

我们可以编写一个驱动程序输出对应驱动对象的地址来看下这几个成员:

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
 
DbgPrint("%x \n", DriverObject);
// 设置一个卸载函数,当驱动卸载时触发
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}

images/download/attachments/1015847/image2022-9-9_17-6-28.png

在Windbg中使用如下格式命令可以很直观的看到结构体中每个成员对应的内容:

dt _DRIVER_OBJECT 驱动对象地址

images/download/attachments/1015847/image2022-9-9_17-8-31.png

我们也可以利用DriverSection指向的结构体获取其他内核模块的信息,因为它指向的是_LDR_DATA_TABLE_ENTRY结构体,在该结构体内有一个成员InLoadOrderLinks是双链表,它记录着前一个和后一个内核模块的_LDR_DATA_TABLE_ENTRY结构体地址:

images/download/attachments/1015847/image2022-9-9_17-14-16.png

应用与驱动通信

本章我们主要了解在正常的开发中R3应用是如何与R0驱动进行通信的,在这之前我们需要先学习一些新的概念来补全前置内容。

设备对象

我们在开发窗口应用程序时,消息被封装成一个结构体MSG,在开发内核驱动程序时,消息被封装成另外一个结构体IRP(I/O Request Package,输入或输出请求包)。

在窗口应用程序中,能够接收MSG消息的只能是窗口对象,在内核驱动程序中,能够接受IRP消息的只能是设备对象。

images/download/attachments/1015847/image2022-9-13_20-11-38.png

通信实现

创建设备对象

正常情况下,一个设备对象是对应一个设备的,如:鼠标、键盘。但是设备对象也可以是一个抽象的概念,不对应到具体某个硬件,也就是我们可以使用如下代码去创建一个设备对象。

// 创建设备名称
UNICODE_STRING DeviceName;
RtlInitUnicodeString(&DeviceName, L"\\Device\\MyDevice"); // 设备名称
PDEVICE_OBJECT pDeviceObj = NULL;
// 创建设备对象
NTSTATUS ioCreateDevRet = IoCreateDevice(
DriverObject, // 调用方驱动程序对象
0,
&DeviceName, // 设备名称
FILE_DEVICE_UNKNOWN, // 设备类型,当前不与某个具体设备挂钩,所以类型为UNKNOWN
FILE_DEVICE_SECURE_OPEN, // 设备属性,大多数驱动程序仅指定 FILE_DEVICE_SECURE_OPEN 属性, 这可确保将相同的安全设置应用到设备的命名空间中的任何打开的请求
FALSE, // 设备对象是否表示独占设备,如果启用了对设备的独占访问,则一次只能打开设备的一个句柄
&pDeviceObj // 创建的设备对象,指向接收指向新创建的 DEVICE_OBJECT 结构的指针的变量的指针
);
// 判断IoCreateDevice函数是否执行成功
if (ioCreateDevRet != STATUS_SUCCESS)
{
DbgPrint("IoCreateDevice Error, code: %s\n", ioCreateDevRet);
return ioCreateDevRet;
}

数据交换配置

创建好设备对象之后,就需要设置0环和3环交换数据的方式,有以下三种:

  1. 缓冲区读写(DO_BUFFERED_IO),操作系统将应用程序提供缓冲区的数据直接复制到内核模式下的地址中;

  2. 直接读写(DO_DIRECT_IO),操作系统会将用户模式下的缓冲区锁住,然后操作系统将这段缓冲区在内核模式地址再次映射一遍,这样用户模式的缓冲区和内核模式的缓冲区指向的是同一区域的物理内存,缺点就是要单独占用物理页面;

  3. 其他读写,在调用IoCreateDevice函数创建设备后不设置交换数据模式即默认为其他方式读写,在使用其他方式读写设备时,派遣函数直接读写应用程序提供的缓冲区地址。在驱动程序中,直接操作应用程序的缓冲区地址是很危险的(不建议使用),只有驱动程序与应用程序运行在相同线程上下文的情况下,才能使用这种方式。

我们可以通过设备对象的Flags成员设置交换数据的方式:

// 设置交换数据方式
pDeviceObj->Flags |= DO_BUFFERED_IO; // 缓冲区读写

设备符号链接

在上述代码中我们创建的设备名称是给0环使用的,如果要在3环访问到设备就需要通过符号链接,我们可以理解为它就是一个设备的别名,如果不这样设置则在3环是无法访问到设备的。我们可以通过IoCreateSymbolicLink函数来创建符号链接,它与设备名称一样,传递的名称需要以UNICODE编码。

// 创建符号链接
UNICODE_STRING SymbolicLinkName;
RtlInitUnicodeString(&SymbolicLinkName, L"\\??\\MyTestDriver");
IoCreateSymbolicLink(&SymbolicLinkName, &DeviceName);

需要注意的是,在内核模式下符号链接的名称是以“\??\”开头,但是在用户模式下则是以“\\.\”开头,所以在3环代码中符号链接名称应写为“\\.\MyTestDriver”。(记得在实际代码中转义特殊符号)

派遣函数

接下来就是编写IRP类型对应的派遣函数了,在这之前我们回顾一下窗口程序的消息流程,当你单击鼠标会产生MSG消息,该消息发送给窗口对象,在窗口对象内会根据消息序号找到对应的处理函数进行处理。同理,在驱动程序中,当你在3环使用了某个函数就会产生IRP消息,该消息发送给设备对象,在设备对象内会根据消息类型选择对应的派遣函数处理。

images/download/attachments/1015847/image2022-9-13_22-45-57.png

常见的IRP类型与其对应的3环下的函数及作用如下所示:

IRP类型

来源函数

函数作用

IRP_MJ_CREATE

CreateFile

打开设备

IRP_MJ_READ

ReadFile

从设备中读取数据

IRP_MJ_WRITE

WriteFile

从设备中写入数据

IRP_MJ_CLOSE

CloseHandle

关闭设备

IRP_MJ_DEVICE_CONTROL

DeviceIoControl

设备控制,比读取、写入操作更加灵活

IRP_MJ_POWER

X

在操作系统处理电源消息时产生该类型

IRP_MJ_SHUTDOWN

X

关闭系统前会产生该类型

在驱动对象中有一个成员MajorFunction,它是一个具有28个成员的数组,对应着就是28种IRP类型:

images/download/attachments/1015847/image2022-9-13_22-54-57.png

所以我们想注册某个IRP类型对应的派遣函数时候可以使用如下格式:

DriverObject->MajorFunction[IRP类型] = 派遣函数名;

派遣函数也是有一个固定格式的:

NTSTATUS MyDispatchFunction(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
// 业务代码
 
// 设置返回状态
pIrp->IoStatus.Status = STATUS_SUCCESS; // Get'Last()得到的就是这个值
pIrp->IoStatus.Information = 0; // 返回给3环多少数据,没有则填0
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}

我们可以先定义好CreateFile和CloseHandle的派遣函数(实际测试发现是必须要定义,否则无法打开设备):

// 注册派遣函数
DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateDispatchFunc;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = CloseDispatchFunc;
 
NTSTATUS CreateDispatchFunc(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
DbgPrint("CreateDispatchFunc ... \n");
// 设置返回状态
pIrp->IoStatus.Status = STATUS_SUCCESS;
pIrp->IoStatus.Information = 0; // 返回给3环多少数据,没有则填0
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
 
NTSTATUS CloseDispatchFunc(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
DbgPrint("CloseDispatchFunc ... \n");
// 设置返回状态
pIrp->IoStatus.Status = STATUS_SUCCESS;
pIrp->IoStatus.Information = 0; // 返回给3环多少数据,没有则填0
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}

灵活通信

为了更灵活的通信,我们可以使用DeviceIoControl函数,该函数在应用层使用,可以用来与内核层传输数据。我的应用层代码如下,根据代码注释我们知道该函数的参数作用,其中操作码我们也可以理解为是一个密码,内核层也应定义操作码,根据操作码来执行不同的指令:

#include <windows.h>
#include <winioctl.h>
#include <stdio.h>
 
HANDLE g_hDevice;
 
int main(int argc, char* argv[])
{
// 通过符号链接打开设备
g_hDevice = ::CreateFile("\\\\.\\MyTestDriver", GENERIC_WRITE|GENERIC_READ, 0, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
DWORD errCode = ::GetLastError();
if (errCode != 0)
{
printf("Error: %d \n", errCode);
exit(0);
}
// 与驱动进行通信
DWORD dwInBuffer = 0x11112222;
DWORD tcOutBuffer = 0;
DWORD dwLength;
 
::DeviceIoControl(
g_hDevice, // 设备句柄
// 操作码
CTL_CODE(
FILE_DEVICE_UNKNOWN, // 设备类型
0x800, // 不可以使用0x0-0x7FF这个范围,你可以使用0x800-0xFFF
METHOD_BUFFERED, // 数据交换的方式
FILE_ANY_ACCESS // 访问模式
),
&dwInBuffer, // 输入缓冲区地址
0x10, // 输入缓冲区长度
&tcOutBuffer, // 输出缓冲区地址
0x10, // 输出缓冲区长度
&dwLength, // 实际返回长度
NULL // 指向OVERLAPPED, 此处为NULL
);
 
printf("%x \n", tcOutBuffer);
 
CloseHandle(g_hDevice);
return 0;
}

内核层代码如下,在派遣函数DeviceDispatchFunc中,我们通过参数pIrp获取IRP数据,并根据操作码进入读取数据、写入数据指令:

#include <ntddk.h> // 必须要包含的头文件
 
// 操作码定义
#define OPER CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
 
// 自定义的驱动程序卸载函数
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
DbgPrint("Bye. \n");
}
 
NTSTATUS CreateDispatchFunc(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
DbgPrint("CreateDispatchFunc ... \n");
// 设置返回状态
pIrp->IoStatus.Status = STATUS_SUCCESS;
pIrp->IoStatus.Information = 0; // 返回给3环多少数据,没有则填0
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
 
NTSTATUS CloseDispatchFunc(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
DbgPrint("CloseDispatchFunc ... \n");
// 设置返回状态
pIrp->IoStatus.Status = STATUS_SUCCESS;
pIrp->IoStatus.Information = 0; // 返回给3环多少数据,没有则填0
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
 
NTSTATUS DeviceDispatchFunc(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
DbgPrint("DeviceDispatchFunc ... \n");
ULONG uWrite;
ULONG uRead;
NTSTATUS retStatus;
 
// 获取IRP数据
PIO_STACK_LOCATION pIrpStack = IoGetCurrentIrpStackLocation(pIrp);
// 获取操作码
ULONG uIoControlCode = pIrpStack->Parameters.DeviceIoControl.IoControlCode;
// 获取输入、输出数据的长度
// ULONG uDataInLength = pIrpStack->Parameters.DeviceIoControl.InputBufferLength;
// ULONG uDataOutLength = pIrpStack->Parameters.DeviceIoControl.OutputBufferLength;
// 获取缓冲区地址(输入、输出的缓冲区都是一个)
PVOID pIoBuffer = pIrp->AssociatedIrp.SystemBuffer;
 
// 判断操作码
switch (uIoControlCode)
{
case OPER:
// DbgPrint("Size: %d \n", uDataOutLength);
// 从缓冲区读取数据
memcpy(&uRead, pIoBuffer, 4);
DbgPrint("uRead: %x \n", uRead);
// 写入数据至缓冲区
uWrite = 0x22334455;
memcpy(pIoBuffer, &uWrite, 4);
pIrp->IoStatus.Information = 4; // 返回给3环多少数据,没有则填0
retStatus = STATUS_SUCCESS;
break;
}
// 设置返回状态
pIrp->IoStatus.Status = retStatus == STATUS_SUCCESS ? retStatus : STATUS_SUCCESS;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
 
// 驱动程序入口函数
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
// 创建设备名称
UNICODE_STRING DeviceName;
RtlInitUnicodeString(&DeviceName, L"\\Device\\MyDevice");
PDEVICE_OBJECT pDeviceObj = NULL;
// 创建设备对象
NTSTATUS ioCreateDevRet = IoCreateDevice(
DriverObject, // 调用方驱动程序对象
0,
&DeviceName, // 设备名称
FILE_DEVICE_UNKNOWN, // 设备类型,当前不与某个具体设备挂钩,所以类型为UNKNOWN
FILE_DEVICE_SECURE_OPEN, // 设备属性,大多数驱动程序仅指定 FILE_DEVICE_SECURE_OPEN 属性, 这可确保将相同的安全设置应用到设备的命名空间中的任何打开的请求
FALSE, // 设备对象是否表示独占设备,如果启用了对设备的独占访问,则一次只能打开设备的一个句柄
&pDeviceObj // 创建的设备对象,指向接收指向新创建的 DEVICE_OBJECT 结构的指针的变量的指针
);
 
// 判断IoCreateDevice函数是否执行成功
if (ioCreateDevRet != STATUS_SUCCESS)
{
DbgPrint("IoCreateDevice Error, code: %s\n", ioCreateDevRet);
return ioCreateDevRet;
}
// 设置数据交换的方式
pDeviceObj->Flags |= DO_BUFFERED_IO;
// 创建符号链接
UNICODE_STRING SymbolicLinkName;
RtlInitUnicodeString(&SymbolicLinkName, L"\\??\\MyTestDriver");
IoCreateSymbolicLink(&SymbolicLinkName, &DeviceName);
// 注册派遣函数
DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateDispatchFunc;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = CloseDispatchFunc;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DeviceDispatchFunc;
 
// 设置一个卸载函数,当驱动卸载时触发
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}

实际运行测试,发现我们可以很顺畅的在3环与0环进行通信:

images/download/attachments/1015847/image2022-9-17_15-15-54.png

Hook技术

SSDT Hook

SSDT Hook就是根据SSDT(即KeServiceDescriptorTable、KeServiceDescriptorTableShadow)找到系统服务表,并且修改系统服务表的函数地址表的某个函数地址,来达到Hook指定函数的目的。(关于SSDT的内容我们已经在系统调用的学习中了解了)

根据SSDT获取系统服务表

本次实验我们只对第一张系统服务表的函数进行修改,所以我们可以先根据KeServiceDescriptorTable需要获取系统服务表的地址。如下代码所示,我们按成员结构体定义好系统服务表和SSDT,并且导出KeServiceDescriptorTable。

// 定义系统服务表结构
typedef struct _KSYSTEM_SERVICE_TABLE
{
PULONG ServiceTableBase;
PULONG ServiceCounterTableBase;
ULONG NumberOfService;
PULONG ParamTableBase;
} KSYSTEM_SERVICE_TABLE, *PKSYSTEM_SERVICE_TABLE;
 
// 定义SSDT结构
typedef struct _KSERVICE_TABLE_DESCRIPTOR
{
KSYSTEM_SERVICE_TABLE KrnlFuncTable;
KSYSTEM_SERVICE_TABLE Wind32KTable;
KSYSTEM_SERVICE_TABLE NotUsedTableA;
KSYSTEM_SERVICE_TABLE NotUsedTableB;
} KSERVICE_TABLE_DESCRIPTOR, *PKSYSTEM_SERVICE_DESCRIPTOR;
 
// 导出由内核模块所导出的SSDT
extern PKSYSTEM_SERVICE_DESCRIPTOR KeServiceDescriptorTable;

接着我们可以来驱动入口函数输出函数地址表的地址,来判断我们获取的是否正确:

DbgPrint("Address: %x\n", KeServiceDescriptorTable->KrnlFuncTable.ServiceTableBase);

如下图所示,我们获取的函数地址表地址与Windbg展示的是一直的,因此这里我们成功通过SSDT获取了系统服务表:

images/download/attachments/1015847/image2022-10-17_16-18-38.png

准备要替换的函数

在准备要替换的函数之前,我们要确定替换哪个函数,并且找到其在函数地址表的位置,这里我们选择一个使用的比较多的函数OpenProcess,首先我们在kernel32.dll找到该函数,发现该函数实际是调用ntdll.dll下的NtOpenProcess即ZwOpenProcess,系统调用号为0x7A:

images/download/attachments/1015847/image2022-10-17_16-36-37.png

我们可以在函数地址表中找到它,即0x805CAC46:

images/download/attachments/1015847/image2022-10-17_16-41-25.png

接着我们可以在Hook前先保存这个地址,这样当卸载驱动时便于还原:

// 用于保存被Hook的函数地址
ULONG OldFuncAddr;
 
// 保存被Hook的函数地址
OldFuncAddr = KeServiceDescriptorTable->KrnlFuncTable.ServiceTableBase[0x7A];

接着我们按开发文档上ZwOpenProcess的格式自定义一个函数,并且定义好NtOpenProcess的函数指针在自定义的函数内最终调用原函数:

// 声明函数指针
typedef NTSTATUS(*NTOPENPROCESS)(
PHANDLE ProcessHandle,
ACCESS_MASK DesiredAccess,
POBJECT_ATTRIBUTES ObjectAttributes,
PCLIENT_ID ClientId
);
 
// 准备的函数
NTSTATUS MyNtOpenProcess(PHANDLE ProcessHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes, PCLIENT_ID ClientId)
{
// 自己的代码
DbgPrint("Hooking... \n");
// 最终还是走向真正的NtOpenProcess
return ((NTOPENPROCESS)OldFuncAddr)(ProcessHandle, DesiredAccess, ObjectAttributes, ClientId);
}

这里肯定很多人会被NtXXX和ZwXXX搞得头晕目眩,我们简单了解下这两个命名的区别:在用户角度下也就是我们之前在ntdll.dll中看到的NtOpenProcess和ZwOpenProcess它们本质都是一样的,ZwOpenProcess只是NtOpenProcess的一个别名,它们最终都指向同一个地址;在内核角度下ZwOpenProcess不仅仅是NtOpenProcess的别名,我们可以看如下代码,ZwOpenProcess实际上也是一个系统调用的过程跟用户侧的ZwOpenProcess没什么区别,而内核下的NtOpenProcess则是真正有实现的函数,所以我们在上面的代码声明中都是以NtOpenProcess来命名也是为了规范统一化:

images/download/attachments/1015847/image2022-10-17_17-0-27.png

修改函数地址表地址

我们准备好了要替换的函数,接着就是修改函数地址表的地址,直接修改是不可行的,因为系统服务表所在的物理页是只读的,如果要修改物理页的内容,就需要先修改页属性为可写

修改物理页读写属性的方式有两种,第一种方法是根据页目录表、页表基址找到PDE、PTE修改其R/W位值为1,就可以让物理页具有读写属性(关于具体的代码实现可以参考不同分页模式下的MmIsAddressValid函数):

// 修改物理页属性
NTSTATUS ChangePageAttr(ULONG attrValue)
{
ULONG RCR4 = 0;
// 获得CR4寄存器的值
_asm
{
_emit 0x0F
_emit 0x20
_emit 0xE0
mov RCR4, eax
}
// 根据CR4寄存器的PAE位判断分页模式
if (RCR4 & 0x00000200)
{
DbgPrint("2-9-9-12 \n");
// 获取PDE、PTE,并修改R/W
*(ULONG64*)(0xC0600000 + (((OldFuncAddr >> 21) << 3) & 0x3FF8)) |= attrValue;
*(ULONG64*)(0xC0000000 + (((OldFuncAddr >> 12) << 3) & 0x7FFFF8)) |= attrValue;
}
else
{
DbgPrint("10-10-12 \n");
// 获取PDE、PTE,并修改R/W
*(ULONG*)(0xC0300000 + ((OldFuncAddr >> 20) & 0xFFC)) |= attrValue;
*(ULONG*)(0xC0000000 + ((OldFuncAddr >> 10) & 0x3FFFFC)) |= attrValue;
}
  return STATUS_SUCCESS;
}
 
// 修改R/W为1
ChangePageAttr(0x2);
// 修改R/W为0
ChangePageAttr(0x0)

第二种方法就是通过修改CR0寄存器的WP位,将其设为0就可以关闭写保护无视物理页只读权限进行读写,这样的方法虽然简单但在多核的情况下在代码运行时候有核切换就会存在问题,所以推荐使用的是第一种方法

VOID PageProtectOn()
{
__try
{
_asm
{
mov eax, cr0
or eax, 10000h
mov cr0, eax
sti
}
}
__except (1)
{
DbgPrint("PageProtectOn执行失败!");
}
}
 
VOID PageProtectOff()
{
__try
{
_asm
{
cli
mov eax, cr0
and eax, not 10000h //and eax,0FFFEFFFFh
mov cr0, eax
}
}
__except (1)
{
DbgPrint("PageProtectOff执行失败!");
}
}

接着我们使用下标替换函数地址的代码即可:

// Hook
NTSTATUS HookFuncAddr(ULONG FuncAddr)
{
ChangePageAttr(0x2);
KeServiceDescriptorTable->KrnlFuncTable.ServiceTableBase[0x7A] = FuncAddr;
ChangePageAttr(0x0);
  return STATUS_SUCCESS;
}
 
HookFuncAddr((ULONG)MyNtOpenProcess);

完整代码

最终将所有代码调优一下细节即可,完整代码如下:

#include <ntddk.h> // 必须要包含的头文件
 
// 定义系统服务表结构
typedef struct _KSYSTEM_SERVICE_TABLE
{
PULONG ServiceTableBase;
PULONG ServiceCounterTableBase;
ULONG NumberOfService;
PULONG ParamTableBase;
} KSYSTEM_SERVICE_TABLE, *PKSYSTEM_SERVICE_TABLE;
 
// 定义SSDT结构
typedef struct _KSERVICE_TABLE_DESCRIPTOR
{
KSYSTEM_SERVICE_TABLE KrnlFuncTable;
KSYSTEM_SERVICE_TABLE Wind32KTable;
KSYSTEM_SERVICE_TABLE NotUsedTableA;
KSYSTEM_SERVICE_TABLE NotUsedTableB;
} KSERVICE_TABLE_DESCRIPTOR, *PKSYSTEM_SERVICE_DESCRIPTOR;
 
// 导出由内核模块所导出的SSDT
extern PKSYSTEM_SERVICE_DESCRIPTOR KeServiceDescriptorTable;
 
// 用于保存被Hook的函数地址
ULONG OldFuncAddr;
 
// 声明函数指针
typedef NTSTATUS(*NTOPENPROCESS)(
PHANDLE ProcessHandle,
ACCESS_MASK DesiredAccess,
POBJECT_ATTRIBUTES ObjectAttributes,
PCLIENT_ID ClientId
);
 
// 准备的函数
NTSTATUS MyNtOpenProcess(PHANDLE ProcessHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes, PCLIENT_ID ClientId)
{
// 自己的代码
DbgPrint("Hooking... \n");
// 最终还是走向真正的OpenProcess
return ((NTOPENPROCESS)OldFuncAddr)(ProcessHandle, DesiredAccess, ObjectAttributes, ClientId);
}
 
// 修改物理页属性
NTSTATUS ChangePageAttr(ULONG attrValue)
{
ULONG RCR4 = 0;
// 获得CR4寄存器的值
_asm
{
_emit 0x0F
_emit 0x20
_emit 0xE0
mov RCR4, eax
}
// 根据CR4寄存器的PAE位判断分页模式
if (RCR4 & 0x00000200)
{
DbgPrint("2-9-9-12 \n");
// 获取PDE、PTE,并修改R/W
*(ULONG64*)(0xC0600000 + (((OldFuncAddr >> 21) << 3) & 0x3FF8)) |= attrValue;
*(ULONG64*)(0xC0000000 + (((OldFuncAddr >> 12) << 3) & 0x7FFFF8)) |= attrValue;
}
else
{
DbgPrint("10-10-12 \n");
// 获取PDE、PTE,并修改R/W
*(ULONG*)(0xC0300000 + ((OldFuncAddr >> 20) & 0xFFC)) |= attrValue;
*(ULONG*)(0xC0000000 + ((OldFuncAddr >> 10) & 0x3FFFFC)) |= attrValue;
}
 
return STATUS_SUCCESS;
}
 
// Hook
NTSTATUS HookFuncAddr(ULONG FuncAddr)
{
ChangePageAttr(0x2);
KeServiceDescriptorTable->KrnlFuncTable.ServiceTableBase[0x7A] = FuncAddr;
ChangePageAttr(0x0);
 
return STATUS_SUCCESS;
}
 
// 自定义的驱动程序卸载函数
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
HookFuncAddr((ULONG)OldFuncAddr);
DbgPrint("Bye. \n");
}
 
// 驱动程序入口函数
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
DbgPrint("Address: %x\n", KeServiceDescriptorTable->KrnlFuncTable.ServiceTableBase);
// 保存被Hook的函数地址
OldFuncAddr = KeServiceDescriptorTable->KrnlFuncTable.ServiceTableBase[0x7A];
HookFuncAddr((ULONG)MyNtOpenProcess);
// 设置一个卸载函数,当驱动卸载时触发
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}

编译代码,在系统上运行成功实现了Hook:

images/download/attachments/1015847/image2022-10-18_11-22-22.png

Inline Hook

SSDT Hook的方式是基于系统服务表的,这种方式很容易被发现并且我们只能取Hook系统服务表内的函数,因此我们可以使用Inline Hook的方式来弥补这些缺陷。Inline Hook可以在3环、0环下使用,它的原理就是改变程序的执行流程,例如你可以在某个函数的第一行位置开始替换为JMP指令,跳转到自己的函数地址。除了使用JMP指令改变执行流程,我们还可以使用CALL指令以及PUSH、RET指令的组合。JMP、CALL指令至少占用5个字节,PUSH、RET指令的组合则需要6个字节。

使用Inline Hook时需要注意避免替换全局变量相关的硬编码,并且需要先将通用寄存器压栈保存,然后将被替换的指令加入到自己的指令中以保证程序可以完整执行,最终再JMP回被替换指令所在行的下一行地址。

我们可以使用SSDT Hook的代码来找到要Hook函数的地址,例如此处我想Hook函数NtOpenProcess来监控它的参数,我们不再需要声明函数指针然后写自定义函数,直接写一个自定的函数即可,但也因此我们需要通过其他的方式来获取参数值,我们可以来看一下该函数的实现,似乎并没有用到寄存器,也就表示在这里不是通过寄存器传参,而是通过栈传参,那我们就可以使用EBP寻址来获取参数。

images/download/attachments/1015847/image2022-10-18_20-39-10.png

接着我们需要找到合适的Hook点,也就是我们代码流程切换指令替换的位置,这里我们选择比较方便的PUSH、RET指令,也就是要找一个非全局变量相关的位置,如上图标红所示即一个比较不错的Hook点,接着我们来编写自定义函数的代码,__declspec(naked)用于表示这是一个裸函数这样编译器就不会为该函数的结尾加入RET指令

// 准备的函数
VOID __declspec(naked) MyNtOpenProcess()
{
__asm
{
// 保存寄存器
pushad
pushfd
// 获取传参数
mov eax, [ebp + 0x8]
mov ProcessHandle, eax
mov eax, [ebp + 0xC]
mov DesiredAccess, eax
mov eax, [ebp + 0x10]
mov ObjectAttributes, eax
mov eax, [ebp + 0x14]
mov ClientId, eax
}
 
// 调试输出
DbgPrint("ProcessHandle: %x, DesiredAccess: %x, ObjectAttributes: %x, ClientId: %x\n", ProcessHandle, DesiredAccess, ObjectAttributes, ClientId);
 
__asm
{
// 还原寄存器
popfd
popad
// 执行被替换的指令,保证运行完整
xor eax, eax
lea edi, [ebp-0x28]
stos dword ptr es:[edi]
// 跳转回原函数被替换指令的下一行地址
jmp BackAddress
}
}

代码中有很多细节的点,如防止我们的代码中会使用到寄存器先压入原来的寄存器到栈,接着还原,在JMP回原函数被替换指令的下一行地址之前我们需要将被替换的指令执行一下,以保证函数运行的完整性。

接着我们需要构造一下修改的指令,即PUSH、RET指令,直接使用硬编码的方式即可:

UCHAR PushRetCmd[6] = { 0 };
 
// 硬编码 PUSH 0x12345678
PushRetCmd[0] = 0x68;
*(ULONG*)(PushRetCmd + 1) = (ULONG)MyNtOpenProcess;
// RET
PushRetCmd[5] = 0xC3;

再使用RtlMoveMemory来替换函数指令:

// 修改函数指令
NTSTATUS ChangeFuncCmd(UCHAR CmdByte[6])
{
ChangePageAttr(0x2);
RtlMoveMemory((PUCHAR)CmdAddress, CmdByte, 6);
ChangePageAttr(0x0);
 
return STATUS_SUCCESS;
}

在驱动入口处我们将该计算的地址,以及指令都填充好即可:

// 保存被Hook的函数地址
FuncAddr = KeServiceDescriptorTable->KrnlFuncTable.ServiceTableBase[0x7A];
DbgPrint("FuncAddr: %x \n", FuncAddr);
// 计算要替换的函数地址和要JMP的返回地址
CmdAddress = FuncAddr + 0x14; // 将要替换的指令地址减去函数首地址得出偏移0x144
DbgPrint("CmdAddress: %x \n", CmdAddress);
BackAddress = CmdAddress + 0x6; // 替换的指令长度为0x6
DbgPrint("BackAddress: %x \n", BackAddress);
// 硬编码 PUSH 0x12345678
PushRetCmd[0] = 0x68;
*(ULONG*)(PushRetCmd + 1) = (ULONG)MyNtOpenProcess;
// 硬编码 RET
PushRetCmd[5] = 0xC3;
// 替换指令
ChangeFuncCmd(PushRetCmd);

最后别忘记在卸载函数时还原指令以及一些细节上的调整,最终完整代码如下:

#include <ntddk.h> // 必须要包含的头文件
 
// 定义系统服务表结构
typedef struct _KSYSTEM_SERVICE_TABLE
{
PULONG ServiceTableBase;
PULONG ServiceCounterTableBase;
ULONG NumberOfService;
PULONG ParamTableBase;
} KSYSTEM_SERVICE_TABLE, *PKSYSTEM_SERVICE_TABLE;
 
// 定义SSDT结构
typedef struct _KSERVICE_TABLE_DESCRIPTOR
{
KSYSTEM_SERVICE_TABLE KrnlFuncTable;
KSYSTEM_SERVICE_TABLE Wind32KTable;
KSYSTEM_SERVICE_TABLE NotUsedTableA;
KSYSTEM_SERVICE_TABLE NotUsedTableB;
} KSERVICE_TABLE_DESCRIPTOR, *PKSYSTEM_SERVICE_DESCRIPTOR;
 
// 导出由内核模块所导出的SSDT
extern PKSYSTEM_SERVICE_DESCRIPTOR KeServiceDescriptorTable;
 
// 用于保存被Hook的函数地址
ULONG FuncAddr;
 
// 用于保存替换指令的地址和返回地址
ULONG CmdAddress;
ULONG BackAddress;
 
// 用于存放修改执行流程的指令
UCHAR PushRetCmd[6] = { 0 };
// 用于保存被替换的指令
UCHAR OriginCmd[6] = { 0x33, 0xC0, 0x8D, 0x7D, 0xD8, 0xAB };
 
// 用于获取参数的全局变量
PHANDLE ProcessHandle;
ACCESS_MASK DesiredAccess;
POBJECT_ATTRIBUTES ObjectAttributes;
PCLIENT_ID ClientId;
 
// 准备的函数
VOID __declspec(naked) MyNtOpenProcess()
{
__asm
{
// 保存寄存器
pushad
pushfd
// 获取传参数
mov eax, [ebp + 0x8]
mov ProcessHandle, eax
mov eax, [ebp + 0xC]
mov DesiredAccess, eax
mov eax, [ebp + 0x10]
mov ObjectAttributes, eax
mov eax, [ebp + 0x14]
mov ClientId, eax
}
 
// 调试输出
DbgPrint("ProcessHandle: %x, DesiredAccess: %x, ObjectAttributes: %x, ClientId: %x\n", ProcessHandle, DesiredAccess, ObjectAttributes, ClientId);
 
__asm
{
// 还原寄存器
popfd
popad
// 执行被替换的指令,保证运行完整
xor eax, eax
lea edi, [ebp-0x28]
stos dword ptr es:[edi]
// 跳转回原函数被替换指令的下一行地址
jmp BackAddress
}
}
 
// 修改物理页属性
NTSTATUS ChangePageAttr(ULONG attrValue)
{
ULONG RCR4 = 0;
// 获得CR4寄存器的值
_asm
{
_emit 0x0F
_emit 0x20
_emit 0xE0
mov RCR4, eax
}
// 根据CR4寄存器的PAE位判断分页模式
if (RCR4 & 0x00000200)
{
DbgPrint("2-9-9-12 \n");
// 获取PDE、PTE,并修改R/W
*(ULONG64*)(0xC0600000 + (((FuncAddr >> 21) << 3) & 0x3FF8)) |= attrValue;
*(ULONG64*)(0xC0000000 + (((FuncAddr >> 12) << 3) & 0x7FFFF8)) |= attrValue;
}
else
{
DbgPrint("10-10-12 \n");
// 获取PDE、PTE,并修改R/W
*(ULONG*)(0xC0300000 + ((FuncAddr >> 20) & 0xFFC)) |= attrValue;
*(ULONG*)(0xC0000000 + ((FuncAddr >> 10) & 0x3FFFFC)) |= attrValue;
}
 
return STATUS_SUCCESS;
}
 
// 修改函数指令
NTSTATUS ChangeFuncCmd(UCHAR CmdByte[6])
{
ChangePageAttr(0x2);
RtlMoveMemory((PUCHAR)CmdAddress, CmdByte, 6);
ChangePageAttr(0x0);
 
return STATUS_SUCCESS;
}
 
// 自定义的驱动程序卸载函数
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
// 还原替换指令
ChangeFuncCmd(OriginCmd);
DbgPrint("Bye. \n");
}
 
// 驱动程序入口函数
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
// 保存被Hook的函数地址
FuncAddr = KeServiceDescriptorTable->KrnlFuncTable.ServiceTableBase[0x7A];
DbgPrint("FuncAddr: %x \n", FuncAddr);
// 计算要替换的函数地址和要JMP的返回地址
CmdAddress = FuncAddr + 0x14; // 将要替换的指令地址减去函数首地址得出偏移0x144
DbgPrint("CmdAddress: %x \n", CmdAddress);
BackAddress = CmdAddress + 0x6; // 替换的指令长度为0x6
DbgPrint("BackAddress: %x \n", BackAddress);
// 硬编码 PUSH 0x12345678
PushRetCmd[0] = 0x68;
*(ULONG*)(PushRetCmd + 1) = (ULONG)MyNtOpenProcess;
// 硬编码 RET
PushRetCmd[5] = 0xC3;
// 替换指令
ChangeFuncCmd(PushRetCmd);
// 设置一个卸载函数,当驱动卸载时触发
DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}

我们可以加载驱动,来看一下实际效果,如下图所示我们成功进行了Hook:

images/download/attachments/1015847/image2022-10-18_21-47-19.png

并且在Windbg中查看nt!NtOpenProcess函数,也可以看到它的指令被我们成功覆盖:

images/download/attachments/1015847/image2022-10-18_21-52-52.png