几种挖掘任意读写驱动的方法

本文以3个比较典型驱动为例,分享几种任意读写驱动的漏洞挖掘思路和一些tricks。

注: RealBlindingEDR 工具新增两款驱动利用,支持Win7 - Win11最新版;其次新增clear参数,可一键永久移除杀软。

详情可见项目地址:https://github.com/myzxcg/RealBlindingEDR。

#内核中跨进程内存读取

#CR3读写

利用CR3寄存器可以实现强制读写特定进程的内存地址。

CR3是一种控制寄存器,它是CPU中的一个专用寄存器,用于存储当前进程的页目录表的物理地址。为了实现进程间的隔离和保护,操作系统会为每个进程分配独立的地址空间。当进程切换时,操作系统会修改CR3寄存器的值,从而让CPU使用新的页目录表来完成虚拟地址的翻译。

#MDL读写

除了通过修改 CR0 寄存器,关闭内存写保护属性,然后再写入内存的方式来修改指定进程内存外,还可以通过MDL来修改指定进程内存。

MDL(内存描述符表)结构体是Windows内核中专门用于描述物理内存的数据结构,可以通过MDL描述一块内存区域,在MDL中包含了该内存区域的起始地址、拥有者进程、字节数量、标记等信息。可以通过MDL 突破内存读写的限制。

MDL 是用来建立一块虚拟地址空间与物理页面之间的映射。当要对一块内核内存进行修改的时候,先为这块内存创建 MDL,那么就会建立一块新的虚拟内存空间,与将要修改内存对应的物理空间相映射。也就是说,同一块物理空间,映射了两块不同的虚拟内存地址。可以通过这两个虚拟内存地址,来操作这块物理内存,这便是 MDL 修改内存的实现思路。

其次,还可以通过ZwOpenSection、ZwMapViewOfSection\Device\PhysicalMemory 节对象来映射物理地址到虚拟地址,从而实现对指定内存的读写。

#内存拷贝实现读写

  1. MmCopyVirtualMemory

    MmCopyVirtualMemory (未记录API)这个内核API函数可以在用户空间和内核空间之间实现内存数据的拷贝。

    1
    2
    3
    
    SIZE_T Result;
    MmCopyVirtualMemory(SourceProcess, SourceAddress, TargetProcess, TargetAddress, Size, KernelMode, &Result))
    //KernelMode=0
    
  2. memmove

    1
    
    void *memmove(void *dest,const void *src,size_t count);
    

直接I/O和缓冲I/O的缓冲区获取方式

对于IRP_MJ_DEVICE_CONTROL,缓冲区访问方式是基于IOCTL的。从IOCTL 中可以看出这是直接I/O 还是缓冲I/O。注意:IRP_MJ_DEVICE_CONTROL 不受DeviceObject->Flags的直接/缓冲 IO设置影响,这个Flags设置的IO只影响IRP_MJ_READ、IRP_MJ_WRITE。 此网站可以解析IOCTL。

更多详细内容可以参考:《windows 内核编程》的7.5章节。

#echo_driver.sys

首先直接定位到驱动的IRP_MJ_DEVICE_CONTROL(14)分发例程函数位置:

Untitled

  1. a2->Tail.Overlay.CurrentStackLocation 其实就是IoGetCurrentIrpStackLocation()函数的实现。

  2. 正常是通过stack->Parameters.DeviceIoControl.IoControlCode 来获取IOCTL值的,反编译这里是通过CurrentStackLocation->Parameters.Read.ByteOffset.LowPart 获取,姑且当做是同一个意思。

  3. a2->AssociatedIrp.MasterIrp 其实是a2->AssociatedIrp.SystemBuffer输入缓冲区的地址),因为它俩在一个union 中,共享一个偏移量。

    Untitled

    Untitled

  4. MasterIrp->Type 其实就是SystemBuffer 这个缓冲区(0x9E6A0594 代表这是一个缓冲IO),因为这里Type它的偏移其实是0。MasterIrp->MdlAddress 这里的MdlAddress偏移是8,是一个长度(从sub_1400012BC函数内部看出来的),这个由客户端程序构造。

  5. dword_14000410C 是一个全局变量(在.data段),它的值是当前进程的PID,在sub_1400012BC函数中赋予。所以,我们要想走到下面的流程,必须要先完成0x9E6A0594 这个IOCTL的操作。 Untitled

