初步认识MDL

xiaoxiao2021-02-28  43

一、

内存描述符列表 (MDL) 是一个系统定义的结构,通过一系列物理地址描述缓冲区。执行直接 I/O 的驱动程序从 I/O 管理器接收一个 MDL 的指针,并通过 MDL 读写数据。一些驱动程序在执行直接 I/O 来满足设备 I/O 控制请求时也使用 MDL。

驱动程序编写人员不应该假设 MDL 描述的内存页的顺序或内容。驱动程序不得依赖于 MDL 指向的任何位置的数据值,并且不应该直接取消对内存位置的引用来获取数据。如果 MDL 描述一个用于直接 I/O 操作的缓冲区,那么发出 I/O 请求的应用程序可能也已经将相同内存页的视图映射到其地址空间中。如果这样的话,应用程序和驱动程序可能尝试同时修改数据,这会导致错误。

而且,在一些情况下,MDL 中的位置不会引用内存管理器保留的相同物理页。当 Microsoft Windows 内存管理器构建一个用于设备读取的 MDL 时,它锁定传输目标使用的物理页。但是,只由内存管理器来确定保留哪些页面和丢弃哪些页面(如果存在的话)。为什么内存管理器将数据读入这些页,然后丢弃它们?因为在更大的群集中进行 I/O 能够提供更好的性能。

例如,在下图中,对应于页 A、Y、Z 和 B 的文件偏移和虚拟地址都是逻辑上相邻的(虽然物理页本身不必相邻)。页 A 和 B 没有驻留在内存中,因此内存管理器必须读取它们。页 Y 和 Z 已经驻留在内存中,所以不必读取它们。(事实上,自从最近一次从备份存储区读入以来它们可能已经被修改,在这种情况下,覆盖它们的内容将会发生严重错误。)但是,在单个操作中读取页 A 和 B 比为页 A 进行一次读取并为页 B 进行第二次读取更有效。因此,内存管理器发出一个包含所有 4 个页(A、Y、Z 和 B)的单个从备份存储区读取的请求。这种读取请求包含对于读取有意义的任意多页(依赖于可用内存的量、当前系统使用情况等)。

当内存管理器构建描述请求的内存描述符列表 (MDL) 时,它提供页 A 和 B 的有效指针。但是,页 Y 和 Z 的条目指向单个系统范围的虚拟页 X。内存管理器可能会使用来自备份存储区的潜在的过时数据填充虚拟页 X(因为它使得 X 不可见)。但是,如果组件访问 MDL 中的 Y 和 Z 偏移,那么它看到的是虚拟页 X 而不是 Y 和 Z。

内存管理器可以将任何数目的丢弃页表示为单个页,该页可以在同一个 MDL 中或者甚至多个并发的 MDL(用于不同的驱动程序)中嵌入多次。因此,表示丢弃页的位置的内容可以随时都可能改变。

基于 MDL 映射的页上的数据值来执行解密或计算校验和的驱动程序不得从系统提供的 MDL 撤销指针来访问数据。为了确保正确的操作,这样的驱动程序应该根据驱动程序从 I/O 管理器接收到的系统提供的 MDL 来创建一个临时 MDL。要创建临时 MDL:

调用 MmGetMdlVirtualAddress 和 MmGetMdlByteCount 来获取系统提供的 MDL 的虚拟基址和长度。

使用 PoolType=NonPagedPool 调用 ExAllocatePoolWithTag 来从未分页内存池分配缓冲区。指定等于系统提供的 MDL 长度的缓冲区大小,向上扩大到页边界。

调用 IoAllocateMdl 来分配一个 MDL(使用步骤 2 中创建的池缓冲区的虚拟基址和长度)。

调用 MmBuildMdlForNonpagedPool 来更新临时 MDL,使其描述步骤 2 中池缓冲区的底层物理页)。

驱动程序应该将这个临时 MDL 传递给从其硬件读取数据的调用,然后在任何需要的操作中使用临时 MDL 描述的数据值。通过调用 MmBuildMdlForNonPagedPool 来更新临时 MDL,驱动程序可以确保临时 MDL 不包含任何临时页,这样可以使其不会发生对页内容的任何改变。通过这种方式,即使系统 MDL 包含将被丢弃的(可能重复的)页,驱动程序仍然可以避免检查不稳定的内容。当驱动程序完成其操作时,它应该通过在try/except 或try/finally 块中使用 RtlCopyMemory 将更改的数据从临时 MDL 复制回系统提供的 MDL。

