Windows XP 驱动开发(三)
ʕ •ᴥ•ʔ ɔ:
1 0环与3环通信常规方式
在驱动中,如果想与3环的程序进行交互,就必须得有设备对象。
本文讲常规的通信方式,通常程序开发使用该方式,非常规方式在这里:0环与3环通信非常规方式 —— 0环InlineHook。
1.1 设备对象
我们在开发窗口程序的时候,消息被封装成一个结构体:MSG,在内核开发时,消息被封装成另外一个结构体:IRP(I/O Request Package)。
在窗口程序中,能够接收消息的只能是窗口对象。在内核中,能够接收IRP消息的只能是设备对象。当上层应用程序需要访问底层输入输出设备时,发出I/O
请求,系统会把这些请求转化为IRP
数据,不同的IRP
会启动I/O
设备驱动中对应的派遣函数。
1 | typedef struct tagMSG { |
1 | kd> dt _IRP |
1.2 创建设备对象
使用IoCreateDevice
函数在0环来创建一个设备对象。
驱动程序原本的目的是用来控制硬件,但我们也可以用驱动做一些安全相关的事情,因为驱动运行在0环。为了控制驱动运行,我们需要在3环向驱动发数据,所以我们需要有一种方法来建立0环到3环的通信。本文介绍常规方式,也就是创建设备对象的方式。
1 | //创建设备名称 |
1.3 0环与3环数据交互方式
设备对象的Flags(UINT32)成员决定0环和3环进行数据交互的方式,分以下3种:
- 缓冲区方式读写(
DO_BUFFERED_IO
):操作系统将应用程序提供缓冲区的数据复制到内核模式下的地址中。实质上是:将3环一个缓冲区的内容复制到0环的缓冲区(数据量小时好用,但是交互慢)。 - 直接方式读写(
DO_DIRECT_IO
):操作系统会将用户模式下的缓冲区锁住。然后操作系统将这段缓冲区在内核模式地址再次映射一遍。这样,用户模式的缓冲区和内核模式的缓冲区指向的是同一区域的物理内存。缺点就是要单独占用物理页面。0环和3环线性地址指向同一块物理页(数据量大时好用,但是物理页不允许换页,如写入文件)。 - 其他方式读写:在0环直接读3环的线性地址,不建议使用。在调用
IoCreateDevice
创建设备后对pDevObj->Flags
即不设置DO_BUFFERED_IO也不设置DO_DIRECT_IO此时就是其他方式。
在使用其他方式读写设备时,派遣函数直接读写应用程序提供的缓冲区地址。在驱动程序中,直接操作应用程序的缓冲区地址是很危险的。只有驱动程序与应用程序运行在相同线程上下文的情况下,才能使用这种方式。
使用方式:
1 | pDeviceObj->Flags |= DO_BUFFERED_IO; |
1.4 创建符号链接
创建符号链接目的是:在3环不能直视使用设备名称进行数据交互,需要在0环将设备对象链接到一个符号上去,然后在3环通过这个符号进行数据交互。
特别说明:
1、设备名称的作用是给内核对象用的,如果要在Ring3访问,必须要有符号链接。其实就是一个别名,没有这个别名,在Ring3不可见。
2、内核模式下,符号链接是以\??\
开头的,如C 盘就是\??\C:
。
3、而在用户模式下,则是以\\.\
开头的,如C 盘就是\\.\C:
。
4、在3环使用符号链接的格式:\\.\符号名称
,编程时通常需要使用转义符,写为\\\\.\\符号名称
。
1 | //创建符号链接名称 |
1.5 IRP与派遣函数
Win32程序中,当一个事件发生,操作系统就会将这个事件封装成一个tagMSG结构的消息,然后将该消息发送给产生事件的窗口,然后窗口调用对应的消息回调函数进行处理。
同理,当3环调用API访问设备的时候,在0环会将某API事件封装成IRP结构,然后将IRP发给设备对象,设备对象调用派遣函数(回调函数)来进行处理。
IRP的类型:
当应用层通过CreateFile,ReadFile,WriteFile,CloseHandle等函数打开、从设备读取数据、向设备写入数据、关闭设备的时候,会使操作系统产生出
IRP_MJ_CREATE
,IRP_MJ_READ
,IRP_MJ_WRITE
,IRP_MJ_CLOSE
等不同的IRP。其他类型的IRP。
IRP
列表如下:
名称 | 描述 | 调用者 |
---|---|---|
IRP_MJ_CREATE | 请求一个句柄 | CreateFile |
IRP_MJ_CLEANUP | 在关闭句柄时取消悬挂的IRP | CloseHandle |
IRP_MJ_CLOSE | 关闭句柄 | CloseHandle |
IRP_MJ_READ | 从设备得到数据 | ReadFile |
IRP_MJ_WRITE | 传送数据到设备 | WriteFile |
IRP_MJ_DEVICE_CONTROL | 控制操作(利用IOCTL宏) | DeviceIoControl |
RP_MJ_INTERNAL_DEVICE_CONTROL | 控制操作(只能被内核调用) | N/A |
IRP_MJ_QUERY_INFORMATION | 得到文件的长度 | GetFileSize |
IRP_MJ_SET_INFORMATION | 设置文件的长度 | SetFileSize |
IRP_MJ_FLUSH_BUFFERS | 写输出缓冲区或者丢弃输入缓冲区 | FlushFileBuffers |
FlushConsoleInputBuffer |
||
PurgeComm |
||
IRP_MJ_SHUTDOWN | 系统关闭 | InitiateSystemShutdown |
在wdm.h
中:
1 |
派遣函数:
在Win32中,回调函数需要在窗口处理函数的参数中进行指定。在驱动程序中,回调函数需要在驱动对象里面进行指定(派遣)。
一个驱动可以创建许多设备,这些设备的回调函数都放在驱动对象的最后一个成员里面(MajorFunction
)。函数是有顺序的。
1 | PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1]; |
IRP_MJ_MAXIMUM_FUNCTION :派遣函数的最大值
1 | kd> dt _DRIVER_OBJECT |
注册派遣函数:(需要谁就注册谁),函数是有顺序的。
1 | NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING reg_path) |
派遣函数的格式
Win32的窗口回调函数有自己的格式,同样的派遣函数也有自己的格式:
1 | NTSTATUS MyDispatchFunction(PDEVICE_OBJECT pDevObj, PIRP pIrp) |
1.6 IRP_MJ_DEVICE_CONTROL交互数据
应用层调用DeviceControl函数会产生此IRP。该函数可以与设备双向交互数据,最常用。
1 | BOOL DeviceIoControl( |
需要注意的是参数2,控制码:dwIoControlCode
。32位整数,由一个宏CTL_CODE
将4个参数组成一个32位数字。
1 |
- FILE_DEVICE_UNKNOWN:设备类型
- 0x800:自定义代码,0~0x7FF为保留,用户可用0x800~0xFFF
- METHOD_BUFFERED:交互方式
- FILE_ANY_ACCESS:权限
1.7 练习:0环与3环通信
0环代码:
1 |
|
3环代码:
1 |
|
1.8 练习:在3环实现驱动加载运行
一、手动加载驱动步骤
用GetFullPathNameW获取驱动的完整路径
用OpenSCManager打开服务控制管理器
用CreateServiceW创建服务
如果服务创建已存在,直接用OpenServiceW打开服务,否则用StartServiceW开启服务
二、卸载
用OpenSCManager打开服务控制管理器
用OpenServiceA打开服务
用ControlService停止驱动服务
⚠️注意:驱动项目名应和三环中定义的 DRIVER_NAME 一致。MathsDriver.sys
程序需要放到3环程序所在目录。
3环代码:
1 |
|
0环驱动程序:
1 |
|
1.9 练习:结束指定PID进程
编写一个3环程序,可以将任意一个进程的PID传递给0环的驱动程序,如果这个进程存在,驱动程序将该进程终结。另外,如果工具可以以一个单文件的形式存在,即不带sys文件,那么看起来会高级不少,然而我现在无法完成该需求,如果你知道怎么做,请在评论中留言,我会转达hambaga。
这个项目基于1.8的练习,3环部分,只需将传递两个4字节加数改成传递一个4字节PID,并且修改一下宏定义中,驱动项目的名字即可。
3环代码:
1 |
|
0环驱动代码:
1 |
|
2 驱动中的全局变量
在编写驱动中发现,使用全局变量或static
变量,它们的地址是会变的,像局部变量一样,全局变量每次调用驱动函数都会重新初始化。
在驱动程序中应该尽量避免使用全局变量,因为全局变量会导致不同步的问题,解决办法之一就是可将全局变量存储在设备扩展中(使用指针指向设备扩展的内存块),将指针作为一个全局变量来使用。
设备扩展(DEVICE_EXTENSION)是与设备对象相关的另一种重要的数据结构。可以用它来保存与特定设备关联的信息。设备扩展其实只是一个未分页的池,由驱动开发者来定义它的大小和内容。并由I/O管理器自动把它分配给设备对象,即设备对象的PVOID DeviceExtension
字段。由于此结构是驱动开发者自定义的,所以必须要让系统知道需要给此结构预留多少空间,因此要把设备扩展结构的大小作为参数传递给IoCreateDevice
函数。I/O管理器的IoCreateDevice
函数将为设备对象和设备扩展对象在非分页内存池内申请内存。
- 非分页内存池:内存页不可以交换出内存。
- 分页内存池:内存页可以交换出内存。
使用IoCreateDevice
函数创建设备时,可以指定当前设备要使用的非分页内存的大小,由成员DeviceExtensionSize
来指定大小。然后由对象的成员DeviceExtension
指向这块内存。这块内存本来是用来存设备相关信息的,但是可以由开发者决定存啥,将其转为PVOID
使用即可。
1 | NTSTATUS IoCreateDevice( |
3 编写ShellCode
3.1 ShellCode定义
Shellcode是不依赖环境,放到任何地方都可以执行的机器码。
将一个函数模块注入到其他进程中,这个函数的硬编码就成为ShellCode,这种注入叫做代码注入/ShellCode注入
3.2 ShellCode编写原则
ShellCode编写原则有很多,下面列举一些必须要掌握的。
关于ShellCode注入应该注意(编写规则):
1、不要使用全局变量(当前程序的全局变量地址在其他进程不可用)
2、不要使用常量字符串(全局变量)
3、不能直接调用系统函数(不能使用IAT)
4、不能嵌套调用其他函数(全局变量)
5、注意编译器的一些设置
1、不要使用全局变量。
因为我们编写ShellCode时,使用的全局变量是自己的进程里面的全局变量,注入到别的进程里,这个地址就没用了。
2、不要使用常量字符串。
因为字符串常量值也是全局变量,注入到别的进程里,根本没有这个字符串。
⚠️替代方法:要使用字符串,请使用字符数组。
1 | char s[] = {'1','2',0}; |
3、不能直接调用系统函数(不能使用IAT)。
如在程序中使用MessageBox(0,0,0,0)
,调用系统函数的方式是间接调用(FF15),需要从IAT表里获取API地址,每个进程的IAT表位置不同,且对方的进程可能没有导入你需要调用的函数的DLL,那么你是不能调用这个系统函数的。
1 | 0040227B FF15 44C14000 call dword ptr ds:[0x40C144] |
这里的0x40C144
就是全局变量的地址。
所以我们需要用到 LoadLibrary 和 GetProcAddress 这两个函数,来动态获取系统API的函数指针。
但是 LoadLibrary,GetProcAddress 本身就是系统函数,它们本身就依赖IAT表,咋办呢?
解决方案是这样的:通过FS:[0x30] 找到PEB,然后通过PEB里的LDR链表 [PEB+0x0C]找到 kernel32.dll 的地址,然后我们遍历它的 IAT表,找到 LoadLibrary 和 GetProcAddress 函数。
4、不能嵌套调用其他函数。
和前两点道理是一样的,本进程里的函数地址,拿到别的进程的虚拟地址空间是无效的。
1 | int Num = Plus(2,3); |
这里的0x004027B0
即为Plus
的地址,是一个全局变量。
5、注意编译器的一些设置。(《Rootkit:系统灰色地带的潜伏者 第2版》)
特别注意:
- 运行时检查:可以设置成关闭,或者代码编译时选择
release
,这样就不会有堆栈检查_checksp
函数。 - 关闭缓冲区安全检查(GS选项):ShellCode中没有这些信息,方便免杀。
可以参考:
代码注入:(原理都是线程注入)
⚠️注意:类型定义不属于全局变量!!!
3.3 ShellCode示例
本例编写一个函数,该函数:
- 利用
fs:[30]
获取PEB起始地址。 - 根据
PEB->InLoadOrderModuleList.Flink;
获取PLDR_DATA_TABLE_ENTRY
。 - 根据Unicode结构
BaseDllName
和Unicode模块名称kernel32.dll
进行对比获取模块加载基址。 - 根据
kernel32.dll
的基址获取导出表中的函数GetProcAddress
的地址。
1 |
|
将函数DWORD WINAPI ShellCode(LPVOID lpThreadParameter);
的硬编码提取出来。然后注入到某个进程中去:
1 |
|
注意:如果直接使用如下代码调用ShellCode会失败,是因为此时的ShellCode放在数据段,无可执行权限。
1 | __asm |
解决方法:可以使用上述的VirtualAllocEx
去申请一段可执行的内存块来保存ShellCode,也可以使用VirtualProtect
来修改内存页的属性,也可以修改.data
节的属性为可读可写可执行。
1 | BOOL VirtualProtect( |
类型 | 注释 |
---|---|
PAGE_READONLY | 该区域为只读。如果应用程序试图访问区域中的页的时候,将会被拒绝访问 |
PAGE_READWRITE | 区域可被应用程序读写 |
PAGE_EXECUTE | 区域包含可被系统执行的代码。试图读写该区域的操作将被拒绝 |
PAGE_EXECUTE_READ | 区域包含可执行代码,应用程序可以读该区域 |
PAGE_EXECUTE_READWRITE | 区域包含可执行代码,应用程序可以读写该区域 |
PAGE_GUARD | 区域第一次被访问时进入一个STATUS_GUARD_PAGE异常,这个标志要和其他保护标志合并使用,表明区域被第一次访问的权限 |
PAGE_NOACCESS | 任何访问该区域的操作将被拒绝 |
PAGE_NOCACHE | RAM中的页映射到该区域时将不会被微处理器缓存(cached) |
PAGE_EXECUTE_WRITECOPY | 写拷贝 |
4 写拷贝属性(WRITECOPY)
线性地址写拷贝属性。
每个进程低2G会有块内存,记录哪些线性地址被占用,哪些没有被占用。这块内存是一个二叉树结构,记录某块被申请内存的开始、结束地址,还有为什么被申请。
这个二叉树也叫做VAD树,结构如下:
这棵二叉树在_EPROCESS
结构偏移0x11C
的位置:
1 | kd> !vad 0x8637fd38 |
注意:
- level:二叉树的级别。
- start:开始地址、结束地址使用的单位是4KB(0x1000),如第一项的起始地址为10,即0x10 000。
- Private/Mapped:该内存是否独占还是和其他进程共享。
- 低2G没有非分页(内存属性)之说(不管是公有还是私有,都有可能会被导到文件中),只有高2G才有。
- 分页和非分页的属性是针对高2G的线性地址。非分页不过被换页。
- 独占和共享的属性是针对低2G的线性地址。
- 只要通过VirtualAlloc申请的内存都是private。
- 只有VirtualAlloc和FileMapping才是真正申请内存,malloc–heapalloc只是在VirtualAlloc申请的内存中挂一块驱动。
- VirtualAlloc:申请私有内存
- FileMapping:申请共有内存(共享物理页)
- READWRITE/READONLY/EXECUTE_WRITECOPY:线性地址属性。
4.1 内存写入检查流程
综上:决定一块内存的属性:PDE && PTE && VAD
Mapped Exe描述:
当一个程序通过LoadLibrary
进行加载时,此时该文件所在的线性地址空间的属性为Mapped Exe,权限为EXECUTE_WRITECOPY
。
由于权限为EXECUTE_WRITECOPY
的地址空间是需要共享给所有程序使用的,因此当我们对权限为EXECUTE_WRITECOPY
的线性地址空间的任何位置进行修改时,系统会先给这块内存重新映射一份物理页,然后再进行修改。
内存写入检查流程:
- 要写一块内存,如果PDE、PTE是不可写的,那再去查线性地址的属性是否是
READONLY
,如果是那就说明该内存不可写。 - 如果线性地址的属性是
EXECUTE_WRITECOPY
,即写拷贝,这个时候操作系统会重新分配一块物理页(该物理页可读可写),会把你修改的地方重新复制一份放到新的物理页,并使用新的线性地址指过去。这就是为什么HOOK之后只能影响单独的进程,因为你HOOK的地方已经被放到新的物理页上了! - 如果PDE、PTE是可写的,那直接就写了,不管线性地址是否是READONLY(绕过线性地址属性)还是啥。
当你读写某个线性地址:
如果物理页属性为只读,CPU会立马进异常,然后查询VAD树,若确实为READONLY,则会返回0xC0000005错误。
如果物理页属性为只读,CPU会立马进异常,然后查询VAD树,若确实为EXECUTE_WRITECOPY,则会复制你修改的代码到新的物理页,然后正常返回。
4.2 绕过写拷贝
方法一:直接修改该物理页的PDE、PTE属性,改成可写,不会触发异常,也就不会触发写拷贝了。
方法二:直接修改该地址的 VAD 树,将写拷贝改为可读可写。
方法三:再申请一块内存,将该内存的PTE指向该物理页,并使PDE_W/R && PTE_W/R == 1
设置属性为可读可写。
举个例子,在Windows XP系统里,MessageBoxA 这个函数位于User32.dll,假如我想HOOK它,比如把它头两个字节的MOV EDI,EDI
改成JMP,此时由于 PTE_R/W = 0,就会触发缺页异常。然后异常处理函数遍历 VAD 树,就会发现 MessageBoxA 的属性是 WriteCopy。此时,如果你对数据进行修改,系统会帮你拷贝一份 MessageBoxA 的代码,然后你的HOOK就只对本进程有效。
总结
1)线性地址分为三类:私有内存 | 共享内存 | 共享文件。
2)共享内存和共享文件本质相同,都是分配了一块物理页,不同的是共享文件将物理页和文件关联了起来。
3)传统的模块隐藏技术很难在VadRoot中进行隐藏(脱钩可能会导致程序崩溃),除非通过VirtualAlloc分配私有内存,手动将文件进行拉伸与贴入等一系列操作,此时能够大大增加寻找该模块的难度。
5 中级项目1
这是中级上保护模式和驱动开发章节的综合练习。程序可以监视系统API调用,和三期的那个函数调用监视器不同,三期的只能HOOK本进程的API,而这个项目可以监视所有进程。
当某个API被监控后,无论从哪个程序调用该API,都被HOOK了,都会跳到新的函数中,新的函数执行结束然后返回该API。
本项目的要点技术:
- 先确定监控的API是谁?在哪个模块?
- 用什么技术达到监控?可以使用DLL注入(开远程线程跳到入口点)、ShellCode注入(开远程线程跳到入口点)、HOOK(Inline注入)等。本项目使用HOOK。
- 写拷贝:API函数所在的物理页对应的线性地址属性一般都是写拷贝,则要达到在所有程序中监控该API就必须对写拷贝进行绕过。
- 绕过写拷贝:根据本项目要求,API的线性地址在每次开机加载后都是一个确定值,可以写改线性地址的属性为READWRITE(该种情况下,仍然会执行一次异常处理,然后CPU才能判断当前的线性地址属性),也可以直接修改PDE、PTE(之后访问内存就不会触发异常)。本项目修改PDE、PTE,则必须要有0环权限才能修改,故需要提权。
- 中断门提权:修改PDE、PTE可以使用。
- 调用门提权:修改PDE、PTE可以使用。
- 0环与3环通信:修改调用门的GDT描述符和IDT描述符,需要使用到函数地址,可以通过通信进行数据传输。
- HOOK技术:通常可以使用
E8 CALL
、E9 JMP
来执行HOOK。但是许多涉及到提权的木马都是直接HOOK API函数起始的前2字节,一般为MOV EDI,EDI
(0x8B,0xFF),使用中断门提权时(INT N
)刚好也是2字节,直接用来执行HOOK。 - 保存监控到的数据:最好是保存到内存中,当应用端需要的时候从内存中读取就好了,尽量避免程序输出文件(保护木马)。
- 驱动中的全局变量:在上述第2章有说过,可以使用设备扩展的内存块。
- 在裸函数内调用 DebugPrint,需要保存FS。