64位内核开发第二讲.内核编程注意事项,以及UNICODE_STRING

一丶驱动是如何运行的

1.服务注册驱动

我们编写驱动.一定要知道驱动是如何运行的 首先在我们安装一个驱动的时候,会创建一个服务.(注册表)

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\SrvNmae 最后一个是是你驱动的名字. 如下:

里面有一个StartType 它是按照GroupOrder顺序来启动的

StartType值越小.代表越早启动.总共是0 - 4

数值

启动时机

说明

0

系统核心装载器装载

系统在启动的时候优先加载.

1

IO子系统装载

稍微晚一些加载.加载完核心驱动才加载我们的

2

自动启动

在登录界面出现的时候开始加载.电脑好驱动还没加载也会登录到桌面系统中.

3

手工启动

如果设置为3.重启之后不会再加载,你需要自己重新加载一次

4

禁止启动

代表我们驱动不会加载.比如设置start值小于4才可以

其中设置Start的值是在我们3环加载驱动的时候设置的 调用 CreateService安装驱动的时候,传递的参数值.其中有个参数就是Start.如下:

SC_HANDLE CreateServiceA(
  SC_HANDLE hSCManager,
  LPCSTR    lpServiceName,
  LPCSTR    lpDisplayName,
  DWORD     dwDesiredAccess,
  DWORD     dwServiceType,
  DWORD     dwStartType, 这个值设置
  DWORD     dwErrorControl,
  LPCSTR    lpBinaryPathName,
  LPCSTR    lpLoadOrderGroup,  GroupOrder注意这个值
  LPDWORD   lpdwTagId,
  LPCSTR    lpDependencies,
  LPCSTR    lpServiceStartName,
  LPCSTR    lpPassword
);

关于如何使用代码加载我们的驱动.前边也有说过.请参考前面资料.

https://www.cnblogs.com/iBinary/p/8280912.html

GroupOrder值 这个值是在注册表 HEEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\GroupOrderList

这个值是越靠前的驱动.启动越早. 如下:

怎么结合的. 如果Start 值位0那么就看 GroupOrder 那个在上边就加载谁. Start值为0. 另一个为1 则值为0的先启动.

2.对象管理器生成驱动对象

上面说了我们的服务会放在注册表中. 但是我们的写的驱动是怎么加载或执行的那.

对象管理器生成驱动对象 (DriverObject)并且把它传递给DriverEntry(). 执行 DriverEntry()入口函数.

3.创建控制设备对象 4.创见控制设备符号链接(Ring可以操作的) 5.绑定过滤驱动 如果我们有过滤驱动.则会创建过滤设备对象.并且进行绑定. 6.注册分发函数 7.完成初始化动作. 8.应用程序通过符号链接打开设备对象.并且通过IRP发送读写请求.

二丶Ring3跟Ring0通讯的几种方式

1.IOCTRL_CODE 控制代码的几种IO

1.METHOD_BUFFERED 通讯方式

METHOD_BUFFERED 在我们内核中 Ring3可以传递控制码给内核层.其中需要指明我们的读写方式 如下:

#define IOCTRL_BASE 0x800
#define MYIOCTRL_CODE(i)\
    CTL_CODE(FILE_DEVICE_UNKNOWN,IOCTRL_BASE+i,METHOD_BUFFERED,FILE_ANY_ACCESS)
4个参数:
参数1: 驱动的类型
参数2: 驱动的控制码.
参数3: 以哪种缓冲方式进行通讯
参数4: 权限

其中我们这里说的就是参数3.指定什么方式进行通讯.

ME_THOD_BUFFERED 则我们的数据都会会封装在IRP头部的SystemBuffer中.

pIrp->AssociatedIrp.SystemBuffer;

2.METHOD_IN_DIRECT 只读缓冲 METHOD_OUT_DIRECT 只写缓冲

如果我们的CTRL_CODE指定的是这两种的其中一种.看如下解释