接下来看下面几个IOCTL对应的代码逻辑: Untitled

  1. 0xE6224248 这个IOCTL代码下面,可以见到是根据PID获取句柄,然后添加到I/O缓冲区后面。此部分逻辑和内存读取无关。

  2. 0x60A26124 这块的代码逻辑,首先是从I/O缓冲区中获取进程句柄。然后利用ObReferenceObjectByHandle 函数获取此句柄对应的EPROCESS结构,最后调用sub_140001B80函数。在这个函数中,发现了MmCopyVirtualMemory 虚拟内存拷贝函数。

    Untitled

接下来就是弄清楚,这个5个参数是如何组织的:

Untitled

这里rdi寄存器中保存的是I/O缓冲区的地址。结合上两张图I/O缓冲区中的数据可以构成如下结构:

1
2
3
4
5
6
7
struct{
HANDLE ProcessHander;
INT64 SourceAddr;
INT64 TargetAddr;
SIZE_T Size;
SIZE_T Result;
}

注意这里MmCopyVirtualMemory 函数的SourceProcess和TargetProcess都一样为当前进程的EPROCESS。所以这里SourceAddr和TargetAddr互换就可以实现读写效果。

#dbutil_2_3.sys

还是首先定位到IRP_MJ_DEVICE_CONTROL(14)的分发例程函数: Untitled 我们首先还是关注,I/O缓冲区分配给了哪个变量。

  1. v3 是一个二级指针,它的值是I/O缓冲区的地址。之后的*v3就代表这个IO缓冲区。

  2. 因为_IO_STACK_LOCATION结构的Parameters是一个巨大的各种结构的联合体。这里根据实际情况,CurrentStackLocation->Parameters.Create.Options 其实是CurrentStackLocation->Parameters.DeviceIoControl.InputBufferLength ,也就是输入缓冲区的大小赋值给了Options变量。然后将这个Options又保存在了(v3+2)这个地址中。

    CurrentStackLocation->Parameters.Read.Length 实际上指向的是CurrentStackLocation->Parameters.DeviceIoControl.OutputBufferLength

这里说一下二级指针在这里的含义:执行图中的1指令后,v3代表这个IO缓冲区,且它是一个INT64类型(8位)。**v3 就表示结构体中的pad1值,(v3+1)就是结构体Address的值(因为v3是一个INT64,+1就是一个INT64*的大小,也就是8位)。v3保存的是一个地址,这个地址就是这个IO缓冲区的地址(如下图)。*((_DWORD *)v3 + 2) ,所以这里地址+2,意思是v3中保存的地址+2,其实是+8字节(因为强转了DWORD)。

Untitled 接下来直接看调用memmove的函数 Untitled

  1. a1 就是外面的v3,直接传进来的。只要调用这个函数前,指定了相应的IOCTL,a2的值就为1。
  2. 这里看到v3就是获取I/O缓冲区的长度。这是在调用函数前设置的,前面有分析。
  3. v11就是获取I/O缓冲区的第一个8字节。v12 就是获取I/O缓冲区的第二个8字节,v13同理。注意这里的v6,它是获取a1的值+16字节的值,但是前面只赋值了a1+8字节的值为缓冲区的长度,所以不会走到后面的if语句里面去。
  4. v8、v9就好理解了。例如可以将v12设置为要读取的目标地址,v13设置为0。就会将读取的数据存入到I/O缓冲区的第四个8字节(v5+3),这里的+3是24字节。因为v5的基本大小是INT64*。

以上就完成了内存读取的过程。同理,当a2的值为0的时候,就是内存写入的操作逻辑。

#GPU-Z

此驱动来自Antivirus_R3_bypass_demo项目

直接看IRP_MJ_DEVICE_CONTROL(14)的分发例程函数。 Untitled 可以看到这里有三个参数,并且v3、v4 直接用的偏移,也并没有解析成结构体。由于驱动中的分发例程都是两个参数:目标设备对象和一个I/O请求包(IRP),所以这里将a3参数删除掉,然后修改a2为IRP* 结构。如下图: Untitled 进入IOCTL为0x80006490的分支中,发现有一个memmove函数。 Untitled 但是这里注意仔细看memmove(v7, SystemBuffer + 81, SystemBuffer[16]); 这行代码。v7 的值可控,可以自定义地址,也就是要写的目的地址可控了。但是第二个参数即源地址,只能是相对于SystemBuffer 偏移81位的地址。注意这并不是SystemBuffer+81 地址上的值(取值应该是*(SystemBuffer+81)SystemBuffer[81])。所以这里是一个任意地址写,但是不能任意地址读。

