首页
社区
课程
招聘
[原创]Windows驱动快速入门
发表于: 2023-2-7 07:49 15866

[原创]Windows驱动快速入门

2023-2-7 07:49
15866

本公众号分享的所有技术仅用于学习交流,请勿用于其他非法活动,如有错漏,欢迎留言指正

NT驱动框架

《Windows 内核情景分析》.(毛德操)

前置知识

R3和R0 的由来

  • Intel的x86处理器是通过Ring级别来进行访问控制的,级别共分4层,从Ring0到Ring3(后面简称R0、R1、R2、R3)。R0层拥有最高的权限,R3层拥有最低的权限。按照Intel原有的构想,应用程序工作在R3层,只能访问R3层的数据;操作系统工作在R0层,可以访问所有层的数据;而其他驱动程序位于R1、R2层,每一层只能访问本层以及权限更低层的数据。
  • 这应该是很好的设计,这样操作系统工作在最核心层,没有其他代码可以修改它;其他驱动程序工作在R1、R2层,有要求则向R0层调用,这样可以有效保障操作系统的安全性。但现在的OS,包括Windows和Linux都没有采用4层权限,而只是使用2层——R0层和R3层,分别来存放操作系统数据和应用程序数据,从而导致一旦驱动加载了,就运行在R0层,就拥有了和操作系统同样的权限,可以做任何事情。

    API从应用层到内核层调用流程

  • 我们在进行Windows编程的时候,经常会调用Windows API。在 Windows 程序中,调用 Windows 函数与调用 C 语言的库函数没有什么两样。
  • 最主要的区别就是 C 语言库函数的机器代码会直接链接到你的程序代码中去,而 Windows 函数则是放到你的程序之外的DLL里。
  • 每个 Windows 的 EXE 文件包含它所要用到的各个动态链接库以及库中的函数的引用地址
    当一个 Windows 程序被装入内存后,程序中的函数调用都被解析成 DLL 函数入口的指针,同时这些被调用的函数也被装入内存
  • 当链接 Windows 程序以生成可执行文件时,一定得链接你的编程环境所提供的特殊的“导入库”。

  • 应用层调用的api一般是被封装在kernel32.dll或者Gdi32.dll/User32.dll动态链接库里面的。

  • 可以分为两类,一种是Kernel32.dll api,另一种与图形操作、键盘操作、用户操作相关的api则是被封装在Gdi32.dll/User32.dll动态链接库中。这两类api的区别:
    • 主要是进入内核之前,Kernel32.dll API会被进一步被封装成Ntdll.dll API,而Gdi32.dll/User32.dll API不会经过封装直接就进入内核;
    • Kernel32.dll API使用SSDT表(保存在ntoskrnl.exe中),而Gdi32.dll/User32.dll API则是使用Shadow SSDT表(保存在Win32k.sys中)

eg:比如说以应用层API为例:CreateFile创建/打开文件是怎么发送内核层让内核层替它完成相关的任务的?

1. API类型不同则走不同的路径:

首先CreateFile API是被封装在kernel32.dll中的API,然后会被进一步封装成Ntdll.dll API(Native的缩写,原生的意思。CreateFile在Ntdll.dll中对应的NtCreateFile或者是ZwCreateFile(NtCreateFile和ZwCreateFile是一对而且完全一样的),主要这里的Zw*和Nt*是在Ntdll.dll里面的和ntoskrnl.exe的Zw*和Nt*不一样)

2. 进入内核:

然后会通过sysenter/syscall指令(sysenter是x86的,syscall是x64的)进入我们的内核层(早期的系统是经过INT 2e软中断进入内核,由于速度比较慢,后来改进成sysenter/syscall)