使用 MDL 作为典型 I/O 操作的一部分的驱动程序(不访问底层页上的数据)不需要创建临时 MDL。在内部实现上,内存管理器跟踪驻留的所有页以及每个页如何被映射。当驱动程序将 MDL 传递给系统服务例程来执行 I/O 时,内存管理器确保使用正确的数据。

您应该做什么?

不要假设 MDL 指向的任何内存位置的内容在任何给定的时间都有效。

如果您的驱动程序依赖于数据的值,那么始终应该在系统提供的 MDL 中双重缓存数据。

 

 

 

二、

前段时间在群里有人问起什么是MDL,当时三言两语也不知道解释清楚了没,今天闲着无事,索性记录下来吧,就当是做个笔记,以后忘了也好查阅。

我们知道,系统中一些重要的表项如SSDT是只读的,如果我们强行对其进行修改就会造成BSOD的严重后果。当然这种保护方式很容易被绕过,我们曾经介绍了通过修改cr0来禁用WP(Write Protect,写保护)位的方法,现在再介绍一种不需要使用汇编的方法,就是MDL。

MDL的全称是Memory Descriptor List,即内存描述符表。我们可以通过MDL描述一块内存区域,在MDL中包含了该内存区域的起始地址、拥有者进程、字节数量、标记等信息,如下所示:

[java] view plaincopy

typedef struct _MDL   {      struct _MDL     *Next;      CSHORT          Size;      CSHORT          MdlFlags;      struct _EPROCESS *Process;      PVOID           MappedSystemVa;      PVOID           StartVa;      ULONG           ByteCount;      ULONG           ByteOffset;  } MDL, *PMDL;  

我们先来看一段在SSDT HOOK中常见的代码,如下所示:

[cpp] view plaincopy

PMDL MDSystemCall;  PVOID *MappedSCT;    MDSystemCall = MmCreateMdl(NULL, KeServiceDescriptorTable.ServiceTableBase, KeServiceDescriptorTable.NumberOfServices*4);  if(!MDSystemCall)  {      return STATUS_UNSUCCESSFUL;  }  MmBuildMdlForNonPagedPool(MDSystemCall);  MDSystemCall->MdlFlags |= MDL_MAPPED_TO_SYSTEM_VA;  MappedSCT = MmMapLockedPages(MDSystemCall, KernelMode);  HookOn( ZwTerminateProcess, New_ZwTerminateProcess);  

其中KeServiceDescriptorTable描述的是SSDT,其中ServiceTableBase标明了SSDT的起始地址,因此我们通过MmCreateMdl函数创建一个MDL,它刚好把SSDT这块内存包含在内,有人可能会问,它的长度为啥是“KeServiceDescriptorTable.NumberOfServices*4”字节呢?很简单,因为指针的长度是4个字节。

后面的代码首先从不分页的内存池中Build MDL,然后添加一个MDL_MAPPED_TO_SYSTEM_VA标记以便允许写入该内存区域;

然后我们将这块内存锁起来,现在可以就开始HOOK SSDT了,直到关闭HOOK后才将其释放。从这里就可以看出,这种方式明显没有修改cr0的方法好,呵呵呵呵。

不过这种方法的应用范围比较广,控制精度也较好,精确地指定了要修改哪一块内存的标记,两者方法算是各有千秋吧,谁喜欢用哪一种都可以。

 三、

http://www.cnblogs.com/guanlaiy/archive/2012/09/06/2673375.html

一 MDL是什么 

在MSDN中有这样的定义

内存描述符列表 (MDL) 是一个系统定义的结构,通过一系列物理地址描述缓冲区。执行直接 I/O 的驱动程序从 I/O 管理器接收一个 MDL 的指针,并通过 MDL 读写数据。一些驱动程序在执行直接 I/O 来满足设备 I/O 控制请求时也使用 MDL。

http://msdn.microsoft.com/zh-cn/windows/hardware/gg463193.aspx这里有完整的内容,但该文章是机器人翻译过来的,所以看起来有点头疼.

