探索黑客技术攻防,实战研究与安全创新

导航菜单

共享内存的奥秘

在互联网兴盛之初,BBS曾火爆一时,大家都可以在这块共享的领地分享自己的故事和心情。在计算机的世界里,如果把每个进程比作一个人,那么它们之间交流的手段又是什么呢?就是我们下面要介绍的Windows中的共享内存机制。

在应用层面共享内存的使用

我们知道,在Windows中每个进程的地址空间都是相互独立的。换句话说,对于两个进程同样的虚拟地址,背后映射的物理内存并不相同(内核地址空间例外)。但有时会有这样的需求,要将两个进程中不同或者相同的地址映射为同一块物理内存,这样做的目的可以是:

1)这段内存是不可修改的可执行程序,两个进程使用同样的代码。简单的例子就是打开两个Word进程,其处理的文档可能不同,但是Word本身的代码是可以共用的,代码段的物理内存可以共用。

2)将一块相同的物理内存映射到不同的进程后,这两个进程可以通过这段物理内存实现进程之间的通信。

以上是共享内存标准的用法,但在实际应用中,共享内存还可以用于文件的映射,即将一个文件的一块区域映射到某一进程的地址空间内,该进程对文件的操作就转换为对内存(编程中表现为对数组)的操作,这样可以有效的减少I/O操作的数量,提高效率。为了让大家对共享内存有个初步的概念,我们先看看编程世界里对共享内存的使用方法,标准步骤如下:

①hFile=CreateFile(pszFileName,….);

//创建或者打开文件对象

②hFileMap=CreateFileMapping(hFile,…);//创建一个文件映射内核对象

③pbFile=MapViewOfFile(hFileMap,….);

//将文件映射对象的部分或者全部映射到进程的地址空间

④pbFile[0]=1;

//共享内存的使用

⑤UnMapViewOfFile(pbFile,…);//取消上一步骤的映射

⑥CloseHandle(hFileMap);

⑦CloseHandle(hFile);

//关闭文件映射内核对象

//关闭文件内核对象

共七个步骤,如果细看,可以发现步骤①与步骤⑦相对应,步骤②与步骤⑥相对应,步骤③与步骤⑤相对应,都是互相对应的反向操作。

在第②步中,CreateFileMapping()函数创建了一个文件映射内核对象,该函数使用一个由步骤①中CreateFile()函数打开并返回的文件句柄,但若是为了两个进程之间共享内存,此句柄可以设置为0xFFFFFFFF,表示使用系统页面文件,则步骤①可以省略。该函数最后一个参数可以为内存映射对象指定名字,通过调用CreateFileMapping函数和OpenFileMapping函数,其他进程可用这个名字来访问相同的文件映像。

CreateFileMapping函数创建成功文件映射内核对象后,步骤③调用MapViewOfFile函数,把文件的一块区域映射到进程地址空间上,调用这个函数需要指定文件映射对象、目标文件的起始地址、操作的数量等参数。

完成步骤③后,操作数组pbFile就等同于操作目标文件本身。当不再需要把文件的数据映射到进程空间时,调用步骤⑤中的UnMapViewOfFile函数解除映射,同时会将一些映射数据写入文件。最后在步骤⑥、⑦中,释放文件映射对象和文件对象。

如果上面的步骤通过编程,亲自实践,能够顺利完成的话,那么就是一个合格的Windows编程人员,但作为一名研究Windows内核的同仁,还要想的更多,在CreateFileMapping函数和MapViewOfFile函数背后,共享内存实现的奥秘是什么呢?

内核层面共享内存的实现

在最初学习这段内容的时候,看着N多个数据结构,N多的连接关系,密密麻麻如八爪鱼般邪恶。为了使读者少一些痛苦,轻松一些,下面我将按照前面介绍共享内存使用的步骤

①~③进行分步讲解。

1.CreateFile的工作

024.png

因为本文并不专门针对文件的操作,所以只涉及共享内存有关的内容。当我们使用CreateFile创建或者打开一个文件时,系统会创建一个文件对象,如果该文件再次被打开,会有一个新的文件对象被创建,打开N次,生成N个文件对象。在每个文件对象中,有一个指针指向一个叫做“内存区对象指针”的结构。

“内存区对象指针”由三个32位的指针组成:指向共享的高速缓存映射的指针、指向数据控制区域的指针和指向映像控制区域的指针。指向共享的高速缓存映射的指针用于文件的缓存管理,是一个很重要的概念,以后的文章会有详细的描述,而后两者都指向控制区域结构,分别用来映射数据(Data)文件和可执行(Image)文件。

2.CreateFileMapping的工作

前面提到,CreateFileMapping函数会创建文件映射对象(FileMappingObject),该对象还有一个别名,叫做内存区对象(SectionObject)。内存区对象有两种:一种是需要具体文件支撑的,这个文件可以是可执行(Image)文件(Image),也可以是数据(Data)文件,通常这种内存区对象用于文件的映射操作,因此可以说这种内存区对象背靠的是映射文件。还有一种内存区对象是不需要具体文件,而使用页面文件支撑的,这种内存区对象通常用于两个或者多个进程共享内存,进行进程间通信,所以可以说这种内存区对象背靠的是页面文件。