再继续看IOCTL为0x8000645C 的分支: Untitled 根据上图分析,客户端程序传输的数据(SystemBuffer)第一个8字节是一个物理地址,第二个4字节是大小。通过MmMapIoSpace 函数将其转换为虚拟地址后,通过MDL又创建了一个映射到此物理地址的虚拟地址,使得这个物理地址可以被用户态进程访问修改,这就实现了对任意物理地址的读写。(MmMapLockedPagesSpecifyCache 第二个参数为1,即UserMode)参考

注: 这里返回的虚拟地址即v13,只有才win7等版本才能进行读写,win10+没有权限读写这个地址。所以这个驱动只适用于win7。

在上面这个IOCTL中没有看到对分配内存的释放,这个处理其实是写在IOCLT为0x80006460的分支中:

Untitled 所以调用完0x8000645C后,还需要调用0x80006460对内存进行释放。

当然,这里需要传入物理地址,所以需要将要修改和查询的内核虚拟地址转换为物理地址,再传给对应的IOCTL。

#64位虚拟地址到物理地址的转化

这里简单分析64位虚拟地址转物理地址的过程:

一些概念:

  1. 在x64体系中只实现了48位的虚拟地址,高16位被用作符号扩展,这高16位要么全是0,要么全是1。
  2. x64体系中普通页大小仍为4KB,页表从x86的2级变成了4级。每级页表寻址长度变成9位,所以每级页表最多512项。

Untitled

  • PML4T(Page Map Level4 Table)及表内的PML4E结构,每个表为4K,内含512个PML4E结构,每个8字
  • PDPT (Page Directory Pointer Table)及表内的PDPTE结构,每个表4K,内含512个PDPTE结构,每个8字节
  • PDT (Page Directory Table) 及表内的PDE结构,每个表4K,内含512个PDE结构,每个8字
  • PT(Page Table)及表内额PTE结构,每个表4K,内含512个PTE结构,每个8字节。

X64,准确的说应该是IA32e paging 模型提供了三种页转换模型,三种模型都是物理页帧的基地址加上页偏移得到物理地址,不同只是在于页帧的大小划分不同:

  1. 4K页面: 使用PML4T,PDPT,PDT和PT 四级页转化表结构;
  2. 2M页面:使用PML4T,PDPT 和PDT三级页转化表结构;
  3. 1G 页面:使用PML4T和PDPT二级页表转化结构。

在个人计算机上,普遍都是4K页面。

这里以0xcc7096f784 这个虚拟地址为例,简单说一下转换到物理地址的过程:

按照4个9位和一个12位的划分:

我们先从CR3寄存器中读取到页目录表的物理基地址:0xa47ef000

查看此处物理地址: Untitled 由于PML4 的值为1,所以这里的索引就为1。PDP 的基地址就在0x0a00000247e03867` 里面。这里的规则是,12~35位为下一基地址的高24位,至于更高的36~51为必须为0,最后的低12位补0;

所以得出PDP的物理基地址为0x247e03000 ,由于PDP的值为0x131,所以乘以8即为0x988,此偏移代表PTS物理基地址所在位置,以此循环往复找到最终的对应的物理地址。(当然根据页帧划分不同,中间逻辑也有些不同)

Untitled 更详细内容,推荐阅读:X64下的虚拟地址到物理地址的转换

CR3值的获取

从上面看出,需要首先知道最关键的CR3的值,但是CR3的值从用户态是无法直接获取的。其次,在整个利用过程中,基本是对内核的系统虚拟地址进行读写操作。由于所有进程的内核系统虚拟地址都是一样的。所以这里只要能获取到任意一个进程的CR3就可以,不一定非要是某个进程的。

方法一:

这个方法其实是获取System进程的CR3 值。通过uefi启动的系统,0x1000-0x100000的物理地址存了一个结构体PROCESSOR_START_BLOCK ,这个结构体中的一个值是_KPROCESSOR_STATE 结构,而这个结构中就有system进程的CR3值。所以简单来说就是通过遍历0x1000-0x100000 地址范围,找到PROCESSOR_START_BLOCK结构,再根据偏移,定位到CR3。

可参考:ProcExp的利用

方法二:

还有一种方法,也就是暴力破解。通过遍历一个范围内的数来猜测当前进程的CR3值,然后把这个值代入到上面的计算流程中,看通过一个指定的虚拟地址转换到物理地址后读出来的数据是否和这个虚拟地址保存的数据相同。

那这里要遍历的CR3地址范围该是多少呢? Untitled 上图可以看到,CR3物理地址最低值可以取0x100000。最高值我们通过前面知道不会超过0xffffff000 。所以这就是需要遍历的范围。同样根据前面介绍,PML4表的大小为4K,所以递增是4k。

END...

加载评论