因此通俗的解释一下,MDL仅仅运用于内核中,在应用层并不会涉及这个结构,由于内核中的驱动有跟应用层程序通信的需要,因此可能会接收到来自进程空间的虚拟地址,而在windows的分页机制下,进程空间中的任何一个虚拟地址所属的页面都有可能被内存管理器从RAW置换到页文件中,或者,进程被释放或是取消地址的映射。这些都会导致严重的错误发生。因此内核创建一个MDL,并将其与来自进程空间的虚拟地址相关联,当需要对这些虚拟地址进行读写的时候调用相关的内核函数,锁定这些虚拟地址对应的物理页面和逻辑页面,防止物理页面被置换,逻辑页面被修改或者释放。

另外一种情况下一个驱动程序在执行纯内核任务中也可以使用MDL,特别的仅仅调用非分页内存的话,这些页面是不会置换到页文件中的,因此不需要考虑锁定页面的问题。

二 MDL的内容

先看看wdm.h中MDL的定义:

[cpp] view plaincopy

typedef __struct_bcount(Size) struct _MDL {      struct _MDL *Next;      CSHORT Size;      CSHORT MdlFlags;      struct _EPROCESS *Process;      PVOID MappedSystemVa;      PVOID StartVa;      ULONG ByteCount;      ULONG ByteOffset;  } MDL, *PMDL;  

 

先大概说明一下爱各个字段:

   Next:MDL可以连接成一个单链表,因此可以将分散的虚拟机地址串接起来。

    Size:一个MDL并不单单包含结构里这些东西,在内存中紧接着一个MDL结构,存着这个MDL对应的各个物理页面编号,由于一个物理页面一定是4KB对齐的,所以这个编号相当于一个物理页面起始地址的高20位。Size的值减去sizeof(MDL),等于存放编号的区域的大小。比如该MDL需要三个物理页面来映射虚拟地址空间,则Size-sizeof(MDL)==4*3==12;

    MdlFlags:很重要的字段,用于描述和操控虚拟地址的各种属性。

    Process:如果虚拟地址是某一进程的用户地址空间,那么MDL代表的这块虚拟地址必须是从属于某一个进程,这个成员指向从属进程的结构

    MappedSystemVa:该MDL结构对应的物理页面可能被映射到内核地址空间,这个成员代表这个内核地址空间下的虚拟地址。对MmBuildMdlForNonPagedPool的逆向表明,MappedSystemVa=StartVa+ByteOffset。这是因为这个函数的输入MDL,其StartVa是由ExAllocatePoolWithTag决定的,所以已经从内核空间到物理页面建立了映射,MappedSystemVa自然就可以这样算。 可以猜测,如果是调用MmProbeAndLockPages 返回,则MappedSystemVa不会与StartVa有这样的对应关系,因为此时对应的物理页面还没有被映射到内核空间。(此处未定,MmProbeAndLockPages 是否会到PDE与PTE中建立映射,未知。)

    StartVa:虚拟地址空间的首地址,当这块虚拟地址描述的是一个用户进程地址空间的一块时,这个地址从属于某一个进程。

    ByteCount:虚拟地址块的大小,字节数

    ByteOffset:StartVa+ByteCount等于缓冲区的开始地址

 由于WDK文档中表述得比较模糊,上面的说明有些仅仅是猜测。我们可以通过DBG调试一个驱动,观察它的内存原始数据来证实我们的推测。

下面是取自tdifw中的一段代码,它为一个IPV4地址ctx->tai 分配一个非分页内存块,然后调用IoAllocateMdl创建一个针对这个虚拟地址的MDL,最后调用MmBuildMdlForNonPagedPool来建立虚拟地址与物理页面直接的映射。

[cpp] view plaincopy

ctx->tai = (TDI_ADDRESS_INFO *)malloc_np(TDI_ADDRESS_INFO_MAX);   if (ctx->tai == NULL) {    KdPrint(("[tdi_fw] tdi_create_addrobj_complete: malloc_np!\n"));      status = STATUS_INSUFFICIENT_RESOURCES;    goto done;   }  

可以看到tai的首地址是0x82040928,这是一个非分页内存中的虚拟地址,长度是0x55。

[cpp] view plaincopy

mdl = IoAllocateMdl(ctx->tai, TDI_ADDRESS_INFO_MAX, FALSE, FALSE, NULL);   if (mdl == NULL) {    KdPrint(("[tdi_fw] tdi_create_addrobj_complete: IoAllocateMdl!\n"));        status = STATUS_INSUFFICIENT_RESOURCES;    goto done;   }   