METHOD_IN_DIRECT 只读缓冲的方式.则我们的缓冲区还是会封装到IRP头部的SystemBuffer缓冲区.

IN pIrp->AssociatedIrp.SystemBuffer;

如上.我们 ring3 输入的数据都会放在这个SystemBuffer中.

METHOD_OUT_DIRECT 只写缓存 如果是这种方式.则我们的数据也会封装到IRP头部.但是会设置的 IRP 头部MdlAddress中.我们输出的数据都要放在MDL中.

如下:

OUT PVOID pOutBuffer = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress,NormalPagePriority);

MDL是 3环虚拟地址,映射到内核中的物理地址. 我们不能直接使用. 比如通过MmGetSystemAddressForMdlSafe这个函数.将映射的物理地址. 转换为内核中的 "虚拟地址" 可以这样理解. 然后对这个内存进入输出即可.我们Ring3则可以接受到数据.

区别: 如果是只读权限打开设备的时候.METHOD_IN_DIRECT则会成功.METHOD_OUT_DIRECT则会失败. 如果是读写的方式.则都会成功.

3.METHOD_NEITHER 其它方式

使用其它方式.则我们Ring3发送过来的数据 会在IRP堆栈中 我们获取Ring3的数据

PIRPSTACK_LOCATIO pIrpStack;
pIrpStack = IoGetCurrentIrpStackLocation(pIrp);
pIrpStack->Parameters.DeviceIoControl.Type3InputBuffer

则我们的输出的数据要放在 IRP头部的UserBuffer中传递给三环

pIrp->UserBuffer;

2.非控制 缓冲区的三种方式.

我们上面说了.控制派遣函数可以传递不同的缓冲区方式.进而在内核中. 进行不同的 取缓冲区 写缓冲区的操作.那么如果不是控制.则会有3种方式.

1.缓冲IO DO_BUFFERED_IO

当我们创建完毕设备对象的时候.可以给设备对象的 Flags字段设置一个缓冲区的方式..分别有三种.

如我们设置 缓冲区读取.

pDevice->Flags  |= DO_BUFFERED_IO;

如果是缓冲区方式.则 我们Ring3发送的数据就会封装到 IRP头部的SystemBuffer中. 也就是说说我们只需要取出 IRP头部的SystemBuffer就可以了.

缓冲IO的意思就是 3环 发送数据到0环. 0环开辟一个空间.用来接收. 这种方式很安全.但是效率差.如果一次发送很多字节.不建议使用这种方式. 因为你进入了内核.那么内核空间就是共享了.如果你在创建这种很多字节的缓冲区.那么就让原本已经很紧张的内核空间负担更重.而且如果内存来不及释放.则会永久占据.除非你重启电脑.

2.DO_DIRECT_IO 直接IO

直接IO的方式就是 将ring3数据所在的虚拟地址,映射到内核空间中. 内核进行读取.这种方式效率快.一般内核厂家都是这种.

听到映射.大家应该知道数据怎么传递了.

如果使用这种方式.那么数据 会在IRP头部的MdlAddress中.

我们取出来就是用 "API"进行获取即可. 这里的API指的是使用内核API.不是ring3的.注意

PVOID pBuffer = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress,NormalPagePriority);

3.其它IO方式.

如果是其它IO方式. 我们输入的数据则会在 IRP堆栈中的DeviceContrl字段中Type3InputBuffer中

pIrpStack->DeviceControl.Type3InputBuffer中.

输出的数据则会在 IRP的头部中的UserBuffer中

PIrp->UserBuffer

使用这种方式很危险.这种方式是内核直接读取ring3虚拟地址数据. 必须保证ring3进程跟内核进程处于同一运行状态中. 对此我们对其内存必须进行检查. 有两个API函数

ProbeForRead();    检查内存是否可读
ProbeForWrite();   检查内存是否可写.