注意,所谓的页面文件是操作系统创立的文件,专门用于将使用次数少的内存进行存储,从而空出内存,所以可以认为页面文件是内存的一个背靠文件。

CreateFileMapping函数通过间接调用MmCreateSection函数完成内存区对象的创建。

MmCreateSection函数实现的逻辑如下:

1)如果FileHandle非空,代表该内存区对象由映射文件支撑,那么新建一个Control_Area对象,并用文件对象中的信息填充,利用MiCreateImageFileMap或者MiCreateDataFileMap创建一个新的segment对象;

2)如果FileHandle为空,代表该内存区对象由页面文件支撑,则调用MiCreatePagingFileMap创建一个控制区对象和segment对象,而并不涉及到文件对象;

3)利用ObCreateObject函数创建section对象,并填充相应信息,返回该section对象。

好了,一会功夫,蹦出三个对象:控制区域(Control_Area)对象,内存区(Section)对象和段(Segment)对象,对象多了好头疼!

内存区对象:内存区对象属于内核对象之一,有标准的对象头,也有对象体,对象头由对象管理器负责,而对象体的内容由内存管理器管理,其对象体结构如下:


typedefstruct_SECTION{
MMADDRESS_NODEAddress;
//当这个section是可执行程序时,放于专门存放可执行程序的VAD树中PSEGMENTSegment;//指向段对象LARGE_INTEGERSizeOfSection;//内存区的大小union{ULONGLongFlags;MMSECTION_FLAGSFlags;}u;
//内存区的一组标志
MM_PROTECTION_MASKInitialPageProtection;//页面保护模式
}SECTION,*PSECTION;
typedefstruct_CONTROL_AREA{
PSEGMENTSegment;
………
//指向段对象
ULONGNumberOfMappedViews;//反映了与之关联的内存区对象被映射了多少次
………
PFILE_OBJECTFilePointer;
PEVENT_COUNTERWaitingForDeletion;
USHORTModifiedWriteCount;
USHORTFlushInProgressCount;
ULONGWritableUserReferences;
ULONGQuadwordPad;
//指向文件对象
}CONTROL_AREA,*PCONTROL_AREA;


这个数据结构是Control_Area对象结构的主体,一个完整的Control_Area对象之后还紧跟着N个SUBSECTION结构,每个SUBSECTION对应着文件中的一个SECTION,用于描述文件中每节映射信息(只读、读写、写时复制等)。例如,我们知道一个PE文件有N个节(section,这个section和前面提到的不一样,它是PE文件的组成部分,可以翻译为“节”),那么PE文件中有几个节,在Control_Area对象中就有多少个Subsection。所有的SUBSECTION结构构成一个单链表,每个SUBSECTION结构有一个指针指回到Control_Area对象结构。

段对象的结构体如下:


typedefstruct_SEGMENT{
struct_CONTROL_AREA*ControlArea;
ULONGTotalNumberOfPtes;
ULONGNonExtendedPtes;
UINT64SizeOfSegment;
MMPTESegmentPteTemplate;
……
SEGMENT_FLAGSSegmentFlags;
PVOIDBasedAddress;
……
union{
PSECTION_IMAGE_INFORMATIONImageInformation;
PVOIDFirstMappedVa;
}u2;
PMMPTEPrototypePte;
MMPTEThePtes[MM_PROTO_PTE_ALIGNMENT/PAGE_SIZE];
}SEGMENT,*PSEGMENT;


Segment段对象在分页缓冲池中分配,用来描述和存放内存区数据。大家会注意到该结

构中有很多PTE的字样,一个完整的Segment段对象除了上述的结构,紧接着还会有一个

PTE数组,形成一个原型PTE阵列,用于完成将内存区对象实际映射到物理内存上。

列出以上三个对象的数据结构,我们先看看它们之间的连接关系,如图2所示。

025.png

图2CreateFileMapping函数所做的工作

当控制区域对象被创建后,如果该内存区对象为可执行文件,则内存区对象指针中的映像控制指针指向该控制区域对象,如果该文件为数据文件,则内存区对象指针中的数据控制指针指向该控制区域对象。

3.MapViewOfFile的工作

好的,现在对象以及各个对象之间的关系都已经创建了,应用程序获得了内存区对象的句柄,但还不能访问内存区对象中的数据。为了使用内存区对象中的数据,应用程序必须映射一个视图,将内存区对象描述的地址映射到进程的地址空间,这个步骤由Windows内核的系统服务例程NtMapViewOfSection函数完成,对应于内存管理器中的函数是MmMapViewOfSection函数。

MmMapViewOfSection函数的大致逻辑是这样的:由内存区对象→段对象→控制区对象中的标志信息确定内存区的类型:

①若PhysicalMemory位为1,则映射的类型为物理内存,使用MiMapViewOfSpecialSection函数来映射内存区;

②若Image位为1,表明内存区对象是个镜像文件,则调用MiMapViewOflmageSection函数来映射内存区;