分配MDL之后的内存:

其中MDL的首地址是0x82010210,size是32,这是MDL结构本身的大小,mdlflags是8,mappedsystemva的值是0xf8c9oa9c

startva是0x82040000,这说明startva目前表示的是tai所指向的虚拟地址的页起始地址,bytecount是55,这代表了虚拟地址的大小,byteoffset是0x929,因此这个字节表示的是虚拟地址相对于页的偏移地址。  MmBuildMdlForNonPagedPool(mdl);

调用此函数之后:

 

仅有3个字段发生了变化

mdlflags变成了12,mappedsystemva真正指向了虚拟地址,process被置0,说明这是一个非分页地址,它不属于任何一个进程的地址空间。

实际上size的大小并不等于MDL结构的大小,因为在MDL后面紧跟着一个表示物理页面的数组,只是没有在结构体中表现出来,这应该是为了避免一般的驱动程序直接修改这些物理数组,因为虚拟地址和物理页面的映射只应该由内存管理器来维护。在刚才的调试中观察内存发现,在MDL后面只有一个物理页面编号。此编号在调用IoAllocateMdl的时候并未初始化,而是在 MmBuildMdlForNonPagedPool(mdl)中被赋的值。有些人认为 MmBuildMdlForNonPagedPool是把物理页面映射到系统地址空间中,这种说法应该是错误的,因为对于非分页内存,在调用ExAllocatePool系列函数的时候,内存管理器就建立了映射关系,否则这些内存根本无法使用,实际上, MmBuildMdlForNonPagedPool的作用是把这种映射保存到MDL中,使其变得不透明,以满足某些驱动的需求。

三,MDL的使用

 典型的,当运行在内核中的一个驱动向另一个驱动发送请求的时候,其中一种数据传输方式将运用到MDL。

首先调用IoAllocateMdl对你需要传递的数据生成一个MDL,它会返回一个MDL结构的指针,然后调用MmBuildMdlForNonPagedPool来更新MDL的内容,最后把这个MDL指针传递给IRP中的MdlAddress成员。

 

顺便说一下,我们不可以直接访问MDL的任何成员。应该使用宏或访问函数,

对于I/O管理器执行的Direct方式的读写操作,其过程可以想象为下面代码:

[cpp] view plaincopy

KPROCESSOR_MODE mode; // either KernelMode or UserMode PMDL mdl = IoAllocateMdl(uva, length, FALSE, TRUE, Irp);   MmProbeAndLockPages(mdl, mode, reading ? IoWriteAccess : IoReadAccess);    MmUnlockPages(mdl); ExFreePool(mdl);  

I/O管理器首先创建一个描述用户缓冲区的MDL。IoAllocateMdl的第三个参数(FALSE)指出这是一个主数据缓冲区。第四个参数(TRUE)指出内存管理器应把该内存充入进程配额。最后一个参数(Irp)指定该MDL应附着的IRP。在内部,IoAllocateMdl把Irp->MdlAddress设置为新创建MDL的地址,以后你将用到这个成员,并且I/O管理器最后也使用该成员来清除MDL。

这段代码的关键地方是调用MmProbeAndLockPages(以粗体字显示)。该函数校验那个数据缓冲区是否有效,是否可以按适当模式访问。如果我们向设备写数据,我们必须能读缓冲区。如果我们从设备读数据,我们必须能写缓冲区。另外,该函数锁定了包含数据缓冲区的物理内存页,并在MDL的后面填写了页号数组。在效果上,一个锁定的内存页将成为非分页内存池的一部分,直到所有对该页内存加锁的调用者都对其解了锁。

在Direct方式的读写操作中,对MDL你最可能做的事是把它作为参数传递给其它函数。例如,DMA传输的MapTransfer步骤需要一个MDL。另外,在内部,USB读写操作总使用MDL。所以你应该把读写操作设置为DO_DIRECT_IO方式,并把结果MDL传递给USB总线驱动程序。

顺便提一下,I/O管理器确实在stack->Parameters联合中保存了读写请求的长度,但驱动程序应该直接从MDL中获得请求数据的长度。

ULONG length = MmGetMdlByteCount(mdl);

 

转载请注明原文地址: https://www.6miu.com/read-2300074.html

最新回复(0)