三丶Ring3跟Ring0开发区别

1.什么是Ring3 什么是Ring0

CPU提供了4层. 而微软只用了2层. 分别是Ring0到Rign3. 而微软只用Ring0. 因为这个设计所以我们写的驱动才能跟操作系统的权限一样.这样做也是因为不过与依赖Cpu的设计.否则以后CPU架构一改.操作系统就废了.

X64系统 在X64系统中.CPU厂商因为操作系统没有使用. 所以CPU直接把 RING1 RING2给去掉了. 所以在X64下,只剩下ring0 跟 ring3了.

虚拟技术 (VT技术)

虚拟技术有三种方式 0 1 3 内核运行在0层. 虚拟机运行在1层 应用程序运行在3层.

VT模式: 虚拟机运行在 -1级别. 根模式. -1就是在0环旁边加了一个新的模式. -1级别就是权限很大的.比内核权限更大.

2.RING3 与 Ring0开发的区别

在内核中

printf scanf fopen fclose fwrite fread malloc free

都不可以使用.

但是与内存相关与字符串相关的可以使用

strcmp strcpy wcslen memset 

但是不建议使用.在内核中有专门的操作函数. 这种函数是Rtl开头. 如:

RtlStringcbCatA /W 字符串拼接
RtlStringcbCopy();字符串拷贝
RtlStringcbLength();求长度
RtlStringcbPrnt(); 字符串打印

如果是UNICODE_STRING则如下

RtlStringcbCopyUnicodeString();
RtlUnicodeStringCat();

在内核中我们的字符串数据结构有得新的数据类型

UNICODE_STRINGANSI_STRING 其实就是一个结构体. 如下:

typedef struct _STRING {
  USHORT  Length;
  USHORT  MaximumLength;
  PCHAR  Buffer;
} ANSI_STRING, *PANSI_STRING;
记录了长度.最大长度.以及一个缓冲区. UNICODE_STRING也一样.

对此针对这个结构体有了新的初始化 函数

