1. 驱动开发环境配置

Visual Studio(内置SDK) + WDK

2. 驱动的开发流程

编写代码 -> 编译生成.sys文件 -> 部署 -> 启动 -> 停止 -> 卸载

3. 编写代码

驱动程序的入口函数为DriverEntry,可以设置卸载函数为DriverUnload。

4. 调试驱动程序

  • 需要使用0环驱动器,VirtualKD + WinDbg
  • 利用PDB文件获取函数和地址之间的关系、结构体等信息
  • 在代码中插入INT 3来插入断点,配合PDB、SYS可以进行源码调试

5. 内核编程基础

5.1. API

WDK文档包含了公开的内核API,对于未公开函数,可以通过特征码搜索或者解析内核PDB文件来获取函数地址,然后定义函数指针指向并调用。

5.2. 基本数据类型

使用WDK自己定义好的类型,以避免跨平台错误。

1
2
3
4
ULONG(unsigned long)         PULONG(unsigned long *)
UCHAR(unsigned char) PUCHAR(unsigned char *)
UINT(unsigned int) PUINT(unsigned int *)
VOID(void) PVOID(void *)

5.3. 函数返回值

大部分内核程序的返回值都是NTSTATUS类型,这个值可以反映函数运行的结果(具体见ntstatus.h),如:

  • STATUS_SUCCESS 0x00000000 成功
  • STATUS_INVALID_PARAMETER 0xC000000D 参数无效
  • STATUS_BUFFER_OVERFLOW 0x80000005 缓冲区长度不够

5.4. 异常处理

内核出错会导致机器蓝屏,所以建议使用try{}机制来保证程序的健壮性。

5.5. 内核内存函数

功能 C语言 内核函数
分配 malloc ExAllocatePool
设置 memset RtlFillMemory
复制 memcpy RtlMoveMemory
释放 free ExFreePool

5.5.1. ExAllocatePool

在使用这个函数分配内存的时候,需要指定内存的类型,其存在两个枚举值:分页内存或者是非分页内存。在内存紧张时,操作系统可能会将分页内存对应物理页存储到文件中以节省内存,而非分页内存则不会被转换。

5.6. 字符串

5.6.1. 字符串结构

  • CHAR(char)
  • WCHAR(wchar_t)
  • ANSI_STRING,升级版char,拥有长度和缓冲区成员,比0x00标示字符串末尾更安全
    1
    2
    3
    4
    5
    typedef struct _STRING{
    USHORT Length;
    USHORT MaximumLength;
    PCHAR Buffer;
    }STRING;
  • UNICODE_STRING,升级版wchar_t,拥有长度和缓冲区成员,比0x0000标示字符串末尾更安全
    1
    2
    3
    4
    5
    typedef struct _UNICODE__STRING{
    USHORT Length;
    USHORT MaximumLength;
    PWSTR Buffer;
    }UNICODE_STRING;

5.6.2. 字符串处理函数

功能 ANSI_STRING UNICODE_STRING
初始化 RtlInitAnsiString RtlInitUnicodeString
复制 RtlCopyString RtlCopyUnicodeString
比较 RtlCompareString RtlCompareUnicodeString
转换 RtlAnsiStringToUnicodeString RtlUnicodeStringToAnsiString

6. 内核空间与内核模块

内核空间为高2G空间,所有进程共享该空间,任何进程对其的修改都会扩散到所有进程。

6.1. 驱动

驱动本身服务于硬件,是硬件与操作系统之间的接口。硬件种类繁多,所以,微软提供了一个标准的接口格式,让硬件开发人员根据接口编写相应的驱动。

6.2. 内核模块

内核空间由多个模块构成,每个加载到内核中的驱动程序都包含在内。只要是符合驱动接口格式的程序,被加载到内核后,都拥有相同的内核权限(ntoskrnl.exe也是相同),无论他是否真正的驱动硬件。

7. 驱动对象结构体

DRIVER_OBJECT这个结构体描述了该驱动加载成为内核模块后,模块的起始地址、大小、名称等属性。

7.1. 找到其它驱动对象结构体

DRIVER_OBJECT.DriverSection指向了结构体_LDR_DATA_TABLE_ENTRY,这个结构体包含了模块的起始地址、大小、文件路径等属性,而它的成员InLoadOrderLinks是一个链表,连接了所有的内核模块结构体。

8. 驱动与3环的常规通信方式

8.1. 消息与设备对象