2.1 何为内核?
  • 内核版本可以通过RtlGetVersion函数获得(5.0:2000,5.1:XP,5.2:2003,6.0:vista,6.1:win7,6.2:win8,6.3:win8.1,10.0:win10)
  • 内核体现在ntoskrnl.exe这个文件,而且有多个版本。在系统安装的时候统一拷贝到system目录下,并改名。
  • ntoskrnl.exe由同一套源代码根据编译选项的不同而编译出四个可执行文件:
    • ntoskrnl-单处理器,不支持PAE(物理地址扩展,让原本只支持4G内存的32位系统支持内存达到64G,即把32位系统变成了36位)
    • ntkrnlpa-单处理器,支持PAE
    • ntkrnimp-多处理器,不支持PAE
    • ntkrnamn-多处理器,支持PAE
      2.2 SSDT表
  • 在内核的Executive Service Routines 层,内部保存着一张表 “SSDT”(System Service Descriptor Table,系统服务描述符表),SSDT表中存放在一组服务函数, 通过该表找到该API在执行体 (Executive)(ntoskrnl.exe)中导出函数的位置,最终调用系统功能。
  • 这个表把R3的Win32API和R0的内核API联系起来。R3下调用的所以函数都会先进入SSDT表或者ShadowSSDT表里的服务函数。
  • 比如CreateFile API经过进一步封装然后调用sysenter/syscall入内核,在SSDT服务分发表中就有与之相对应的函数(NtCreateFile
  • 在x86下,SSDT表是导出的,即可以把它当作一个全局变量来用,直接访问SSDT表就可以得到SSDT表首地址(指针数组首地址),只需要知道索引就可以得到目标函数的地址了。如何获取到目标函数在SSDT表中索引值呢?
    • 思路一:解析Ntdll.dllPE结构(Nt函数在SSDT表有一份,在Ntdll.dll也有一份,而且编号都一样的,即Ntdll.dll是PE文件,里面存了一个和SSDT表一样的表),通过目标函数的名字,找到目标函数的索引值
    • 思路二:研究Nt函数和与之对应的Zw函数之间的关系
    • 对ZwReadFile函数反汇编得:
1
2
3
4
5
6
7
8
9
Uf nt!ZwReadFile
 
.text:00406508        move eax,0B7h      ;观察可以发现0B7h有点特殊,是NtReadFile在SSDT表的索引值,但在x64上,NtReadFile在SSDT表的索引值不在第一条指令,而是在中间
.text:0040650D      lea edx,[esp+FileHandle]
.text:00406511      pushf
.text:00406512      push 8
.text:00406514      call _KiSystemService
.text:00406519      retn 24h
.text:00406519_ZwReadFile@36 endp
  • 拿到NtreadFile函数在SSDT表的索引值
1
2
3
/// 这条x86汇编指令占5个Byte,操作码mov eax是B8,占1Byte,后四个Byte是操作数 0000B7h无符号整数
/// ZwReadFile是一个函数指针,指向函数起始的首地址,先将函数指针转化成unsigned char *类型,此时指针移动的长度才是1Byte,即((usigned char *)ZwReadFile + 1)定位到四个字节的索引值的起始地址,然后把四个字节索引值读取出来,即以四个字节为单位,读取指针指向的值,即*(DWORD *)((usigned char *)ZwReadFile + 1)
DWORD index = *(DWORD *)((usigned char *)ZwReadFile + 1)
  • 从而知道目标函数地址:
1
2
3
4
5
6
FuncAddr = KeServiceDesctiptortable + index * 4; ///< 指针运算,相当于KeServiceDesctiptortable[index * 4]这里面是存放着目标函数绝对地址
 
/// x64上,SSDT表存放的不是绝对地址,而是相对偏移,是相对KeServiceDescriptortable起始地址的偏移,即存放的是(目标函数的绝对地址-KeServiceDescriptortable起始地址)*16,即目标函数地址= KeServiceDesctiptortable + (KeServiceDesctiptortable[index * 4] / 16)
/// x64的SSDT为什么不直接存放绝对地址而是存放相对偏移,因为x64的地址是8个Byte,如果SSDT表存放8Byte的绝对地址,如果SSDT表项数目一样的情况下,SSDT表就会变大,为了依然用4Byte表示8Byte地址,就采用存放相对偏移的办法
/// 类似于实模式分段模型,但为什么*16呢,可能是为了和实模式分段模型中*16保持一致吧,也可能是访问速度快吧
FuncAddr = KeServiceDescriptortable + ((KeServiceDescriptorable + index * 4) >> 4)
  • 总的来说 Ntdll.dll 中的 API 都只不过是一个简单的包装函数而已,当 Kernel32.dll 中的 API 通过 Ntdll.dll 时,会完成参数的检查;再调用一个中断(int 2Eh 或者sysenter/syscall指令),从而实现从 R3 进入 R0 ;并且将所要调用的服务号(也就是在 SSDT 数组中的索引值)存放到寄存器 EAX 中,并且将参数地址放到指定的寄存器(EDX)中,再将参数复制到内核地址空间中,再根据存放在 EAX 中的索引值来在 SSDT 数组中调用指定的服务
    函数ZW与NT区别:
  • Ntdll.dll中:ZW与NT完全一样
  • 在ntoskrnl.exe中:
    • NT函数是存放在SSDT表中的,用来响用户态的请求或者响应内核态Zw函数的请求,即无论走用户态路径还是内核态路径都是调用NT函数
    • Zw*->Nt*(Zw函数会调用Nt),Nt函数更底层,既然Nt函数更底层,内核态驱动可不可以直接调用NT函数呢?不能!
    • 因为Zw函数会把kthread中的PreviousMode设置为KernelMode,然后再调用Nt函数,因为此时是KernelMode,所以在Nt函数中就不会进行参数检查
    • 而如果直接调用Nt函数的话,必须程序员自己将PreviousMode设置为KernelMode(修改的过程很麻烦的,因为kthread是未导出的,要硬编码偏移来定位PreviousMode,才能修改),否则PreviousMode很可能仍然是UserMode,这样的话,Nt函数就会认为对它的调用来自用户态,从而做一些检查(probe内存,发现驱动传的是内核态内存,但PreviousMode很仍然是UserMode),这时就会调用失败导致蓝屏保护,防止越权。
    • 所以在内核态,还是老老实实调用Zw函数,Zw函数要注意什么?(不要接受应用层内存,参见下面的内核驱动漏洞与攻击预防的第4条b项

      3. 封装成Irp包

      应用层传下来的参数、请求、命令(文件路径,以什么方式打开文件等)会被封装在Irp数据包

      4. 发给驱动处理

      比如CreateFile的Irp数据包会发给文件管理驱动ntfs.sys-->磁盘驱动disk.sys,最终由硬件处理,处理完再返回给调用者。

      5. 验证

  • 用windbg加载微软符号,可以在windbg看到应用层到内核层的调用栈,结合应用层和内核层的代码,可以清晰看到api从应用层到内核层的传递过程
  1. 首先双击运行一个程序的时候, kerenl32中的BaserProccessStart (模块名!该模块中的函数)调用main模块的MainCRTStartup(一个程序最早执行的是MainCRTStartup),然后调用__tmainCRTStartup之后才会调用main模块中的main函数
  • CONSOLE(无windows界面)程序中,main函数是用户定义的执行入口点,当程序编译成功之后,连接器(linker)会将mainCRTStartup连接到exe中;
  • exe执行时,一开始执行的就是mainCRTStartup,而不是main。这是因为程序在执行时会调用各种各样的运行时库函数,因此程序执行之前必须要先初始化好运行时库,mainCRTStartup函数会负责相应的初始化工作,它会完成一些C全局变量以及C内存分配等函数的初始化工作(函数CRTInit会去调用某些表的函数 ),如果使用C++编程,还要执行全局类对象构造函数。最后,mainCRTStartup才调用main函数
  • CONSOLE(无windows界面): mainCRTStartup(或wmainCRTStartup)->main
    (其中w开头的函数是unicode版本的工程,CRTStartup(C Runtime startup Code))
  • WINDOWS(有界面):WinMainCRTstartup(或 wWinMainCRTStartup) ->WinMain
  • 如何在main()函数之前执行一些代码?
    • 非正常方法:改EOP(修改程序入口点,病毒感染的原理,加壳也是改了EOP)
    • 正常开发中的方法:
  • 1.gcc中,可以使用attribute关键字,声明constructordestructorC函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<stdio.h>
 
/// 在linux中,这个函数先于main函数执行
__attribute__((constructor)) void before_main() { 
   printf("before main\n");
}
 
/// 在linux中,这个函数后于main函数执行
__attribute__((destructor)) void after_main() {
   printf("after main\n");
}
 
int main(int argc, char **argv) {
   printf("in main\n");
   return 0;
}
  • 2.VC中不支持attribute
    • 但可以把想要在mian函数之前执行的代码插入初始化函数表 [_xi_a,xi_z](c)[_xc_a,_xC_z](C++),由CRTInit调用
    • 在main函数之后执行的函数可以使用_onexit(after_main);来注册
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// c_premain.cpp : Defines the entry point for the console application.
//
#include <stdlib.h>
//通过把函数放进特殊段名的代码区,插入C和C++初始化函数表中
int before_main1()
{
    printf("before_main1()\n");
    return 0;
}
int before_main2()
{
    printf("before_main2()\n");
    return 0;
}
int after_main()
{
    printf("after_main()\n");
    return 0;
}
/*
__CRTInit中做一些初始化工作:
包括C库、C的初始化函数,C++库、C++的初始化函数等.
C和C++分别有一张表来保存初始化函数指针,
每个表又使用2个指针来明确范围,
__CRTInit会依次调用这2个表中的函数.
C初始化函数表:[ __xi_a, __xi_z] __xi_u
C++初始化函数表: [ __xc_a, __xc_z] __xc_u
现在对照代码注释,就会明白上述那段代码的作用.
通过特殊的段名称".CRT$XIU",".CRT$XCU",
链接器会把before1表放在"C初始化函数表"中,类似这样
[__xi_a, ..., before1(xiu), ..., __xi_z]. u在a到z之间
同理,before2表会被链接器放在"C++初始化函数表"中,象这样
[__xc_a, ..., before2(xcu), ..., __xc_z],u在a到z之间
*/
 
typedef int func(); //int(*func)()才是声明一个函数指针,而这里声明的是一个返回值int,参数是void的函数类型,是这样使用的func p=&before_main1; int temp =p(arg1);
 
/*
int a = 0;
int *p = &a;
int **pp = p;(pp存放p的地址,p存放a的地址)
*/
#pragma data_seg(".CRT$XIU") // 定义了一个数据节XIU在xi_a和xi_z之间
static func *before1[] = {before_main1}; //定义了一个指向func的指针的指针(即**before存放的是before_main1函数指针的地址,*before存放的是before_main1函数的地址),链接器会把before1表放在"C初始化函数表"中,类似这样[__xi_a, ..., before1(xiu), ..., __xi_z]. u在a到z之间
 
#pragma data_seg(".CRT$XCU") // 定义了一个数据节XCU在xc_a和xc_z之间
static func *before2[] = {before_main2}; //同理,before2表会被链接器放在"C++初始化函数表"中,象这样[__xc_a, ..., before2(xcu), ..., __xc_z],u在a到z之间
 
#pragma data_seg()
 
/// __CRTInit会mian之前依次调用这2个表中的函数.
int _tmain(int argc, _TCHAR *argv[])
{
    _onexit(after_main); //在main函数之后执行的函数可以使用_onexit(after_main);来注册
    printf("hello world\n");
    return 0;
}
  • 3.C++无论是在gcc还是在VC,方法是统一的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// cpp_premain.cpp : Defines the entry point for the console application.
//
 
#include <iostream>
using namespace std;
using std::cout;
 
int func()
{
    cout<<"func() called before main()"<<endl; //把要先于main函数执行的代码放到初始化全局变量的函数里面
    return 100;
}
class A
{
public:
    A()
    {
        cout<<"A() constructor called"<<endl; //把要先于main函数执行的代码放到构造函数里面
    }
    ~A()
    {
        cout<<"~A() destructor called"<<endl;
    }
};
 
A a; //定义一个全局类,在mian函数之前会初始化全局类,就会去调用全局类的构造函数
int g_iValue = func(); //除了全局类,还可以通过全局变量(用函数去初始化全局变量)
 
int _tmain(int argc, _TCHAR* argv[])
{
    cout<<"main() called"<<endl;
    return 0;
}
  • 在main()函数之前执行执行代码的目的有哪些?
    • 加解密防破解(自解密这部分工作需要在main函数执行之前做的)

      《简单粗暴的so加解密实现》

  1. ->TestDriver(在应用层代码main函数调用了TestDriver函数)
  2. ->CreateFileA->CreateFileW(A代表多字节,W代表宽字节),接着调用ntdll中的KiFastSystemCallEntry(进入内核)
  3. ->KiFastCallEntry(所有应用层api进入内核后必须经过这个函数,负责请求的分发,把请求分发到SSDT表中的NtCreateFile;HOOKKiFastCallEntry这个函数这样可以监控应用层所有调用)->NtCreateFile(可以HOOK这个函数来监控文件的创建)
  4. ->IO管理器会为其分配一个IRP(IRP_MJ_CREATE)->ntmodeldriv模块(自己写的驱动)的DispatchCreate分发函数(如果是文件的读/写,那最终还会经过磁盘驱动,可以通过过滤驱动来进行监控驱动)
  • 通过这个栈也就可以在知道安全软件有哪些位置可以进行监控了。
  • 最后, 总结一下, 微软在Intel处理器上开发Windows操作系统, 我们在Windows操作系统上开发应用程序,无非是一层层的封装, 其实具体到细节, 每层都没有太多神秘的东西。我们当然不可能掌握每层的细节, 只能理解每层的概念, 以帮助我们更好地开发。

    IRP定义

    头部

    • IOSTATUS
      • Status:这次io的完成状态,成功还是失败,失败是由于什么原因失败的,用来放置错误码的,错误码会从内核层传输到应用层,应用层从而知道错误的原因。
      • eg:在内核层会调用各种IO函数会有一个返回状态,这个返回状态就是存放在这里,从而返回给应用层;而在内核层也有各种函数返回值,比如DriverEntry中的"return STAUS_SUCCESS",就不是返回给应用层的,而是返回给IO管理器的
      • Information:表示这次IO的相关信息,在读/写操作的过程中,它是用来表示这次IO实际都读/写了多个字节
      • eg:应用层读/写的函数都有一个返回值用来标识这次IO实际读/写多少个字节
  • SystenBuffer (对应r3和r0的3种通信方式之一:buffer io)
  • MdlAddress (对应r3和r0的3种通信方式之一:direct io)
  • UserBuffer (对应r3和r0的3种通信方式之一:neither io)

  • 栈分很多层,是为了支持windows内核驱动框架分层设计,eg:Irp从上往下发,从文件过滤驱动设备->文件卷设备->磁盘设备,每一层都有与这一层相关的使用数据,每一层都有属于自己的栈空间。会将这些数据的头部分离之后剩下的数据保存在这一层对应的栈上。
  • 每一层栈的结构
    • MarjorFunction(主功能号,告诉我们这一层请求是什么请求,是读、写还是打开等)
    • MinorFunction(次功能号,eg:IRP_MJ_SET_INFORMATION,这个Irp有两个次功能号用来区别删除重命名操作)
    • union(有多个结构体组成的联合体,根据Irp的类型,选用不同的struct。从Parameters拿到对应缓存的长度,通过Irp头部拿到缓存的地址,知道了缓存的首地址和长度,即确定了Irp传下的数据)
1
2
3
4
5
union{
    struct{...}Read;
    struct{...}Write;
    struct{...}DeviceControl;
}Parameters;
  • DeviceObject (发送Irp的设备对象)
  • FileObject()

    通信方式

  • Q1:何为通信?
    应用层传数据到内核层,或者内核层发数据到应用层
  • Q2:何为通信协议?
    把数据放在什么地方去
  • R3和R0之间的readwrite的通信方式有三种
  1. buffered io
    • 在内核层分配一块缓存,io管理器负责把应用层/内核层copy到buffer,io管理器负责把buffer拷贝到io管理器负责把内核层/应用层。
    • 优点:安全简单,因为不会操作应用层的内存,buffer是来自内核态的,应用层无法改内核层的数据,所以是安全的。
    • 缺点:效率低,因为一次通信有两次拷贝,一般传输数据量是不大的,buffer io是够用的,但如果是类似3d渲染,数据量大,direct io更适合;
  2. direct io
    • io管理器通过MPL把应用层/内核层的虚拟地址映射成物理地址,然后lock,防止被这块内存切换出去(pageout),io管理器通过MPL把同一物理地址映射成内核层/应用层的物理地址
    • 优点效率是最高的,一次通信只有一次拷贝
    • 但稍复杂
  3. neither io
    1
    内核层直接访问应用层的数据,前提是应用层和内核层`同处于一个进程上下文`(因为应用层内存地址是私有的,应用层进程切换之后内存就失效了),要对内核层传入的内存地址要做检查(`ProbeForRead`/`ProbeForWrite`),否则会有提取漏洞
  • Q3:三种通信方式中,应用层发下来的内存和内核层把数据返回给应用层的内存对应Irp的什么位置?
    应用层发下来给内核层的内存对应Irp的;内核层把数据返回给应用层的内存对应Irp的头部SystenBufferMdlAddressUserBuffer

    MDL(memory descriptor list)

    《程序员求职成功路》

1
2
3
4
5
6
7
8
9
10
11
typedef struct _MDL
    struct _MDL *Next
    CSHORT Size; 
    CSHORT MdlFlags; 
    struct _EPROCESS *Process; 
    PVOID MappedSystemVa; 
    PVOID StartVa; 
    ULONG ByteCount; 
    ULONG ByteOffset; 
}MDL,*PMDL;
  • MDL只是一个对物理内存的描述,但是因为系统跟Driver都是使用虚拟内存,所以MDL就是把虚拟内存『映射』到物理内存(from DDK)。这样讲是很模糊的,其实MDL的作用很简单:当Driver要存取某段内存位置时,确保MDL所描述的内存位置不会引起page fault。
  • 取得MDL的虚拟内存位置。 DDK特别讲明,Lower-Level Driver不可以直接把这个Address拿来使用,因为这有可能是user-space的内存位置。因此,Driver必须调用MmGetSystemAddressForMdlSafe()来取得并锁定这个Address所对应到的system-space的内存位置。
  • 如果Driver希望建立的MDL是映射到Driver自己配置的Non-Paged记忆体的话,Driver还得调用MmBuildMdlForNonPagedPool()。这是因为IoAllocateMdl()只有配置记忆体,但是并没有Build MDL 。
  • 临时用户空间缓冲区增添一个系统空间映射,这使同一组物理页面有了两个虚拟地址区间,其一就是原来的用户空间虚拟地址区间,其二则是系统空间的虚拟地址区间。于是,就可以通过系统空间的虚拟地址访问用户空间缓冲区了,直到完成操作而返回用户空间时才撤销系统空间的映射。这种方法称为“直接”方法。直接方法对于很小的缓冲区不划算的,因为临时映射的建立撤销需要一定的开销,对于大一点的缓冲区才合适。`
  1. 用户态到内核态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
BaseAddr = OpenKernel32();        //映射kernel32的section到本进程的低2G空间 
if (!BaseAddr) 
    KdPrint(("DriverEntry--OpenKernel32 failure!\n")); 
    return 0
 
KdPrint(("BaseAddr: 0x%08x\n",BaseAddr)); 
 
//创建一个MDL 
pMDL = IoAllocateMdl(BaseAddr,0x11c000,FALSE,FALSE,NULL); 
if (!pMDL) 
    KdPrint(("pMDL == NULL\n")); 
    return 0
 
_try 
    MmProbeAndLockPages(pMDL,UserMode,IoReadAccess); 
_except(EXCEPTION_EXECUTE_HANDLER) 
    KdPrint(("MmProbeAndLockPages exception\n")); 
 
_try 
    pMapedAddr = MmMapLockedPagesSpecifyCache(pMDL,
                                              KernelMode,
                                              MmCached,
                                              NULL,
                                              FALSE,
                                              NormalPagePriority); 
    if (!pMapedAddr) 
    
        KdPrint(("pMapedAdd == NULL\n")); 
        return 0
    
_except(EXCEPTION_EXECUTE_HANDLER) 
    KdPrint(("MmMapLockedPagesSpecifyCache exception\n")); 
}

2.内核态到用户态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pShareMM_SYS = ExAllocatePool(NonPagedPool,1024);//size必须是page的整数倍 
RtlZeroMemory(pShareMM_SYS,1024); 
 
pShareMM_MDL = IoAllocateMdl(pShareMM_SYS,1024,FALSE,FALSE,NULL); 
MmBuildMdlForNonPagedPool(pShareMM_MDL); 
 
pShareMM_User = MmMapLockedPagesSpecifyCache(pShareMM_MDL,
                                             UserMode,
                                             MmCached,
                                             NULL,
                                             FALSE,
                                             NormalPagePriority); 
KdPrint(("pShareMM_SYS的地址为: 0x%p\n",(PUCHAR)pShareMM_SYS)); 
KdPrint(("pShareMM_User的地址为: 0x%p\n",(PUCHAR)pShareMM_User));

驱动函数的集合

  • 知道驱动函数的集合的好处是,当想不起来的时候,可以只输入前面几个字母,利用IDE的自动补全,进行模糊查询
  • ExXxx()执行
    • ExAllocatePoolWithTag() ;
    • ExAcquireFastMutex;
    • ExGetPreviousMode
  • IoXxx()与IO管理器相关
    • loCreateDevice;
    • loCreateSymbolicLink;
    • loGetCurrentlrpStackLocation ;
    • loAttachDeviceToDeviceStack;
    • loAllocatelrp;loSetCompletionRoutine
  • KeXxx()与同步相关
    • KeWaitForSingleObject;
    • KeSetEvent
  • MmXxx() 内存管理
    • MmGetSystemRoutineAddress;
    • MmlsAddressValid
  • ObXxx() 操作内核对象
    • obReferenceobjedBYHandle;
    • ObQueryNameString
  • PsXxx() 进程相关的
    • PsGetCurrentProcess
    • PsGetCurrentProcessld
    • PsCreateSystemTh read
    • PsLookupProcessByProcessld
  • RtlXxx() 运行时库
    • RtlZeroMemory
    • RtllnitUnicodeString
  • ZWXXX() 与文件注册表相关的,ZW没有任何含义,只是为了和其他函数区分开
    • ZwOpenKey;
    • ZwCreateFile;
    • ZwOpenProcess;
    • ZwQuerySystemInformation
  • FItXxx()文件过滤相关
  • NdisXxx()Ndis过滤相关

    驱动在安全领域的应用:

  • 弹窗和拦截
  • HOOK
  • 绑定与过滤
  • 注册回调来监控

    内核驱动漏洞与攻击预防

    基于《内核驱动漏洞原因和七大忠告》原作者:MJ0011 进一步完善

1. 不要使用 MmIsAddressValid 函数,这个函数对于校验内存结果是 unreliable 的。

  • 首先,他只能判断一个字节地址的有效性 :
    (一个物理内存页是4k,只需要一个字节有效,则认为整个内存页是有效的)
    比如: 拒绝服务攻击
1
2
3
4
5
if(MmIsAdressValid(p1){ //判断内存地址P1是否有效
    //C库函数int memcmp(const void *str1, const void *str2, size_t n)) 把存储区str1和存储区 str2的前n个字节进行比较
    /// @warning 攻击者只需要传递第一个字节在有效页,而第二个字节在无效页的内存就会导致系统崩溃, 例如 0x7000 是有效页,0x8000 是无效页,攻击者传入p1=0x7fff
    memcmp(p1,p2,len);
}
  • 其次,MmIsAddressValid 对于 pageout 的页面不能准确的判断(MmIsAddressValid 对pageout的内存的返回值是Ture或者False是不能确定的 ),所以攻击者可以利用你的判断失误来绕过你的保护。
  • 如果对一个函数没有把握,直接看英文文档是不错的选择https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/content/ntddk/nf-ntddk-mmisaddressvalid

2. 在 try_except 内完成对于用户态内存的任何操作:

错误写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// 如果使用r3和r0通信方式中的第三种,在内核中访问应用层发下的内存的时候,一定要Probe,
//以确保应用层发下来的地址一定是R3的地址,因为r3没有资格访问内核态的地址。
__try{
        //(内存地址,长度,对齐方式)
        ProbeForRead(Buff, Len , Alig);
}
  /// 捕获异常,保证系统的稳定性,如果不放在try_except,当异常发生,异常没有被捕获,系统就奔溃掉
__except(EXECUTE_HANDLER_EXCEPTION) {
        ...
}
/// @warnig 任何操作必须要放在try{}里面,因为ProbeForRead 只能确保在检查probe的那一刻, buff 地址是在用户态地址范围内。
/// 当出去try{}之后,buff地址可能就发生变化了,就不再能保证是buff地址是用户态地址了。
if (memcmp(Buff , Buff2 , Len)
.....

3.留心长度为 0 的缓存、为 NULL 的缓存指针和缓存对齐

  • a.长度为 0:
    内存校验函数 ProbeForReadProbeForWrite 函数当 ProbeForXXX 的参数 Length0 时, 这两个函数都不会做任何工作,连微软都犯过错。
      1. 使当你 ProbeForRead 验证了参数,一样要当心 Length 为 0 时的情况常见错误:
        当 Len=0 时,这样的函数会导致系统崩溃。
1
2
3
4
5
6
7
8
9
10
11
__try{
        /// (内存地址,长度,对齐方式)
        /// @warning 如果攻击者传一个内核态的地址下来,同时将len设为0
        ///          就会轻易地绕开函数的检查,我们设置的保护就不起作用了
         ProbeForRead(Str1,Len,sizeof(WCHAR));
         if(wcsnicmp(Str1,Str2,wcslen(Str2)) {
             ....
         }
      }
        __except(EXECUTE_HANDLER_EXCEPTION) { ....
}
    1. 需要注意,对于长度为 0缓存不能随意放行, 因为系统可能接受长度为 0 的缓存参数做特殊用途, 比如: 对于 ObjectAttributes->ObjectName (内核对象的名字)的 Length, 如果为 0, 系统会以对应的参数打开 ObjectAttributes->RootDirectory 的句柄(即是接受长度为0的情况的), 攻击者可以先以低权限(比如只能读不能写)得到一个受保护对象的句柄(比如可写可读),再以长度为 0 的缓存,将句柄填入RootDirectory 来获取高权限的句柄(先把只读的句柄填入RootDirectory,再重新调用一个函数,把长度为 0 设为零,系统就会去打开RootDirectory,这时候被修改的RootDirectory句柄可能就是可读写的句柄了)。造成提权漏洞
  • b.缓存指针为空:
    不要使用诸如下面的代码来判断用户态参数:
1
2
3
4
5
6
7
/// @warnig buffer==NULL 并不能代表是个无效内存
/// Windows操作系统是允许用户态申请一个地址为0的内存的,攻击者可以利用这个特性来绕过检查和保护。
/// win8及以上版本系统微软已经封杀了这个漏洞
if (UserBuffer == NULL)
{
    goto pass_request;
}
  • c.缓存对齐的问题:
    ProbeForRead 的第三个参数 Alig 即对齐, 如果没有正确地传递这个函数, 也会导致问题,例如对于 ObjectAttributes ,系统默认按 1Byte 来对齐,如果在对其参数处理中使用 sizeof(ULONG)来对齐,就会对本来可以使用的参数引发异常,绕过保护或检查。

4.不正确的内核函数调用引发的问题

  • a.ObReferenceObjectByHandle 未指定类型
    对于用户态句柄使用 ObRefenceObjectByHandle(根据句柄拿到内核对象,因为句柄不跨进程只在同一个进程有效,如果把句柄传给另一个进程,它是无效的。所以一般是拿到handle之后直接得到它的fileobject), 不指定类型仍可以获得对应的对象地址,但如果你直接访问这个对象,就会引发漏洞常见的错误: 拒绝服务攻击
1
2
3
4
5
6
/// 把文件的句柄转换成文件的内核对象
/// @warning 没有指定一个句柄的类型,攻击者可以传入非文件类型的句柄从而造成系统漏洞,得到其他类型的内核对象,对应的结构体的定义里很可能没有FileName,就会行为未定义或者无效内存,下面调用wcsnicmp访问FileName,系统会崩溃,造成蓝屏。
///没有指定一个句柄的类型如果指定了句柄的类型,即使攻击者故意发下来句柄和指定的不符,函数会执行失败,从而wcsnicmp会发现这个失败,就不会去访问fileobject->FileName了
ObReferenceObjectByHandle(FileHandle , Access , NULL(ObjectType) ,...&fileobject);
/// 再访问文件内核对象的文件路径 wcsnicmp把文件内核对象的文件路径与某一路径进行比较
if(wcsnicmp(fileobject->FileName....)
  • b.不正确的 ZwXXX 函数调用
    注意,不能将任何用户态内存通过调用 ZwXXX 函数传递给内核,用户态内存未经过校验, 传递给 ZwXXX 会让系统忽略内存检查(因为 ZwXXX 调用时认为上个模式已经是内核模式) ,即使你进行了校验,传递这样的内存给系统也可以引发崩溃(例如内存页在调用时突然无效) ,即使你在外部有异常捕获,也可能造成内核内存泄露、对象泄露,甚至权限提升等严重问题
    常见的错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
__try{                     
        ProbeForRead(ObjectAttrbutes,sizeof(OBJECT_ATTRIBUTES),1);
        ProbeForRead(ObjectAttributes->ObjectName,
                     sizeof(UNICODE_STRING),1);
        ProbeForRead(ObjectAttributes->ObjectName->Buffer,
                     ObjectAttributes->ObjectName->Length,
                     sizeof(WCHAR));
        ZwOpenKey(&hkey,ObjectAttributes....)
/// @warning 未校验全部参数就传递给 Zw 函数, ObjectAttributes 还有多个域
/// @warnig 即使全部校验了也不能传递给 Zw 函数,例如内存如果是无效的用户态内存,
///         最后会被我们驱动的异常捕获,但是内核函数的数据回收很可能没有进行,导致内存泄漏
///            甚至导致权限的提示等问题(因为触发了try-except,万一异常处理函数被别人动了手脚,可能会执行一些额外的提权代码)
}
  • c.不要接受任何用户输入的内核对象给内核函数
    接受用户输入的内核对象意味着可以轻易构造内核任意地址写入漏洞, 不要在设备控制中接受任何用户输入的内核对象并将其传递给内核函数。
    常见的错误:
1
2
3
4
5
6
7
8
9
/// 在应用层传一个控制码IOCTL_RELEASE_MUTEX下来
if(IoControlCode==IOCTL_RELEASE_MUTEX)
{
    /// 在内核层调用KeReleaseMutex把互斥内核对象释放掉
    /// @warning 在内核中有很多内核对象(进程、文件,驱动,事件,信号量,线程等)
    /// 这些内核对象在内核态采用的是双向循环链表来组织和管理的,进程隐藏其实就是把进程的内核对象从双向链表摘掉,这里把互斥体内核对象释放掉,就是把内核对象从双向循环链表中摘掉(即修改该结点的首尾指针,就有两次内存写入的机会,与堆溢出的原理一样),用户态程序只需要传递一个精心构造的 Mutex 对象 (可以位于用户态内存 ) , 就可以做到任意地址写入,提升权限。驱动要采用最小原则,尽可能少暴露接口出去。
    KeReleaseMutex((MY_EVT_INFO)(Irp->AssociatedIrp->SystemBuffer)->Mutex);
}

用户态程序只需要传递一个精心构造的 Mutex 对象 (可以位于用户态内存 ) , 就可以做到任意地址写入,提升权限

5.给驱动提供的功能性接口必须小心(权限最小)

例如可以对注册表、 文件、 内存、 进程线程等操作的功能性接口(控制码,添加注册表,删除文件,分配内存,创建进程线程等), 一定要非常小心, 如果不能完全杜绝存在被恶意利用的可能(恶意进程会借助你暴露的控制码来借刀杀人), 一定要限制设备控制的调用者(应用要打开设备得到句柄才能进行读写deviceioctrl请求。前提是能打开我们驱动,所以从根本入手,应该限制能够打开我们驱动设备对象的进程。在打开的分发函数中获取调用者的pid校验签名(比如360,可以通过非对称加解密拿到签名。在.exe发布的时候,将信任的.exe文件(交了钱的.exe)用自己的私钥对它进行加密,把密文放在key文件新增的的section里面,用自己的公钥对密文进行解密会得到md5;当有程序在打开我的进程的时候,拿到它的磁盘路径,对它section中的数据用自己的公钥对密文进行解密会得到md5,对比这两个md5是否一致),如果签名不对,禁止打开(return STATUS_SEVERITY_SUCCESS_DENY),禁止一切非受信进程的调用(没有包含我们驱动的签名的进程),但这种方式无法防止dell注入),如金山网盾的漏洞,腾讯的 QQ 医生漏洞等。

6. 设 备 控 制 尽 量 使 用 BUFFERED IO, 而 且 一 定 要 使 用SystemBuffer, 如 果 不 能 用BUFFERED IO,对于 UserBuffer 必须非常小心地 Probe,同时注意 Buffer 中指针、 字符串引发的严重问题(对指针、 字符串也要进行probe),如果可能,尽量禁止程序调用自己的驱动。

  • 设备控制中,可以的话尽量使用 BUFFERED IO,使用 BUFFERED IO 时一定要注意仅使用 SystemBuffer (参考超级巡警曾出现的漏洞)如果不能使用 BUFFERED IO,对于UserBuffer 一定要做 ProbeForReadTry_except 的完整校验。 即使使用 Buffered IO,Buffer 中的指针也可能引发内核 DOS(拒绝服务) 或提权,如果 BUFFER 中还有指针(可能指向的不是内核态地址),必须像对待非BUFFERED IO 的 UserBuffer 那样仔细地校验,同时对于字符串使用也要非常小心,杜绝使用 strcpy这样的会引发栈溢出的函数。
  • 不过分影响产品功能的前提下,尽一切可能限制对驱动的调用。
    例如在打开设备时检查是否保护进程(DisptchCreate),至少要检查是否为Admin用户,如果可以的话尽量禁止非 Admin 用户打开设备,同时,如果是服务进程或常驻进程使用的驱动设备,可以在 IoCreateDevice 时对 Exclusive(排他性,只允许一个进程打开)参数传 TRUE,来杜绝其他进程打开设备

    7.使用 verifier(内核校验器,微软系统自带的)和 Fuzz 工具检查和测试驱动

    SecurityCheck 功能就可以检查驱动是否传递了错误的内存、句柄给内核函数对于挂钩内核函数的驱动,可以使用 BSOD HOOK 一类的 FUZZ 工具,来检查内核函数的缺陷和漏洞
  • verifier

    如果驱动有问题,会蓝屏,根据蓝屏提示,改进我们的驱动

    总结:

  • 对函数调用要小心
  • 对内存操作要小心
  • 对外接口暴露要小心

    NT驱动框架

  • NT框架是由三部分组成:
    • DriverEntry(驱动程序执行入口,类似main函数。负责初始化)
    • DriverUnload(卸载函数,负责资源的回收和释放,比如DriverEntry创建了一些句柄和设备对象,在DriverUnload需要把他们释放掉)
    • 若干分发函数
  • 应用层的API在驱动中都有对应的分发函数,分发函数执行的时候处于进程的上下文
  • DriverEntry和DriverUnload(都是单线程环境),分发函数是多线程环境(所以在分发函数中使用全局变量的时候,记得要加锁
  • NT框架是基础,因为其他更复杂的框架都是在NT框架的基础上添加新东西形成的。

内核层代码

定义设备对象名,符号链接名和控制码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
*  Copyright (c) 2022, 源代码已同步到gitee(https://gitee.com/ciscco/system_secure_official_account)
*  All rights reserved.
*
*  @file        1_NtDriver/ntdrv.c
*  @version     v0.1
*  @author      cisco(微信公众号:坚毅猿)
*  @date        2022-02-13 22:52
*
*  @brief
*  @note
*  @see
*/
#include <ntddk.h>
 
/// @brief      设备对象名 "\\device(固定不能变)\\自己任意取",符号链接名 "\\dosdevices(固定定不变或者可以使用"??"代替"dosdevices")\\自己任意取";
/// @note       "L"表示这里定义的是宽字节字符串,即"WCHAR *"或者"wchar_t *",但在驱动中统一使用的字符串类型是"UNICODE_STRING",所以需要调用函数"RtlInitUnicodeString(&uDeviceName, DEVICE_NAME)"来转换
///             "\\"代表"\"(是因为第一个'\'会被被识别成转义字符的标志,这是windows中表示路径的常规写法)
/// @see        DriverEntry()
/// @warning    但最好设备对象名、符号链接名和驱动名取相同名字,避免混淆,因为不清楚什么时候使用对应的名字往往会导致驱动加载出问题;
#define DEVICE_NAME L"\\device\\ntmodeldrv"
#define LINK_NAME L"\\dosdevices\\ntmodeldrv"
 
#define IOCTRL_BASE 0x800 ///< 控制码的起始值,比如有5个控制码,第一个为0x800,第二个0x801,第三个0x802 ...
/// MYIOCTRL_CODE 用来定义控制码
/// FILE_DEVICE_UNKNOWN 在DriveEntry中定义的设备对象的类型
/// METHOD_BUFFERED 通信协议,这里使用的是buffer io
/// @warning  在DriveEntry中指定设备对象的r3和r0通信方式只是用来规定Read和Write,
/// 管不了DispatchIoctrl,DispatchIoctrl的通信协议是由这里的控制码METHOD_XXX来决定的
/// FILE_ANY_ACCESS 所有的权限,包含读和写
/// #define CTL_CODE( DeviceType, Function, Method, Access ) (                 \
/// ((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) \
/// )
 
#define MYIOCTRL_CODE(i) \
    CTL_CODE(FILE_DEVICE_UNKNOWN, IOCTRL_BASE+i, METHOD_BUFFERED,FILE_ANY_ACCESS)
/// 三个是r3和r0进行额外通信的控制码
/// @warning     要保证内核层和应用层定义一致,一般把这部分代码放在应用层和内核层共享的公共头文件中
/// @note        可以自定义更多的控制码,为了保证通信安全,也可以对控制码进行加密
///
#define CTL_HELLO MYIOCTRL_CODE(0)
#define CTL_PRINT MYIOCTRL_CODE(1)
#define CTL_BYE MYIOCTRL_CODE(2)

入口函数DriverEntry

  • 1.初始化驱动对象名和符号链接名;
  • 2.创建设备对象;
  • 3.对刚创建的设备对象指定一个通信方式;
  • 4.创建符号链接
  • 5.注册分发函数
  • 6.注册卸载函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
/**
*  @brief       DriverEntry 是加载驱动程序后调用的第一个函数,它负责初始化驱动程序;
*  @param[in]   pDriverObject 由IO管理器创建,标识驱动,创造设备对象,指向 DRIVER_OBJECT 结构体;
*  @param[in]   pRegPath 驱动安装后在注册表中的路径,指向 UNICODE_STRING 字符串的指针,
*                        该结构指定注册表中驱动程序的 Parameters 项的路径;
*  @return      ntStatus 如果例程成功,则它必须返回 STATUS_SUCCESS.
*                        由于#define STATUS_SUCCESS                   ((NTSTATUS)0x00000000L)
*                         即成功返回0,否则为非0,**与应用层相反,应用层返回0则是失败**.
*                        否则,它必须返回在 ntstatus 中定义的错误状态值之一;
*  @warning     驱动入口函数名称是写死的不可修改.
*  @warning     不要使用.cpp来写驱动,因为C++在编译器里面会改名(命名粉碎规则(name mangling))驱动框架就不认识DriverEntry这个名字了,会导致编译出错;
*  @see         https://docs.microsoft.com/zh-cn/windows-hardware/drivers/wdf/driverentry-for-kmdf-drivers
*  @author      cisco(微信公众号:坚毅猿)
*  @date        2022-02-13 22:09
*/
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegPath)
{
    /// 函数内部并没有用到参数pRegPath,所以需要UNREFERENCED_PARAMETER(pRegPath),让编译器忽略掉未使用的变量;
    UNREFERENCED_PARAMETER(pRegPath);
 
    UNICODE_STRING uDeviceName = { 0 }; ///< 设备对象的名字,UNICODE_STRING是windows内核中统一使用的字符串类型
    UNICODE_STRING uLinkName = { 0 };   ///< 符号链接的名字
    NTSTATUS ntStatus = 0;              ///< 返回状态
    PDEVICE_OBJECT pDeviceObject = NULL; ///< 设备对象指针
    ULONG i = 0;
 
    DbgPrint("Driver load begin\n");
    /// 初始化初始化设备对象名和符号链接名
    /// 就是将"wchar_t *"转换 "UNICODE_STRING *",同时将DEVICE_NAME赋值给uDeviceName
    ///@todo  没有找到它的实现代码
    RtlInitUnicodeString(&uDeviceName, DEVICE_NAME);
    RtlInitUnicodeString(&uLinkName, LINK_NAME);
 /**
 *  @brief       IoCreateDevice 创建设备对象;
 *  @param[in]   pDriverObject 由IO管理器创建,标识驱动,创造设备对象,指向 DRIVER_OBJECT 结构体;
 *  @param[in]   DeviceExtensionSize 设备扩展,创建设备对象的一个缓冲区空间,用来存放一些数据,不需要空间,则设置为0;
 *  @param[in]   DeviceName 设备对象名
 *  @param[in]   DeviceType 设备对象的类型,比如磁盘设备类型等,未知设备类型的定义为
 *               "#define FILE_DEVICE_UNKNOWN             0x00000022"
 *  @param[in]   DeviceCharacteristics 文件的属性
 *  @param[in]   Exclusive 排他性,最好设置为TURE表示只要有一个进程打开,其他进程就无法打开,从而提高驱动安全性;
 *  @param[out]   &pDeviceObject 被创建的设备对象的指针的指针,是二级指针;
 *  @warning     如果不传二级指针,pDeviceObject是不会发生改变的,存在内存泄漏;
 *  @todo        为什么会导致内存泄漏
 *  @return      ntStatus 如果例程成功,则它必须返回 STATUS_SUCCESS.
 *                        因为#define STATUS_SUCCESS                   ((NTSTATUS)0x00000000L)
 *                        即成功返回0,否则为非0,**与应用层相反,应用层返回0则是失败**.
 *                        否则,它必须返回在 ntstatus 中定义的错误状态值之一;
 *  @note        为什么要在驱动中创建一个设备对象?是因为只能用设备对象是用来接收应用层的IRP数据包.
 *               eg1:应用层调用CreateFile()进入内核层会封装成一个irp数据包,irp发给指定的由驱动创建的设备对象,
 *               设备对象接收到irp之后才会传给驱动分发函数去处理;
 *  @note        Q1:驱动对象和设备的对象的关系?互指.
 *               因为同一个驱动对象可以创建多个设备对象,比如可以创建控制设备对象,可以创建过滤设备对象等,
 *               这些设备对象都被串联起来存放在同一个链表中,而且每个设备对象都有一个指针指向创建它的驱动对象.
 *  @see         C:\Program Files (x86)\Windows Kits\10\Include\10.0.22000.0\km\wdm.h
 *  @author      cisco(微信公众号:坚毅猿)
 *  @date        2022-01-22 22:09
 */
    ntStatus = IoCreateDevice(pDriverObject,
        0, &uDeviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &pDeviceObject);
 
    if (!NT_SUCCESS(ntStatus))
    {
        /// 错误状态值ntStatus如果以十进制打印出来还可能是个负数,不如十六进制来得直观
        /// 通过查询ntstatus.h的宏可以很方便找到对应的错误类型.
        /// 例如:#define STATUS_OBJECT_NAME_INVALID       ((NTSTATUS)0xC0000033L);
        DbgPrint("IoCreateDevice failed:%x", ntStatus);
 
        return ntStatus;
    }
    /// 对刚创建的设备对象指定一个通信方式;
    /// @note       "|="是往Flges添加一些标志
    /// @note       Q2:何为通信?A2:应用层传数据到内核层,或者内核层发数据到应用层
    ///             Q3:何为通信协议?A3:把数据放在什么地方去
    /// DO_BUFFERED_IO规定R3和R0之间的read和write的通信方式有三种
    /// 1.buffered io 在内核层分配一块缓存,io管理器负责把应用层/内核层copy到buffer,
    ///               io管理器负责把buffer拷贝到io管理器负责把内核层/应用层
    ///               优点:安全简单,因为不会操作应用层的内存,buffer是来自内核态的,
    ///               应用层无法改内核层的数据,所以是安全的.
    ///               缺点:效率低,因为一次通信有两次拷贝,一般传输数据量是不大的,buffer io是够用的,
    ///               但如果是类似3d渲染,数据量大,direct io更适合;
    /// 2.direct io  io管理器通过MPL把应用层/内核层的虚拟地址映射成物理地址,然后lock,
    ///              防止被这块内存切换出去(pageout),io管理器通过MPL把同一物理地址映射成内核层/应用层的物理地址.
    ///              效率是最高的,一次通信只有一次拷贝,但稍复杂
    /// 3.neither io 内核层直接访问应用层的数据,前提是应用层和内核层同处于一个进程上下文
    ///             (因为应用层内存地址是私有的,应用层进程切换之后内存就失效了)
    /// @warnnig     要对内核层传入的内存地址要做检查(ProbeForRead/ProbeForWrite),否则会有提取漏洞
    /// @note DO_DEVICE_INITIALIZING 是用来标识设备对象刚刚创建出来,正在初始化,还不能工作,
    ///       告诉应用层我现在暂时无法处理ipr请求,不要给我发,发了我也处理不了,
    ///       Q4:由谁来去除这个标志?A4:在DriverEntry中创建的设备对象是由io管理器把这个标志去掉,
    ///       但在其他地方创建的,比如过滤驱动,由驱动程序负责清除,即必须用程序员自己手动去除这个标志;
    pDeviceObject->Flags |= DO_BUFFERED_IO;
 
    /// 创建符号链接;
    /// @note        设备对象在应用层的体现,r0和r3通信的过程中,r3需要符号链接才能看到设备对象将其打开,
    ///              进而获得句柄,然后发送读写等各种请求;
    ///              eg2:磁盘中看到的盘符号(C:)、(D:)都是符号链接,对应内核的卷设备对象\Device\HarddiskVolume
    ///
    ntStatus = IoCreateSymbolicLink(&uLinkName, &uDeviceName);
    if (!NT_SUCCESS(ntStatus))
    {
        IoDeleteDevice(pDeviceObject);
        DbgPrint("IoCreateSymbolicLink failed:%x\n", ntStatus);
        return ntStatus;
    }
    /// 注册分发函数
    /// "#define IRP_MJ_MAXIMUM_FUNCTION         0x1b" 分发函数存放在pDriverObject->MajorFunction,
    /// 0x1b+1=28,所有的分发函数有28
    for (i = 0; i < IRP_MJ_MAXIMUM_FUNCTION + 1; i++)
    {
        pDriverObject->MajorFunction[i] = DispatchCommon;
    }
    /// 对感兴趣的Irp进行注册,还有两个重要的Irp(重命名和删除,Major),复制、粘贴、移动没有对应的Irp,因为这三个动作本质是读和写,
    /// 文件过滤驱动中为了检测这三个动作,只需要监控都和写的Irp就可以了;
    /// @warnig      分发函数名字可以随便起,但接口定义,参数类型必须一样
    pDriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreate; //创建
    pDriverObject->MajorFunction[IRP_MJ_READ] = DispatchRead; //读 是相对于应用层来说,数据流向:r0->R3
    pDriverObject->MajorFunction[IRP_MJ_WRITE] = DispatchWrite; //写 是相对于应用层来说,数据流向:r3->R0
    pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchIoctrl;
    pDriverObject->MajorFunction[IRP_MJ_CLEANUP] = DispatchClean;
    pDriverObject->MajorFunction[IRP_MJ_CLOSE] = DispatchClose;
 
    /// 注册卸载函数
    pDriverObject->DriverUnload = DriverUnload;
 
    DbgPrint("Driver load ok!\n");
 
    return STATUS_SUCCESS;
}

卸载函数函数DriverUnload

  • 把符号链接删掉
  • 把设备对象删掉
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
VOID DriverUnload(PDRIVER_OBJECT pDriverObject)
{
    /// 把符号链接删掉
    UNICODE_STRING uLinkName = { 0 };
    RtlInitUnicodeString(&uLinkName, LINK_NAME);
    IoDeleteSymbolicLink(&uLinkName);
    /// 把设备对象删掉
    /// @warning      设备对象可能有多个,遍历链表把所有的设备对象删掉
    /// @warning      DriverEntry创建的符号链接和设备对象如果不清理掉,会导致重新加载驱动会失败
    ///              (因为没删除,重新加载,但设备对象已经存在,必然会加载失败),除非重启系统
    IoDeleteDevice(pDriverObject->DeviceObject);
 
    DbgPrint("Driver unloaded\n");
}
 
/**
typedef struct _DEVICE_OBJECT *PDEVICE_OBJECT;typedef struct DECLSPEC_ALIGN(MEMORY_ALLOCATION_ALIGNMENT) _DEVICE_OBJECT {
    CSHORT Type;
    USHORT Size;
    LONG ReferenceCount;                  
    struct _DRIVER_OBJECT *DriverObject; ///< 指向创建它的内核驱动对象
    struct _DEVICE_OBJECT *NextDevice;   ///< 指向下一个设备对象
    struct _DEVICE_OBJECT *AttachedDevice;
    ...
*/

分发函数

DispatchCommon

  • 将Irp的头部的Status设置为成功
  • 传出的字节数设置为0
  • 把Irp终止掉
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
*  @brief       DispatchCommon 为所有分发函数注册通用的分发函数,即对Irp请求不做任何处理直接返回,eg3:好比对一个变量初始化为0;
*  @param[in]   pObject 设备对象,在DriveEntry调用IoCreateDevice所创造设备对象,指向 DRIVER_OBJECT 结构;
*  @param[in]   pIrp    IO REQUST PASKET缩写,Irp数据包封装应用层发下的数据和命令.
*  @return      ntStatus 不是返回给应用层的,而是返回给IO管理器的;
*  @warning     名字可以随便起,但接口定义,参数类型必须一样
*  @warning     在内核层会调用各种IO函数会有一个返回状态,这个返回状态就是存放在Irp的头部的Status这里,从而返回给应用层;而在内核层也有各种函数返回值,
*               比如DriverEntry中的"return STAUS_SUCCESS",就不是返回给应用层的,而是返回给IO管理器的;
*  @see
*  @author      cisco(微信公众号:坚毅猿)
*  @date        2022-01-22 22:09
*/
NTSTATUS DispatchCommon(PDEVICE_OBJECT pObject, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pObject);
 
    pIrp->IoStatus.Status = STATUS_SUCCESS; //将Irp的头部的Status设置为成功,用于返回给应用层
    pIrp->IoStatus.Information = 0; //传出的字节数设置为0
 
    IoCompleteRequest(pIrp, IO_NO_INCREMENT); //@warning     必须调用这个函数,如果不调用这个,会导致Irp不会被终止,相当于被挂起;
 
    return STATUS_SUCCESS;
}

DispatchCreate

  • 将Irp的头部的Status设置为成功
  • 传出的字节数设置为0
  • 把Irp终止掉
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
*  @brief       DispatchCreate 发现 DispatchCreate、DispatchCommon、DispatchClean和DispatchClose都是一样的,
*                              因为NT驱动是非常简单的单层驱动,只需要接受自己应用层创建和打开,直接返回成功即可,
*                              文件就允许被创建了,然后分配句柄表,在句柄表里面增加一项,但在过滤驱动中稍复杂,
*                              必须在DispatchCreate中监控别的进程对文件的创建和打开;
*  @param[in]   pObject 设备对象,在DriveEntry调用IoCreateDevice所创造设备对象,指向 DRIVER_OBJECT 结构;
*  @param[in]   pIrp    IO REQUST PASKET缩写,Irp数据包封装应用层发下的数据和命令.
*  @return      ntStatus 不是返回给应用层的,而是返回给IO管理器的;
*  @warning     名字可以随便起,但接口定义,参数类型必须一样
*  @warning     在内核层会调用各种IO函数会有一个返回状态,这个返回状态就是存放在Irp的头部的Status这里,从而返回给应用层;而在内核层也有各种函数返回值,
*               比如DriverEntry中的"return STAUS_SUCCESS",就不是返回给应用层的,而是返回给IO管理器的;
*  @warning     提权漏洞:一般驱动只允许自己的进程打开,不允许别的进程打开
*  @see
*  @author      cisco(微信公众号:坚毅猿)
*  @date        2022-02-15 22:09
*/
NTSTATUS DispatchCreate(PDEVICE_OBJECT pObject, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pObject);
 
    pIrp->IoStatus.Status = STATUS_SUCCESS;
    pIrp->IoStatus.Information = 0;
    /// 为了避免提权漏洞,可以在运行到这里之前拿到打开驱动的进程的pid,通过pid拿到这个进程的全路径,然后验证签名,
    /// 如果是自己的签名则允许打开,否则就做安全校验.
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
 
    return STATUS_SUCCESS;
}

DispatchRead

  • 第一步,拿到缓存的地址和长度
  • 第二步,读操作
  • 第三步,完成IRP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
*  @brief       DispatchRead 读 是相对于应用层来说,数据流向:r0->R3;
*  @param[in]   pObject 设备对象,在DriveEntry调用IoCreateDevice所创造设备对象,指向 DRIVER_OBJECT 结构;
*  @param[in]   pIrp    IO REQUST PASKET缩写,Irp数据包封装应用层发下的数据和命令.
*  @return      ntStatus 不是放回给应用层的,而是返回给IO管理器的;
*  @warning     名字可以随便起,但接口定义,参数类型必须一样;
*  @warning     在内核层会调用各种IO函数会有一个返回状态,这个返回状态就是存放在Irp的头部的Status这里,从而放回给应用层;而在内核层也有各种函数返回值,
*               比如DriverEntry中的"return STAUS_SUCCESS",就不是放回给应用层的,而是返回给IO管理器的;
*  @warning     内存溢出可能会造成提权漏洞
*  @see
*  @author      cisco(微信公众号:坚毅猿)
*  @date        2022-02-15 22:09
*/
NTSTATUS DispatchRead(PDEVICE_OBJECT pObject, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pObject);
 
    PVOID pReadBuffer = NULL; ///< buffer io缓存的首地址
    ULONG uReadLength = 0; ///< 应用层传下来要读取的数据长度
    PIO_STACK_LOCATION pStack = NULL; ///< Irp栈中当前驱动对应的栈,应用层发下来给内核层的内存对应Irp的栈;内核层把数据返回给应用层的内存对应Irp的头部usrbuffer
    ULONG uMin = 0; ///< 最小长度
    ULONG uHelloStr = 0; ///< 内核层想要上传的数据长度uHelloStr
 
    uHelloStr = (ULONG)(wcslen(L"hello world") + 1) * sizeof(WCHAR);  //分配宽字节字符串的的空间,"hello world"的长度 +1是因为'/0',即内核层想要上传的数据长度
 
    /// 第一步,拿到缓存的地址和长度
    /// 从头部拿缓存地址,因为创建设备对象之后,为设备对象指定的通信方式是buffer io,所以对应SystenBuffer
    pReadBuffer = pIrp->AssociatedIrp.SystemBuffer;
    /// 从栈上拿缓存长度
    pStack = IoGetCurrentIrpStackLocation(pIrp); //从Irp栈上拿到属于当前驱动对应的栈
    uReadLength = pStack->Parameters.Read.Length; //拿到应用层传下来要读取的数据长度,是存放在Irp栈上联合体Parameters中结构体Read的Length成员中
 
    /// 第二步:读操作
    uMin = uReadLength > uHelloStr ? uHelloStr : uReadLength; //传两者中的最小值
    RtlCopyMemory(pReadBuffer, L"hello world", uMin); //是将L"hello world"拷贝到pReadBuffer中去,"#define RtlCopyMemory(Destination,Source,Length) memcpy((Destination),(Source),(Length))"
    /// 为什么选择copy 应用层传下来要读取的数据长度uReadLength和内核层想要上传的数据长度uHelloStr 两者之间的最小值
    /// 情况1:应用层传下来要读取的数据长度uReadLength大于上传到应用层的数据长度uHelloStr,这时候如果拷贝uReadLength的长度,
    /// 对于L"hello world"来说,紧接着L"hello world"后面的内存的数据是不确定的,如果是数据,那就拷多了浪费时间和空间,
    /// 如果这部分内存是无效的(无效就是没有对应的物理内存,只有虚拟内存,没有物理内存,就会缺页错误,也就是虚拟内存无效),
    /// 那就可能会导致系统奔溃,所以这种情况下这时候应该拷贝uHelloStr的长度,也就是两者中最小值.
    /// 情况2:如果uReadLength小于uHelloStr,这时候如果拷贝uHelloStr的长度,这时候pReadBuffer没有足够容量容纳L"hello world",
    /// 会造成写溢出,此时如果蓝屏则是系统保护,不蓝屏则有可能已经造成提权漏洞,所以这种情况下这时候应该拷贝uHelloStr的长度,也就是两者中最小值.
    /// 综上所述, 应该拷贝两者中的最小值.
 
    /// 第三步,完成IRP
    pIrp->IoStatus.Status = STATUS_SUCCESS;
    pIrp->IoStatus.Information = uMin; //Irp头部的IoStatus.Information设置成实际上拷贝的数据长度;
    /// @warning     必须调用这个函数,如果不调用这个,会导致Irp不会被终止,相当于被挂起;
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
    return STATUS_SUCCESS;
}

DispatchWrite

  • 第一步,拿到缓存的地址和长度
  • 第二步,写等操作
  • 第三步,完成IRP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
NTSTATUS DispatchRead(PDEVICE_OBJECT pObject, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pObject);
 
    PVOID pReadBuffer = NULL; ///< buffer io缓存的首地址
    ULONG uReadLength = 0; ///< 应用层传下来要读取的数据长度
    PIO_STACK_LOCATION pStack = NULL; ///< Irp栈中当前驱动对应的栈,应用层发下来给内核层的内存对应Irp的栈;内核层把数据返回给应用层的内存对应Irp的头部usrbuffer
    ULONG uMin = 0; ///< 最小长度
    ULONG uHelloStr = 0; ///< 内核层想要上传的数据长度uHelloStr
 
    uHelloStr = (ULONG)(wcslen(L"hello world") + 1) * sizeof(WCHAR);  //计算"hello world"的长度 +1是因为'/0',即内核层想要上传的数据长度
 
    /// 第一步,拿到缓存的地址和长度
    /// 从头部拿缓存地址,因为创建设备对象之后,为设备对象指定的通信方式是buffer io,所以对应SystenBuffer
    pReadBuffer = pIrp->AssociatedIrp.SystemBuffer;
    /// 从栈上拿缓存长度
    pStack = IoGetCurrentIrpStackLocation(pIrp); ///< 从Irp栈上拿到属于当前驱动对应的栈
    uReadLength = pStack->Parameters.Read.Length; ///< 拿到应用层传下来要读取的数据长度,是存放在Irp栈上联合体Parameters中结构体Read的Length成员中
 
    /// 第二步:读操作
    uMin = uReadLength > uHelloStr ? uHelloStr : uReadLength; ///< 传两者中的最小值
    RtlCopyMemory(pReadBuffer, L"hello world", uMin); ///< 是将L"hello world"拷贝到pReadBuffer中去,"#define RtlCopyMemory(Destination,Source,Length) memcpy((Destination),(Source),(Length))"
    /// 为什么选择copy 应用层传下来要读取的数据长度uReadLength和内核层想要上传的数据长度uHelloStr 两者之间的最小值
    /// 情况1:应用层传下来要读取的数据长度uReadLength大于上传到应用层的数据长度uHelloStr,这时候如果拷贝uReadLength的长度,
    /// 对于L"hello world"来说,紧接着L"hello world"后面的内存的数据是不确定的,如果是数据,那就拷多了浪费时间和空间,
    /// 如果这部分内存是无效的(无效就是没有对应的物理内存,只有虚拟内存,没有物理内存,就会缺页错误,也就是虚拟内存无效),
    /// 那就可能会导致系统奔溃,所以这种情况下这时候应该拷贝uHelloStr的长度,也就是两者中最小值.
    /// 情况2:如果uReadLength小于uHelloStr,这时候如果拷贝uHelloStr的长度,这时候pReadBuffer没有足够容量容纳L"hello world",
    /// 会造成写溢出,此时如果蓝屏则是系统保护,不蓝屏则有可能已经造成提权漏洞,所以这种情况下这时候应该拷贝uHelloStr的长度,也就是两者中最小值.
    /// 综上所述, 应该拷贝两者中的最小值.
 
    /// 第三步,完成IRP
    pIrp->IoStatus.Status = STATUS_SUCCESS;
    pIrp->IoStatus.Information = uMin; ///< Irp头部的IoStatus.Information设置成实际上拷贝的数据长度;
    IoCompleteRequest(pIrp, IO_NO_INCREMENT); ///< @warning     必须调用这个函数,如果不调用这个,会导致Irp不会被终止,相当于被挂起;
 
    return STATUS_SUCCESS;
}

DispatchIoctrl

  • 拿到缓存的地址和长度
  • 拿到应用层传下来的控制码
  • 识别控制码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
*  @brief       DispatchIoctrl 是用来响应应用层的DeviceIoControl,相当与READ和WRITE之外的扩展,可以自定义一些应用层和内核层之间的其他命令,
*               把这些命令发送到内核层中去,让驱动根据命令做其他操作.
*  @note        r3和r0进行通信,除了读和写之外还可以让驱动做一些事情,比如弹窗的拦截和阻止某些操作,强删文件或者强杀进程、
*               检测一次隐藏的木马,检测隐藏的端口;
*  @note        内核层和应用层有一组通信编码,来让内核层知道应用层需要我做什么事情
*  @param[in]   pObject 设备对象,在DriveEntry调用IoCreateDevice所创造设备对象,指向 DRIVER_OBJECT 结构;
*  @param[in]   pIrp    IO REQUST PASKET缩写,Irp数据包封装应用层发下的数据和命令.
*
*  @return      ntStatus 成功返回0,否则为非0,如果例程成功,则它必须返回 STATUS_SUCCESS.
*                        否则,它必须返回在 ntstatus中定义的错误状态值之一;
*  @pre
*  @see         https://docs.microsoft.com/zh-cn/windows/win32/api/ioapiset/nf-ioapiset-deviceiocontrol
*  @author      cisco(微信公众号:坚毅猿)
*  @date        2022-01-22 22:09
*/
 
NTSTATUS DispatchIoctrl(PDEVICE_OBJECT pObject, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pObject);
 
    ULONG uIoctrlCode = 0; ///< 控制码
    PVOID pInputBuff = NULL; ///< 指向输入缓冲区的指针,该缓冲区包含执行操作所需的数据
    PVOID pOutputBuff = NULL; ///< 指向输出缓冲区的指针,该缓冲区将接收操作返回的数据
 
    ULONG uInputLength = 0; ///< 输入缓冲区的大小(以字节为单位).
    ULONG uOutputLength = 0; ///< 输出缓冲区的大小(以字节为单位).
    PIO_STACK_LOCATION pStack = NULL; ///< 当前驱动对应的栈
    /// pInputBuff和pOutputBuff共用一块内存, 从Irp头部拿缓存地址
    pInputBuff = pOutputBuff = pIrp->AssociatedIrp.SystemBuffer;
    /// 拿到属于当前驱动对应的栈
    pStack = IoGetCurrentIrpStackLocation(pIrp);
    ///< 拿到应用层传下来要读取的数据长度,是存放在联合体Parameters中结构体DeviceIoControl的InputBufferLength成员中
    uInputLength = pStack->Parameters.DeviceIoControl.InputBufferLength;
    uOutputLength = pStack->Parameters.DeviceIoControl.OutputBufferLength;
    ///< 拿到应用层传下来的控制码,是存放在联合体Parameters中结构体DeviceIoControl的IoControlCode成员中
    uIoctrlCode = pStack->Parameters.DeviceIoControl.IoControlCode;
    /// 识别控制码
    switch (uIoctrlCode)
    {
    case CTL_HELLO:
        DbgPrint("Hello iocontrol\n");
        break;
    case CTL_PRINT:
        DbgPrint("%ws\n", pInputBuff);
        //*(DWORD *)pOutputBuff =2;
        break;
    case CTL_BYE:
        DbgPrint("Goodbye iocontrol\n");
        break;
    default:
        DbgPrint("Unknown iocontrol\n");
    }
 
    pIrp->IoStatus.Status = STATUS_SUCCESS;
    pIrp->IoStatus.Information = 0;//sizeof(DWORD);
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
 
    return STATUS_SUCCESS;
}

DispatchClean

  • 将Irp的头部的Status设置为成功
  • 传出的字节数设置为0
  • 把Irp终止掉
1
2
3
4
5
6
7
8
9
10
11
12
13
/// @note        对应文件句柄应用计数为0,handle句柄是不跨进程的
/// @warnig      写关闭需要单独处理,写关闭:以写的方式打开文件,然后写入文件,最后关闭,这时候其他进程有机可乘,关闭之前需要重新扫描一下.
NTSTATUS DispatchClean(PDEVICE_OBJECT pObject, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pObject);
 
    pIrp->IoStatus.Status = STATUS_SUCCESS;
    pIrp->IoStatus.Information = 0;
 
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
 
    return STATUS_SUCCESS;
}

DispatchClose

  • 将Irp的头部的Status设置为成功
  • 传出的字节数设置为0
  • 把Irp终止掉
1
2
3
4
5
6
7
8
9
10
11
12
/// 对应fileobject引用计数为0,fileobject是跨进程的
NTSTATUS DispatchClose(PDEVICE_OBJECT pObject, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pObject);
 
    pIrp->IoStatus.Status = STATUS_SUCCESS;
    pIrp->IoStatus.Information = 0;
 
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
 
    return STATUS_SUCCESS;
}

应用层代码

main

  • 1.加载驱动LoadDriver
  • 2.测试驱动TestDriver
  • 3.卸载驱动UnloadDriver

    1.加载驱动LoadDriver

  • 1.得到完整的驱动路径
  • 2.打开服务控制管理器
  • 3.创建驱动所对应的服务
  • 4.开启服务,触发驱动的DriverEntry的执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
/**
*  @brief       LoadDriver 装载NT驱动程序;
*  @param[in]   lpszDriverName 驱动名字;
*  @param[in]   lpszDriverPath 驱动的路径;
*  @return      BOOL 成功返回0,否则为非0
*  @pre
*  @see         https://docs.microsoft.com/zh-cn/windows-hardware/drivers/wdf/driverentry-for-kmdf-drivers
*  @author      cisco(微信公众号:坚毅猿)
*  @date        2022-02-15 22:09
*/
BOOL LoadDriver(char* lpszDriverName, char* lpszDriverPath)
{
    //char szDriverImagePath[256] = "D:\\DriverTest\\ntmodelDrv.sys";
    char szDriverImagePath[256] = { 0 }; ///< 驱动的完整路径
    //得到完整的驱动路径
    GetFullPathName(lpszDriverPath, 256, szDriverImagePath, NULL);
 
    BOOL bRet = FALSE;
 
    SC_HANDLE hServiceMgr = NULL;//SCM管理器的句柄
    SC_HANDLE hServiceDDK = NULL;//NT驱动程序的服务句柄
 
    //打开服务控制管理器
    hServiceMgr = OpenSCManager(NULL, NULL, SC_MANAGER_ALL_ACCESS);
 
    if (hServiceMgr == NULL)
    {
        //OpenSCManager失败
        printf("OpenSCManager() Failed %d ! \n", GetLastError());
        bRet = FALSE;
        goto BeforeLeave;
    }
    else
    {
        ////OpenSCManager成功
        printf("OpenSCManager() ok ! \n");
    }
 
    //创建驱动所对应的服务
    hServiceDDK = CreateService(hServiceMgr,
        lpszDriverName, ///< 驱动程序的在注册表中的名字
        lpszDriverName, ///< 注册表驱动程序的 DisplayName 值
        SERVICE_ALL_ACCESS, ///< 加载驱动程序的访问权限
        SERVICE_KERNEL_DRIVER,///< 表示加载的服务是驱动程序
        SERVICE_DEMAND_START, ///< 注册表驱动程序的 Start 值,SERVICE_DEMAND_START表示需要的时候动态加载(3)[^20]
        SERVICE_ERROR_IGNORE, ///< 注册表驱动程序的 ErrorControl 值 ,SERVICE_ERROR_IGNORE表示系统没加载成功驱动,则忽略这个错误
        szDriverImagePath, ///< 注册表驱动程序的 ImagePath 值,测试的.exe和.sys文件放在同一个目录下,否则会报错,加载失败,errcode:2 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\ntmodelDrv\ImagePath
        NULL,  ///< GroupOrder HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\GroupOrderList
        NULL, ///< @warnig 如果驱动依赖了其他服务,要晚于所依赖的服务启动;
        NULL,
        NULL,
        NULL);
 
    DWORD dwRtn;
    //判断服务是否失败
    if (hServiceDDK == NULL)
    {
        dwRtn = GetLastError();
        if (dwRtn != ERROR_IO_PENDING && dwRtn != ERROR_SERVICE_EXISTS)
        {
            //由于其他原因创建服务失败
            printf("CrateService() Failed %d ! \n", dwRtn);
            bRet = FALSE;
            goto BeforeLeave;
        }
        else
        {
            //服务创建失败,是由于服务已经创立过
            printf("CrateService() Failed Service is ERROR_IO_PENDING or ERROR_SERVICE_EXISTS! \n");
        }
 
        // 驱动程序已经加载,只需要打开
        hServiceDDK = OpenService(hServiceMgr, lpszDriverName, SERVICE_ALL_ACCESS);
        if (hServiceDDK == NULL)
        {
            //如果打开服务也失败,则意味错误
            dwRtn = GetLastError();
            printf("OpenService() Failed %d ! \n", dwRtn);
            bRet = FALSE;
            goto BeforeLeave;
        }
        else
        {
            printf("OpenService() ok ! \n");
        }
    }
    else
    {
        printf("CrateService() ok ! \n");
    }
 
    ///< 开启此项服务,会触发驱动的DriverEntry的执行
    bRet = StartService(hServiceDDK, NULL, NULL);
    if (!bRet)
    {
        DWORD dwRtn = GetLastError();
        if (dwRtn != ERROR_IO_PENDING && dwRtn != ERROR_SERVICE_ALREADY_RUNNING)
        {
            printf("StartService() Failed %d ! \n", dwRtn);
            bRet = FALSE;
            goto BeforeLeave;
        }
        else
        {
            if (dwRtn == ERROR_IO_PENDING)
            {
                //设备被挂住
                printf("StartService() Failed ERROR_IO_PENDING ! \n");
                bRet = FALSE;
                goto BeforeLeave;
            }
            else
            {
                //服务已经开启
                printf("StartService() Failed ERROR_SERVICE_ALREADY_RUNNING ! \n");
                bRet = TRUE;
                goto BeforeLeave;
            }
        }
    }
    bRet = TRUE;
    //离开前关闭句柄
BeforeLeave:
    if (hServiceDDK)
    {
        CloseServiceHandle(hServiceDDK);
    }
    if (hServiceMgr)
    {
        CloseServiceHandle(hServiceMgr);
    }
    return bRet;
}

2.测试驱动TestDriver

  • 1.打开驱动或者句柄
  • 2.读/写
  • 3.发送控制码
  • 4.把句柄关掉
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
void TestDriver()
{
    //打开驱动或者句柄
    HANDLE hDevice = CreateFile("\\\\.\\NTmodeldrv", ///< 符号链接\\.\NTmodeldrv,use your own name
        GENERIC_WRITE | GENERIC_READ,
        0,
        NULL,
        OPEN_EXISTING,
        0,
        NULL);
    if (hDevice != INVALID_HANDLE_VALUE)
    {
        printf("Create Device ok ! \n");
    }
    else
    {
        printf("Create Device Failed %d ! \n", GetLastError());
        return;
    }
    CHAR bufRead[1024] = { 0 };
    WCHAR bufWrite[1024] = L"Hello, world";
 
    DWORD dwRead = 0;
    DWORD dwWrite = 0;
 
    /**
 *  @brief       ReadFile 调用ReadFile之后会进入内核层,封装成Irp数据包,发送到内核层创建的设备对象上,
 *                        交给ReadFile对应的分发函数把数据拿到,通过内核的io管理器把内核层buffer拷贝到bufRead上;
 *  @param[in]   hFile 设备对象的句柄;
 *  @param[in]   ipBuffer 存放从内核层读出来的数据的buffer
 *  @param[in]   nNumberOfBytesToRead 存放从内核读出来的数据buffer的长度;
 *  @param[in]   lpNumberOfBytesToRead 实际从内存层读出来的数据长度;
 *  @param[in]   lpOverlapped 用来控制异步通信
 *  @see         https://docs.microsoft.com/zh-cn/windows-hardware/drivers/wdf/driverentry-for-kmdf-drivers
 *  @author      cisco(微信公众号:坚毅猿)
 *  @date        2022-02-15 22:09
 */
    ReadFile(hDevice, bufRead, 1024, &dwRead, NULL);
    printf("Read done!:%ws\n", bufRead);
    printf("Please press any key to write\n");
    getch();
    /**
*  @brief       WriteFile 调用ReadFile之后会进入内核层,封装成Irp数据包,发送到内核层创建的设备对象上,
*                        交给ReadFile对应的分发函数把数据拿到,通过内核的io管理器把内核层buffer拷贝到bufRead上;
*  @param[in]   hFile 设备对象的句柄;
*  @param[in]   ipBuffer 存放从内核层读出来的数据的buffer
*  @param[in]   nNumberOfBytesToRead 存放从内核读出来的数据buffer的长度;
*  @param[in]   lpNumberOfBytesToRead 实际从内存层读出来的数据长度;
*  @param[in]   lpOverlapped 用来控制异步通信
*  @see         https://docs.microsoft.com/zh-cn/windows-hardware/drivers/wdf/driverentry-for-kmdf-drivers
*  @author      cisco(微信公众号:坚毅猿)
*  @date        2022-02-15 22:09
*/
    WriteFile(hDevice, bufWrite, (wcslen(bufWrite) + 1) * sizeof(WCHAR), &dwWrite, NULL);
 
    printf("Write done!\n");
 
    printf("Please press any key to deviceiocontrol\n");
    getch();
    CHAR bufInput[1024] = "Hello, world";
    CHAR bufOutput[1024] = { 0 };
    DWORD dwRet = 0;
 
    WCHAR bufFileInput[1024] = L"c:\\docs\\hi.txt";
 
    printf("Please press any key to send PRINT\n");
    getch();
/**
* @warning 这些数据都是封装在Irp中
  BOOL DeviceIoControl(
  [in]                HANDLE       hDevice, ///< 要在其上执行操作的设备的句柄。设备通常是卷、目录、文件或流。
  [in]                DWORD        dwIoControlCode, ///< 操作的控制代码,可以自己定义
  [in, optional]      LPVOID       lpInBuffer, ///< 指向输入缓冲区的指针,该缓冲区包含执行操作所需的数据
  [in]                DWORD        nInBufferSize, ///< 输入缓冲区的大小(以字节为单位)。
  [out, optional]     LPVOID       lpOutBuffer, ///< 指向输出缓冲区的指针,该缓冲区将接收操作返回的数据
  [in]                DWORD        nOutBufferSize, ///< 输出缓冲区的大小(以字节为单位)。
  [out, optional]     LPDWORD      lpBytesReturned, ///< 这次io实际传输的长度
  [in, out, optional] LPOVERLAPPED lpOverlapped ///< 用作异步通信的
);
*/
    DeviceIoControl(hDevice,
        CTL_PRINT,
        bufFileInput,
        sizeof(bufFileInput),
        bufOutput,
        sizeof(bufOutput),
        &dwRet,
        NULL);
    printf("Please press any key to send HELLO\n");
    getch();
    DeviceIoControl(hDevice,
        CTL_HELLO,
        NULL,
        0,
        NULL,
        0,
        &dwRet,
        NULL);
    printf("Please press any key to send BYE\n");
    getch();
    DeviceIoControl(hDevice,
        CTL_BYE,
        NULL,
        0,
        NULL,
        0,
        &dwRet,
        NULL);
    printf("DeviceIoControl done!\n");
    //把句柄关掉
    CloseHandle(hDevice);
}

3.卸载驱动UnloadDriver

  • 打开SCM管理器
  • 打开驱动所对应的服务
  • 停止驱动程序,如果停止失败,只有重新启动才能,再动态加载。
  • 动态卸载驱动程序
  • 离开前关闭打开的句柄
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
//卸载驱动程序
BOOL UnloadDriver(char* szSvrName)
{
    BOOL bRet = FALSE;
    SC_HANDLE hServiceMgr = NULL;//SCM管理器的句柄
    SC_HANDLE hServiceDDK = NULL;//NT驱动程序的服务句柄
    SERVICE_STATUS SvrSta;
    //打开SCM管理器
    hServiceMgr = OpenSCManager(NULL, NULL, SC_MANAGER_ALL_ACCESS);
    if (hServiceMgr == NULL)
    {
        //带开SCM管理器失败
        printf("OpenSCManager() Failed %d ! \n", GetLastError());
        bRet = FALSE;
        goto BeforeLeave;
    }
    else
    {
        //带开SCM管理器失败成功
        printf("OpenSCManager() ok ! \n");
    }
    //打开驱动所对应的服务
    hServiceDDK = OpenService(hServiceMgr, szSvrName, SERVICE_ALL_ACCESS);
 
    if (hServiceDDK == NULL)
    {
        //打开驱动所对应的服务失败
        printf("OpenService() Failed %d ! \n", GetLastError());
        bRet = FALSE;
        goto BeforeLeave;
    }
    else
    {
        printf("OpenService() ok ! \n");
    }
    //停止驱动程序,如果停止失败,只有重新启动才能,再动态加载。
    if (!ControlService(hServiceDDK, SERVICE_CONTROL_STOP, &SvrSta))
    {
        printf("ControlService() Failed %d !\n", GetLastError());
    }
    else
    {
        //打开驱动所对应的失败
        printf("ControlService() ok !\n");
    }
 
    //动态卸载驱动程序。
    if (!DeleteService(hServiceDDK))
    {
        //卸载失败
        printf("DeleteSrevice() Failed %d !\n", GetLastError());
    }
    else
    {
        //卸载成功
        printf("DelServer:deleteSrevice() ok !\n");
    }
 
    bRet = TRUE;
BeforeLeave:
    //离开前关闭打开的句柄
    if (hServiceDDK)
    {
        CloseServiceHandle(hServiceDDK);
    }
    if (hServiceMgr)
    {
        CloseServiceHandle(hServiceMgr);
    }
    return bRet;
}

编译

  • .sys
    • x86平台编译x86的,x64必须编译成x64的
  • .exe
    • 既可以编译成x86的,也可以编译成x64,x86的应用程序是可以加载x64的驱动的
  • .sys可以作为资源打包到.exe中去,在启动.exe的时候把.sys释放出来,把.sys加载起来,然后把.sys删掉,这样隐蔽性更高。
    • 怎么打包?使用pe工具打包。

      调试

  • 调试必须要两台机器,一台开发机(物理机),一台测试机器(虚拟机或者真实的物理机)。因为同一台机器本身就跑在内核上,此时内核如果被调试,整个系统就会卡住了,没办法往下执行调试操作了。
    • 但只要1台电脑也不是不可以调试。windbg默认是关闭实时内核模式调试,即windbg调试当前windbg所运行系统内核。可以开启,开启之后就可以自己调试自己了。
  • windbg x64版本既可以调试x64、也可以调试x86;但windbg x86版本只能调试x86
  • 建议
    • x64需要关闭数字签名,x86测试方便些。
    • 推荐使用windbg调试,visual studio 太过笨重,调试体验没有windbg好。

      调试内核准备工作

      windbg连接目标机器并加载驱动

  • 虚拟机建立串口
  • 物理机windbg快捷方式添加串口参数
  • 运行应用层程序加载驱动

    设置符号

  • 物理机是win10系统则需要设置符号代理的环境变量
    • 在环境变量中设置符号下载代理
      变量名为: _NT_SYMBOL_PROXY
      变量值为: 127.0.0.1:8080
  • 然后在虚拟机外面的 windbg 里设置驱动符号路径
    • 使该路径指向内核驱动编译后的符号位置,并加载相应的符号文件。也就是说,编译后的驱动拷贝在虚拟机里运行,但调试驱动的符号是在虚拟机外面设置。
    • srv*``E:Share\Source\repos\symbols``*http://msdl.microsoft.com/download/symbols;E:\Share\Source\repos\system_secure_\1\testfile
    • (srv*``提前创建好的本地文件夹``*加载微软符号;Nt驱动的符号所在的文件夹(.pdb和.sys文件的时间戳要保持一致))

      加载不上符号的原因

  • 时间戳不一致
  • 驱动未加载
  • 都不行,可以尝试强制加载.reload -i
    • Press ctrl-c (cdb, kd, ntsd) or ctrl-break (windbg)to abort symbol loads that take too long.
    • Run !sym noisy before .reload to track down problems loading symbols.
      `

      调试命令

      windbg调试命令的分类

  • Commons
  • Meta-Commands(前面带.的,比如.process /p EPROCESS
  • Debugger Extension CommandsSSTATUS_GUARD_PAGE_VIOLATION(前面带的,)!process 0 0

    Windbg调试窗口

  • Registers
  • Calls
  • Memory
  • Locals

    调试应用层的进程

  • .attach pid(调试应用层的进程)
  • ~*(显示所有线程)
  • ~.(显示当前活动线程)
  • ~#(显示引起异常的线程)
  • ~2(显示第二个线程)
  • ~2 s(选择第二个线程)
  • ~* kb(显示所有线程的栈)
  • !runaway(显示线程运行的时间)
  • .detach(释放当前调试的进程)

    下断点

    断点的分类
  • int 3 : 临时在代码里插入cc指令,一旦执行到这里,就会抛出异常EXCEPTION_BREAKPOINT(0x80000003)
  • 内存断点:将欲下断点地址所在的内存页增加一个名为PAGE_NOACCESS(写入时设为PAGE_EXECUTE_READ)的属性,引发异常STATUS_GUARD_PAGE_VIOLATION(0x80000001)时在判断是否为断点位置。以一页为单位设置,性能慢,但可以下很多个。
  • 硬件断点:DRx调试寄存器总共有8个,从DRx0到DRx7:
    • DR0~DR3:调试地址寄存器,保存需要监控的地址,如设置硬件断点;
    • DR4~DR5:保留
    • DR6~DR7:用来控制断点的大小和触发断点的时机(比如说大小是一个byte,触发时机为写入时),最多只能设置4个断点。
    • 由于CPU的直接支持,硬件断点的效率非常高,设置了硬件断点,在不触发的情况下,不会有肉眼可见的效率影响,毕竟只是写了个寄存器而已。CPU执行命令的时候,发现与dr寄存器符合的时候会触发DTATUS_SINGLE_STEP(0x80000004)单步异常。
  • ollydbg在菜单把int3断点/内存断点/硬件断点并列在一起供选择。
    下断点的命令
  • lm m ntmodel* (查看模块符号是否加载成功,支持正则匹配)
  • du 地址 (以unicode编码显示内存地址所存放的内容)
  • bc *清除所有断点,支持正则匹配)
  • bl (列出所有断点)
  • bd n(禁用断点)
  • be n (启用断点)
  • bp address
    • 地址断点,绑定某个特定的地址,地址所在模块卸载后,断点移除消失。
  • bp model!func
    • 要求驱动模块必须已经加载到内存中了,因为bp最终是找断点所在的地址,不在内存中,地址无效,bp失败后会转换成bu断点(延迟断点),即在内存中没有这个模块,先把模块名和函数名记下来,等到加载驱动模块的时候就会触发,从而把断点下下来)
    • 当代码修改之后,函数地址改变,该断点仍然保持在相同位置,不一定继续有效。
    • windbg会把bp断点保存在工作空间中。(就说重新启动windbg之后,之前设置的bp断点都会消失)
  • bu model!func
    • 符号断点(延迟断点),绑定符号,长期存在,可以设置在某个未加载的函数上,例如bu ntmodeldrv!DriverEntry 0x10
    • 在代码修改后,该断点可以随着函数地址改变而自动更新到最新位置。
    • bu断点会保存在windbg的工作空间中,下次启动windbg的时候该断点会自动设置上去。
  • bm model!fu*c
    • 设置符合模式的多个断点,默认是bu类型,可以加/d设置成bp类型,例如bm ntmodeldrv!dispatch*,支持正则匹配
  • bp /p eprocess address/func (表示只有指定的进程的内核对象eprocess在执行到address这个位置的时候才会断下来,其他进程则无做反应)
  • bp /t ethread address/func(表示只有指定的线程的内核对象ethread在执行到address这个位置的时候才会断下来,其他进程则无做反应)
  • ba access size address
    • 内存访问下断点,该断点在指定内存被访问时触发
    • access是访问方式
    • sizes是监控访问的位置大小,以字节为单位。值为1,2或者4,在64位机器上还可以为8。
  • ba w4/r4/e4/i4 address (内存断点、I/O)
    • w4 ,ba w4 address表示当往address中write4个字节的数据时,就会中断下来。进程的内核对象是Eprocees结构,所有在调试进程时候,其实通过Eprocess结构体的成员DebugProt进行通信的(往DebugProt写数据),游戏反调试就是通过建立一个新线程,定时对DebugProt进行清零。那如果ba w4 address监控DebugProt,)从而知道进程的pid,把进程杀掉,应该就可以实现反反外调试
    • r4 ,ba r4 address表示当对address中Read4个字节的数据时,就会中断下来。
    • e4 ,ba r4 address表示当执行address中4个字节的数据时,就会中断下来。
    • i4,ba r4 address表示当往address(某个寄存器或者io端口)进行操作时,就会中断下来。
  • ba /p /t
    • 对内存断点通过/p或者/t来指定由那个进程或者那个线程执行到某个位置才下断点
  • 条件断点(bp Address "条件脚本"

    查看内存的命令

    dt命令(显示结构体)
  • 作用:查看结构体里面的每个成员
  • 前置条件:把微软符号加载起来,否则导致会查看失败
  • dt [nt!]_EPROCESS [-r]
    • 中括号表示里面的参数可以省略
    • [nt!]表示模块名,[-r]表示递归打印(因为结构体里面还会有结构体)
  • dt nt!_P*支持正则表达式
  • dt [nt!]_PEB [members]adderss(将address的内存看作是PEB结构体的某一个成员去解析)
    显示值
  • eg:db address L20(十六进制)(显示内存address处32Byte的长度的值和ASSCII字符)
  • eg:!db physical_addr L20(带!显示的是物理内存,不带!显示的是虚拟内存)
  • 注意读数的时候,注意x86上整数的存储是低位优先
    • 比如windbg用db显示的是0x78 56 34 12 实际上表示是0x12345678
    • 比如windbg用dW显示的是0x5678 1234 实际上表示是0x12345678
      显示数(不清楚内存地址所存放的值的类型的时候使用)
  • db(以1个字节为单位显示值和ASSCII字符)
  • dw(以2个字节为单位显示值(没有同时显示ASSCII字符了))
  • dd(以4个字节为单位显示值)
  • dp(以指针的长度为单位来显示值,32等同于dd,64等同于dq)
  • dD(以8个字节为单位显示double实数的值)
  • df(以4个字节为单位显示float实数的值)
    显示字符(明确内存地址所存放的值的是字符类型时使用)
    • da (显示asscii值)
    • du(显示unicode值)
    • ds(显示ANSI_STRING值)
    • dS(显示UNICODE_STRING的值)
      混合显示
    • dW(显示2字节和asscii的值)
    • dc(显示4字节和asscii的值和dw对比)
      显示指针的值
      ddp/ddu/dda/dds(这里ddu\= dd+dd
    • ddp表示要显示的指针的长度
      • d:4字节;q:8字节;p:32位4字节,64位8字节
  • ddp表示指针的值的显示形式
    • p:DWORD或者QWORD
    • a:ASSCII
    • u:UNICODE
    • s:以系统符号来显示

      修改内存的命令(修改用的比较少,比调试窗口修改快一些)

  • eb/ew/ed/ef/eD/eq/ep
    • eg:ed nt!Kd_SXS_MASK 0ed nt!Kd_FUSION_MASK 0禁用SXS.dll无用调试输出
    • eg:eb address value 如果没有签名校验的话,修改机器码,改变程序的逻辑,等绕过登陆验证等操作
  • ea/eu(非null结尾的字符)
    • eg:ea address "string"
  • eza/ezu(null结尾的字符)

    查看栈的命令

  • kv
    • 提示FPO信息,ChildEBP(当前ebp寄存器),retaddr(返回地址),前三个参数
    • FFPO是一种优化,它压缩或者省略了在栈上为该函数创建的框架指针的过程。这个选项加速了函数调用,应为不需要建立和移除框架指针(ESP,EBP)了。同时还解放出了一个寄存器,用来存储使用频率较高的变量。只在intel CPUd 架构上才有这种优化。之前的任何一种调用约定[25]都保存了前一函数中栈的信息(压栈ebp,然后让ebp\=esp,再移动esp来保持局部变量)。一个FPO的函数可能会保存前一函数的栈执政(ESP,EBP),但是并不为当前的函数调用设立EBP。相反,他使用的EBP来存储一些其他的变量。debugger会计算栈指针。但是debugger必须得到一个使用FPO的提醒,该提醒是基于FPO类型的信息来完成的
  • kb
    • ChildEBP(当前ebp寄存器),retaddr(返回地址),前三个参数
  • kp
    • 全参数
    • ebp,返回地址,突出显示函数的参数(前提时必须加载了符号)

      进程线程命令(只限于内核层)

  • !process 0 0 显示所有进程的概要信息
    • _EPROCESS结构地址,会话id,pid,Peb,父进程,yml基地址,句柄表,句柄树,进程名字
  • !process _EPROCESS结构地址 7 显示特定进程的详细信息
    • 进程占用时间,内核和应用层占用的时间,线程ethread结构,tid,tTb,线程执行的栈
  • !process 0 0 显示所有进程的详细信息
    • 信息太多屏幕显示不下,重定向到文件里面去比较好.logopen d:\temp\dump.txt 可以把logclose重新打印到屏幕上
    • 这个命令主要用于系统死机,发生死锁,cpu占用100\%的情况下,进行详细分析
  • .process /p EPROCESS进入该进程上下文
    1
    - 因为应用态的内存地址是私有的,要想看应用态的内存地址的内容,需要切换到应用层对应的进程上下文
  • .thread ETHREAD 进入该线程的上下文
  • !thread ETHREAD 查看线程的结构

    实操1:用windbg查看ssdt表/shadowSSDT表

  • 必须加载好微软符号
  • 只能在x86上看到,因为这两张表都是未导出的,不太确定在x64上能否看得到,但在VT中可以有办法在x64系统上找到这两张表。

    用windbg查看ssdt表

    x nt!kes*des*table*显示在nt模块中以kes开头包含destable的变量
    dd address用dd命令查看地址上的数据,从第一行得到ssdt表的基地址元素个数
    dds 基地址 L元素个数用dds命令,以系统符号来显示ssdt表的所有函数
    uf 函数地址用uf反汇编命令,将函数的机器码转换成汇编代码

    用windbg查看shadowSSDT表

  • shadowSSDT表只在某些进程运行的情况下加载,比如与要运行图形相关的进程
    !process 0 0找到绘图器的EPROCESS结构
    .process /p 绘图器的EPROCESS结构地址进入都绘图器进程的上下文
    x nt!kes*des*table*显示在nt模块中以kes开头包含des和table的变量
    dd address用dd命令查看地址上的数据,从第二行得到shadowSSDT表的基地址和元素个数,可以发现shadowSSDT表的基地址和元素个数有两份,在ssdt表的第二行也是shadowSSDT表的基地址元素个数,所以也可以直接通过ssdt表来定位shadowSSDT表,从而不需要开启绘图进程。
    dds 基地址 L元素个数用dds命令,以系统符号来显示shadowSSDT表的所有函数
    uf 函数地址用uf反汇编命令,将函数的机器码转换成汇编代码

    其他调试方式

  • Procmom诊断工具
    • 可以监控进程的行为(进程创建打开了那些文件,创建了那些线程,有哪些网络访问,访问那些注册表等等)
    • 可以自己写一个类似Procmom诊断工具
  • 屏蔽测试(需要手上有源码)
    • 如果栈被破坏了(栈溢出,堆溢出),找不到问题,可以边注释掉一部分边调试排查,慢慢缩减问题范围

      反调试或者反反调试反逆向

      反调试

  • DebugProt
    • 对于未导出的函数,无法通过符号来找到DebugProt,只能通过硬编码来找到DebugProt
  • 在内核层
    • KdDisableDebugger 在内核里调用KdDisableDebugger来禁用内核调试
    • KdEnable Debugger 启用内核调试
  • 在应用层
    • 开两个线程来分别调用IsDebugerPresentCheckRemoteDebuggerPresent来检测自己是否被正在被调试,如果发现被调试,自己就退出,其他人就无法继续调试我了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// 没有加入反调试代码的时候MFC程序是可以被windbg调试的,加入反调试代码之后,windbg调试MFC程序的是,MFC就会一闪而过退出
/// 反调试线程函数,放在MFC函数的执行入口
UINT AntiDebug(PVOIDparam)
{
    while(g_bWillExit == FALSE)
    {
        HANDLE hRrocess = GetCurrentProcess();
        BOOL bDebuggerPresent=FALSE;
        CheckRemoteDebuggerPresent(hProces,&bDebuggerPresent);
        if(IsDebuggerPresent()||bDebuggerPresent)
        {
            ::ExitProce(0); //把当前进程结束掉
        }
        Sleep(5000); //每隔5s检测程序是否被调试
    }
    return 0;
}
 
AfxBeginThread((AFX_THREADPROC)AntiDebug, NULL); //启动反调试线程
  • Hook-Anti-debug
    • hook系统中一些与调试相关的函数,以可以防止各种调试器调试。
    • NTOpenThread() hook这个函数,可以防止调试器在程序内部创建线程
    • NTOpenProcess() hook这个函数,可以防止OD(OllyDbg)等调试工具在进程列表中看到我们
    • KiAttachProcess() hook这个函数,可以防止被附加上
    • NtReadVirtualMemory() hook这个函数,可以防止被读内存
    • NtWriteVirtualMemory() hook这个函数,可以防止被内存被写
  • hook这两个函数用来防止双机调试
    1
    2
    - `KdReceivePacket()` KDCOM.dll中Com串口接收数据函数
    - `KdSendPack()` KDCOM.dll中Com串口发送数据函数

    反反调试

  • ba w4 debugport 对DebugProt内存地址下断点
    • 这样一旦有程序代码在修改DebugPort,就会被断下,从而找到对应清零DebugPort的反调试代码,然后对这部分代码进行patch(用机器码0x90(nop)或 者0xC3(ret)取代)从而让它失去作用,当然有的程序会对代码进行校验,一旦发现代码被篡改,就会采取保护措施,比如抛出异常或者退出程序。
  • 针对调用系统函数如KaDisabeDebugger()来检测调试器存在,从而禁止被调试的方法,可以在对应的这些函数的地址下断点,然后对相关的代码进行patch,然后使该函数判断失效。
    • 比如bp KdDisableDebugger, eb xxx
  • 针对通过HOOK系统函数来防止进程被调试的方法,可以直接将这些系统函数的钩子直接恢复,可以通过检测和恢复这些函数钩子。

    反逆向

    花指令
    花指令是程序中的无用指令或者垃圾指令,故意干扰各种反汇编静态分析工具,但是程序不受任何影响,缺少了它也能正常运行。加花指令后,IDA Pro等分析工具对程序静态反汇编时,往往会出现错误或者遭到破环,加大逆向静态分析的难度,从而隐藏自身的程序结构和算法从而较好的保护自己。
1
2
3
4
5
6
7
8
//花指令1
//代码没有任何作用,但会混淆调试者
push edx
pop edx
inc ecx
dec ecx
add esp,1
sub esp,1
1
2
3
4
//花指令2
jmp Labe1
db opcode     //在jmp后面加上一个字节的机器码,并不完整(完整的汇编指令是一个机器码+操作数),但不影响程序的执行(jmp会跳过这条残缺的汇编指令)。在IDE工具反汇编的时候,看到机器码(以为紧接着的就是操作数,接着后面的反汇编都错位了)
Label1:

IDA采用的反汇编算法是递归下降算法,如果没有指令调整到这个位置,就拒绝对这条指令进行反汇编。

1
2
3
4
5
//花指令3
jz Label      //花指令对代码依旧没有影响,为0的时候会跳过花指令。
jnz Label     //0才跳转到 Label,为0则执行下面的花指令,IDE此时就认为这条花指令需要反汇编。
db opcode
Label:
OLLVM代码混淆工具
  • 控制流平展模式
    • 增加很多条件分支
  • 指令替换模式
    • 把简单的运算变成复杂的运算
  • 控制流伪造模式
    • 不仅会打乱流程,还会添加花指令
      加壳
  • UPX
  • VPM

    实操2:蓝屏分析

    DUMP 文件

  • windows或者linux系统崩溃的时候都会产生一个文件,windows产生的是DUMP文件,保存系统在蓝屏时刻的内存信息,寄存器信息,栈的信息,所以可以借助DUMP文件分析蓝屏
  • 如果蓝屏是因为栈溢出,栈的信息已经被破坏了,DUMP文件分析是不准确的了,使用windbg高级调试帮助,调试栈溢出

    如何产生DUMP文件

  • 设置好保存DUMP文件的路径c:\Windows\MEMORY.DMP,启动和故障恢复-系统失败-核心内存转储(几十MB)
  • 制造一个蓝屏
  • 如果驱动设置为随系统启动,开机就会蓝屏,死循环无法进入系统获得DUMP文件,此时可以进入安全模式来获取DUMP文件
  • 如果发生蓝屏之前,机器已经连接上windbg,蓝屏的信息会直接被windbg捕获,免去拷贝和加载DUMP文件的步骤。

    影响驱动启动的时机两个因素

  • Startype
1
2
3
4
5
6
7
8
9
// Start Type
// StartType值有01234,数值越小越早启动
#define SERVICE_BOOT_START             0x00000000 //是内核刚刚初始化之后,此时加载的都是与系统核心有关的驱动程序,比如磁盘驱动;
#define SERVICE_SYSTEM_START           0x00000001 //稍晚一些;
#define SERVICE_AUTO_START             0x00000002 //是在登陆界面出现的时候开始,如果登陆较快可能驱动还没加载就登陆进去了;
#define SERVICE_DEMAND_START           0x00000003 //是需要的时候动态加载;
#define SERVICE_DISABLED               0x00000004 //是不加载,要假装之前必须把Start的值改小于4的值;
/// @warnig 如果驱动依赖了其他服务,要晚于所依赖的服务启动;
/// 期望:驱动越早启动越好,如果驱动比病毒木马启动晚的话,可能来不及杀掉病毒和木马就被反杀了。

  • GroupOrder
    • 当两个驱动的StartType相同,GroupOrder越靠启动

      分析DUMP文件

  • Windbg加载DUMP文件
  • 加载微软符号和驱动模块符号
  • !analyze -v找到出问题模块的函数调用栈,定位到出问题的函数
  • .open -a 出问题的函数调出函数对应源代码

    • 如果调不出来源代码,设置一下windbgsource path

    蓝屏常见原因

    • 关闭无效的HANDLE
    • ObReferenceObjectByHandle 未指定类型
    • 在没有ObReferenceObject(pFileObject)的情况下ObDereferenceObject(pFileObject)
    • 引用了NULL指针
    • 内存访问越界
    • 高中断级别访问了缺页内存(缺页内存只能在PASSIVE_LEVEL使用),引发DRIVER_IRQL_NOT_LESS_OR_EQUAL

      实操3:R3-r0联调(综合使用上述的命令)

      R0

  • ./main.exe运行main.exe加载驱动(先加载驱动是避免dp断点转换成du断点,导致下断点没有高亮,造成混淆)
  • 连接windbg
  • 加载微软符号和驱动模块符号
  • 在驱动中下断点

    R3

  • 切换应用程序地址空间
    !process 0 0找到main.exe的EPROCESS结构
    .process /p 绘图器的EPROCESS结构地址进入都main.exe进程的上下文
  • .reload /f /user重新加载user程序的PDB文件
    • 需要在windbg里设置好R3的微软符号和应用程序的符号(不要在对话框点reload,设置好后手动输入命令执行)
  • 在应用程序中下断点
  • kv 查看函数调用栈(此时的栈只包含R3的函数调用,因为现在还处于应用层空间)
  • main.exe中按回车,进入内核
    kv 查看函数调用栈(此时的栈是完整的了,包含R3和R0,因为现在进入了内核空间)

[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)

收藏
免费 14
支持
分享
最新回复 (7)
雪    币: 0
活跃值: (30)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
感谢分享
2023-6-5 23:48
0
雪    币: 3004
活跃值: (30861)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
感谢分享
2023-6-6 09:14
1
雪    币: 429
活跃值: (398)
能力值: ( LV6,RANK:81 )
在线值:
发帖
回帖
粉丝
4
这么好的文章被冷落了,有点可惜
2023-6-6 10:47
0
雪    币: 10316
活跃值: (4525)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
如果想学驱动开发,可以试试这个项目,可以结合文章学习,有对应的书籍, 2023-04-25 发布的:
https://github.com/zodiacon/windowskernelprogrammingbook2e
2023-6-6 12:03
0
雪    币: 1285
活跃值: (5144)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
6
感谢分享
2023-6-6 17:44
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
学习了
2023-7-12 09:11
0
雪    币: 551
活跃值: (720)
能力值: ( LV3,RANK:24 )
在线值:
发帖
回帖
粉丝
8
好文!
2023-9-26 17:30
0
游客
登录 | 注册 方可回帖
返回
//