③若非以上两种情况,那么内存区对象为数据文件或者页面文件,则调用MiMapViewOfDataSection函数来映射。

下面我们以MiMapViewOfDataSection函数的逻辑为例,该函数大体完成两个任务:①在进程地址空间中找到内存区对象声明大小的空闲地址范围,建立一个与该地址范围对应的VAD对象之后,将该VAD对象中的ControlArea指向在CreateSection函数中创建的控制区对象,并将该VAD节点插入到进程的VAD树中;②针对该地址范围,设置相应页表中的内容

为段对象中的SegmentPteTemplate值。如果该内存区对象是使用页面文件支撑的,则仅设置保护属性。

在调用过MiMapViewOfDataSection函数后,系统就已经完成了从虚拟地址到物理内存再到文件的映射,应用程序就可以通过访问内存的方式来访问文件或者共享内存,其结构如图3所示。

026.png

图3MapViewOfFile函数所做的工作

4.原型PTE的使用

在前面的内容我们经常提到原型PTE,下面我们看看原型PTE的庐山真面目!可以说原型PTE是实现共享内存最根本的机制。一个原型PTE可以描述6种状态的页面:

①有效。对应的页面位于物理内存中,此时原型PTE已经是一个有效的PTE。

②位于页面文件中,对应的页面位于页面文件中。

③位于映射文件中,对应的页面位于映射文件中。

还有三种状态的页面分别是:要求零页面,转移页面,已修改但不写出页面。前面提到,在段对象中包含了内存区对应页面的原型PTE阵列,当进程访问该内存区对象中的页面时,内存管理器将页面对应的原型PTE中的内容填充到对应的页表PTE中,下面不妨用例子来说明。

阶段1:

为了表达更清楚,我们将进程A中P1页面的PTE定义为PTE1,进程B中P1页面的PTE定义为PTE2。

假设进程A和进程B共享一个内存区对象,而该内存区包含一个页面P1,目前该页面还没有被访问过,所以进程A和进程B中对应页面的PTE是无效的,并且都指向段对象中的P1的原型PTE,而该原型PTE指向页面文件中的页面P1,如图4所示。

图4原型PTE使用的例子,阶段1

027.png

当一个共享页面无效时,进程页表中的页表项由一个特殊的页表项来填充。这个特殊的页表项指向描述该页面的原型页表项,此时PTE1和PTE2中的格式如图5所示,其中有效位为0表示这是一个无效的PTE,原型位为1,表示这是一个指向原型PTE的PTE,两段原型PTE地址组成0~27共28位的地址,因为每个PTE是四个字节,所以28位的地址可以用来描述30位的空间。因为段对象在系统换页池中分配,那么原型PTE都在换页内存池中,因此图5中原型PTE的地址指的是该原型PTE相对于系统换页内存池起始位置的偏移。

028.png

图5指向原型PTE的无效PTE

为了将图5所示结构中的原型PTE地址转换为虚拟内存地址,需要将其进行转换,转换方法如图6所示。

将上图右移11位再左移9位得到原型PTE地址7—27位将上图左移24位再右移23位得到

029.png

图6PTE转换为虚拟地址(偏移)

因此,我们可以看到WRK中会有以下转换宏:


#define
MiPteToProto(lpte)(PMMPTE)((PMMPTE)((((lpte)->u.loong)>>11<<9)+\((((lpte)->u.long))<<24)>>23)+MmProtopte_Base))
其中MmProtopte_Base定义为:#defineMmProtopte_Base((ULONG)MmPagedPoolStart)


实际上也就是换页内存池的开始位置。

阶段2:

当进程A访问该页面时,发生访问违例,系统将页面P1倒入内存,并将其页面号赋予段对象中的指向P1的原型PTE,同时将此原型PTE赋予PTE1,如图7所示;此时PTE1和段对象中的原型PTE的内容是一致的,都是有效的PTE,而PTE2则仍然是一个如图5所示的结构。

030.png

图7原型PTE使用的例子,阶段2

阶段3:

当进程B访问此页面时,依然会发生访问违例,发现指向此页面的PTE是个原型PTE(由原型位为1,valid位为0),并且页面已经导入内存,那么就直接将段对象中的对应的原型PTE赋予PTE2,从而快速的实现页面共享。为了跟踪每个共享页面的使用情况,在物理页面对应的帧号数据库中记录了该页面被几个进程共享,当一个共享页面已经不再被任何页表引用,内存管理器会将这个页面标记为无效,并将其移到转换链表或写回外存。如图8所示。

031.png

图8原型PTE使用的例子,阶段3

另外如在图7的状态下,内存区对象中的一个页面从有效变成无效时,它的硬件PTE将直接指向原型PTE,恢复图4中的状态。

小结

共享内存不仅应用于进程间内存共享,也用于将文件映射到进程的地址空间,从而实现文件的快速访问。本文讲解了共享内存的实现机制,重点是要理解CreateFileMapping函数和MapViewOfFile函数所做的工作以及原型PTE的原理。

(完)