在开发窗口程序的时候,消息被封装成结构体MSG,只有窗口对象能够接收MSG消息然后调用回调函数;在内核开发时,消息被封装成另外一个结构体IRP(I/O Request Package),只有设备对象(一般来说,设备对象作为一个结构体,对应着一个硬件设备。但是设备对象也可以作为一个抽象概念存在,即不对应任何硬件设备。)能够接收IRP消息(驱动对象无法直接接收IRP消息)。举例如下:

  • IRP_MJ_CREATE:CreateFile打开设备
  • IRP_MJ_READ:ReadFile从设备读取数据
  • IRP_MJ_WRITE:WriteFile向设备写入数据
  • IRP_MJ_CLOSE:CloseHandle关闭设备
  • IRP_MJ_DEVICE_CONTROL:应用层调用DeviceControl函数会产生该IRP
  • IRP_MJ_POWER:操作系统处理电源消息时,产生此IRP
  • IRP_MJ_SHUTDOWN:关闭系统前会产生此IRP

8.2. 创建通信通道步骤

8.2.1. 创建设备对象

1
2
3
4
5
6
7
8
9
10
11
12
UNICODE_STRING Devicename;
RtlInitUnicodeString(&Devicename, L"\\Device\\MyDevice");
//0环函数,创建一个设备
IoCreateDevice(
pDriver, //设备所属驱动对象,设备必须有一个所属的驱动对象
0,
&Devicename, //设备名称,最好遵循\\Device\\MyDevice的格式命名,这样可以让设备挂到Windows的驱动树型结构上。当然也可以不遵循。必须是UNICODE_STRING类型
FILE_DEVICE_UNKNOWN, //设备类型(鼠标、键盘),对于抽象设备可以设置为UNKNOWN
FILE_DEVICE_SECURE_OPEN,
FALSE,
&pDeviceObj //设备对象指针,创建成功后这里会存储创建出来的设备的指针
);

8.2.2. 设置设备与3环交互数据的方式

  • 缓冲区方式读写(DO_BUFFERED_IO):操作系统将应用程序提供缓冲区的数据复制到内核模式下的地址中,适用于小数据量传输,大数据传输复制过程很消耗性能。
  • 直接方式读写(DO_DIRECT_IO):操作系统会将用户模式下的缓冲区锁住,然后操作系统将这段缓冲区在内核模式地址再映射一遍。这样,用户模式的缓冲区和内核模式的缓冲区指向的是同一区域的物理内存,但是这样需要单独占用物理页面(被锁住无法切换,无法SWAP进文件)。优点是传输大数据量很方便,不消耗额外性能。
  • 其它方式读写:不设置为上面两种方式即为这种方式,其实质为在0环直接读取3环的线性地址,如果线程被切换,CR3切换,将会出现读写错误。

8.2.3. 创建符号链接

使用函数IoCreateSymbolicLink创建符号链接,相当于给设备起一个别名,给3环定位设备使用,内核起名遵循\\??\\MyTestDriver(其中两个\是转义符)格式,3环使用CreateFile打开设备时,使用\\\\.\\MytestDriver(其中三个\是转义符)来定位设备。

8.2.4. 设置派遣函数(IRP回调函数)

DEVICE_OBJECT.MajorFunction是一个长度为28的数组,每个IRP都有一个编号,其派遣函数的地址需要存储到数组中(编号对应数组下标)。派遣函数的格式需要遵循以下标准:

1
2
3
4
5
6
7
8
9
NTSTATUS MyFun(PDEVICE_OBJECT pDevObj, PIRP pIrp){
//处理业务

//设置返回状态,如果不设置,返回的是失败
pIrp->IoStatus.Status = STATUS_SUCCESS; //GetLastError返回的值
pIrp->IoStatus.Information = 0; //返回给3环的数据的字节数
IoCompleteRequest(pIrp, IO_NO_INCREMENT); //将信号向下传递
return STATUS_SUCCESS;
}

8.3. 具体通信方法

三环设备需要先调用CreateFile,通过符号链接打开设备(此时会产生IRP_MJ_CREATE信号),然后通过DeviceIoControl函数将控制代码(控制代码为函数的第二个参数)和数据直接发送到指定的设备(此时会产生IRP_MJ_DEVICE_CONTROL信号)来通信。

9. 重载内核

9.1. 绕过内核Hook的方法

  • 优先挂钩或者脱钩
  • 映射内核文件(重载内核)
  • 自写代码实现内核功能

9.2. 重载内核的步骤

  • 申请内存空间,读取内核文件,按照内存对齐展开
  • 根据重定位表修复全局变量
  • 修复IAT表
  • 制作一份系统服务表,指向新内核
  • Hook KiFastCallEntry,对于自己需要的函数,将其重定向到新的系统服务表

9.3. 重载内核的缺点

重载内核对内存改动太大,所以重载行为无法很好的隐藏。所以绕过内核Hook的最好方法还是自写代码实现内核功能。