ANSI_STRING string;
RtlInitAnsiString(string,"HelloWorld"");

UNICODE则是
RtlInitUnicodeString(string,L"Hello");

3.返回值的判断

在内核中使用函数.则会返回一个状态值. 如:

ntStatuse =IoCreateDevice();

需要使用宏判断

if (!NT_SUCESS(ntStatus))
    xxxx

常见的几个状态值

|状态|含义| |---|---|| |STATUS_SUCCESS|代表此次调用成功| |STATUS_UNSUCCESSFUL|代表失败| |STATUS_ACCESS_DENIED|代表访问拒绝| |STATUS_INSUFFICIENT_RESOURCES|资源不足| 这些状态吗都在 <ntstatus.h>中定义的.

4.内存的使用与申请

在内核中申请内存跟ring3不同. 内核中申请内存使用

PVOID 
  ExAllocatePoolWithTag(
    IN POOL_TYPE  PoolType,   申请内存的类型.内存是分页还是不可以
    IN SIZE_T  NumberOfBytes, 申请的字节
    IN ULONG  Tag             4个字节的内存标识,随便写.
    );

其中参数1需要你指定类型. 分页内存.就是内存可以交换到磁盘使用. 非分页内存.就是内存不能交换.就是固定的.不能变.但是非分页内存很宝贵.只有100-200MB.给我们操作系统使用.所以建议使用分页内存.

四丶IRQL中断级别

这一讲我很多博客也说过了.就是说我们调用的 内核函数都有级别一说.

如下表: 了解:

编码

级别

说明

PASSIVE_LEVE

无中断

常规线程执行

APC_LEVEL

软中断

异步过程调用执行

DISPATC_LEVEL

软中断

线程调度延时过程调用执行

DIRQL

硬中断

设备中断请求级处理程序执行

PROFILE_LEVEL

硬中断

配置文件定时器

CLOCK2_LEVEL

硬中断

时钟

SYNCH_LEVEL

硬中断

同步级

IPI_LEVE

硬中断

处理器之间的中断级

POWER_LEVEL

硬中断

电源故障级别

除了硬中断是最高的. 我们只看软中断. PASSIVER_LEVE APC_LEVEL DISPATCH_LEVEL 级别. 分别是 0 1 2 在软中断中Dispath级别最高.

如我们编写内核的时候给的派遣函数.以及入口点函数. 可以如下图:

调用源函数

级别

DriverEntry,DriverUnLoad0

Passive级别

各种分发函数

Passiver级别

完成函数

Dispatch级别

各种NDIS回调函数

Dispatch级别

在使用函数的时候.应该查询WDK文档.看看级别.

五丶驱动函数的分类

在驱动中有一些函数前缀都是固定的

如: Ex开头的.

函数

函数说明

ExAllocatePoolWithTag

分配内存

ExAcquireFastMutex

获取快速互斥锁

ExGetPreviousMode

获取前一个请求者的运行模式. 判断来自Ring0还是Ring3,拦截ring3.过滤ring0

Io开头的 属于Io管理器的

函数

函数说明

IoCreateDevice

创建设备对象

IoCreateSymbolicLink

创建符号链接

IoGetCurrentIrpStackLocation

获取Irp堆栈

IoAttachDeviceToDeviceStack

设备绑定,自己生成的设备绑定到别人的设备上,做过滤用的.

IoAllocateIrp

自己分配一个IRP.

IoSetCompletionRoutine

为IRP设置完成例程的.

Ke开头的

函数

函数说明

KeWaitForSingleObject

等待事件发生.做同步用的.

KeSetEvent

设置事件信号

KeInitializeEvent

初始化一个事件对象.

Mm开头的. 与Memory相关的.

函数

函数说明

MmGetSystemRoutineAddress

内核中获取函数的内存地址. 跟ring3 GetProcAddress相似.一个ring3一个ring0

MmIsAddressValid

判断函数地址是否无效.

Ob开头 与内核对象相关的.

函数

函数说明

ObReferenceObjectByHandle

把一个内核对象的Handle转化成内核它的内核对象. 句柄不能夸进程.所以转换为内核对象.

ObQueryNameString

查询名字跟路径.可能会死锁

Ps开头的. 与进程相关的.

函数

函数说明

PsGetCurrentProcess

获取当前进程的EPROCESS未导出的结构

PsGetCurrentProcessId

获取当前进程Pid

PsCreateSystemThead

在内核中创建线程的

PsLookUpProcessByProcessId

进程进程PID获取这个PID的EPROCESS结构

Rtl开头的 一组重写的函数.可以操作内存跟字符串

函数

函数说明

RtlZeroMemory

对一块内存清空位0.跟memset一样.

RtlInitUnicodeString

初始化UNICODE字符串

Zw开头. 系统服务的. 文件系统.注册表.

函数

函数说明

ZwOpenKey

打开注册表键

ZwCreateFile

创建文件或打开一个文件

ZwOpenProcess

打开一个进程

ZwQuerySystemInformation

遍历进程的一些信息

Flt开头的. 文件过滤相关的一组函数 (minfilter)

Ndis 防火墙中用的一些函数

六丶编写内核中的注意事项

1 不要使用 MmIsAddressValid函数.这个函数对于校验内存没有意义

这个函数只能判断一个字节地址的有效性

if (MmisAddressValid(Buffer))
{
 memcmp(BUffer,BufferTwo,Length);
}

它只判断地址字节的第一个地址.只要你的地址在这个分页.那么可以. 但是就怕分页.后面分页不对就会出错.

他还会对 Page Out不能准确的判断. 所以攻击者可以利用你的判断.来绕过你的保护.

2.保证我们的代码在 tye _except中完成.否则蓝屏. 编写驱动代码一定要注意不要产生异常.否则就会蓝屏. 如:

try
{
    ProbeFroRead(Buffer,len,alig);
    if (memcpy(Buffer,buffer2,len){};//这行出错就会在except.
}
_except(EXECUTE_HANDLER_EXCEPTION)
{
    //如果出错就会在这.
}

3.注意长度为0的缓存. 以及为NULL的缓存指针与缓存对其

缓存长度为0 ProbeForread跟Write. 如果我们Buffer长度穿的为0.这两个函数是不工作的.很容易被别人攻击.所以要小心len为0的情况. 如下漏洞代码:

try
{
   ProbeForRead(Buffer1,len,sizeof(char));
   if (strcmp(Buffer1,Buffer2,len){}

}_except(EXECUTE_HANDLER_EXCEPTION)
{
    xxx 
}

上面的代码会产生问题.因为当ProbeForRead的时候.长度传递为0 则这个函数不工作.但是我们的strcmp至少会访问一个字节.这样就造成了崩溃蓝屏. 绕过你的保护.所以最好使用Rtl之类的函数操作.

缓存指针如空

不要使用下面的代码

if (userBuffer == NULL){xxx};

windows操作系统运行用户态申请的一个地址为0的内存. 攻击者可以以它来绕过检查过保护.

在我们以前讲调用们的时候也说过. ring3可以使用0内存. 在Windows8以后内存不能申请为NULL.

缓存对齐

ProbeForRead(Address,length,Alignm);

在函数的第三个参数是对其. 默认是按照1对齐.如果使用 Sizeof(ULONG) 也会出问题.导致过保护.

4.注意不正确的内核调用引发的问题

如函数:

ObReferenceObjectByHandle();

如果使用这个函数.不指定类型.任然可以获得对应的对象地址.但是如果你直接访问这个对象.就会引发漏洞.

如:

HookZwSetInformationFile();
ObReferenceObjectByHandle(FileHandle,Access,NULL);
//ObReferenceObjectByHandle(FileHandle,Access,&Fileobject);

if (wscnicmp(fileObject->FileName){}

如上,参数如果传递为NULL. 攻击者可以传入非文件类型的句柄.如果你没有校验.就会导致悲剧.所以使用必须给指定对象类型.会影响第一个参数. 第一个参数攻击者可以传入任何Handle. 这就是拒绝服务攻击.一句话你就蓝屏.

不正确的Zw函数使用 使用Zw函数的时候.不能将用户态的内存给它. 因为Zw函数不会进行校验. 就算你进行了校验.传递这样的内存给系统也可以引发崩溃. 比如内存也在调用的时候突然无效. 就算你进行异常驳货.也可以造成内存泄漏.对象泄漏.甚至权限提升等问题.

不要下发内核对象给内核 我们Ring3的内核对象.不要通过 DeviceControl 进行传递. 如果这样写.很可能让攻击者可以做到任意地址写入.提升权限.

5.给驱动提供的功能性接口必须小心

如果对注册表 文件 内核内存.进程线程等操作的功能性接口.一定要非常小心才可以.禁止一切受信进程的调用. 不然你暴露接口就会被利用.

6.数据传输尽量使用 BUFFERED_IO 缓存的方式.

我们内核中的最好使用缓冲IO.也就是说使用SystemBuffer.如果不使用BUFFERED_IO而使用UserBuffer一定注意使用 Pro等检查函数.

7.发布的驱动必须通过内核校验

微软提供的驱动校验工具: verifier 在CMD命令中输入即可.打开后界面如下:

使用的时候

创建自定义设置(供程序开发人员使用) -> 从一个完整列表选择单个设置 ->出来很多检查. -> 从一个列表中选择驱动程序 ->有的话你选择.没有的话你自动选择一个.选中之后则会重启.自动进行检测. 如果出错.就会蓝屏.

队友挂钩内核函数的驱动. 还可以使用 BSOD HOOK 一类的Fuzz工具 来进行检查.

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券