内存寻址原理

在做网络安全事件分析的时候,都会遇到内存寻址的知识,例如上次跟大家分享的《 空指针漏洞防护技术》,就涉及到非法访问内存地址的问题。如果这个坎儿迈不过去,你就会迷失在代码中,更无从分析了。今天绿盟科技的安全技术专家就讲讲这个内存寻址的原理,文章分为上下两篇《内存寻址原理》及《内存寻址方式》。

随着信息化发展和数据处理能力需求的提高,对计算机硬件产品的性能和容量也提出了新的挑战,要求计算机处理能力也要能随实际情况需求的变动而提升、改变。

当下,一台普通的电脑硬盘容量也要200多G,内存也有4G;如此大容量的硬盘和内存,在处理大量数据或是大型游戏面前还是显得力不从心,需要通过扩容来满足需求,比如将内存由4G提升到8G或是16G不等。扩容后对个人体验确实提升不少。对于内存容量的提升需要有相应的硬件基础支撑,需要有能消化掉这么多内存的寻址地址。比如说如果一8位单片机如果要装载16G的内存,那就是暴殄天物。

哪里有需求哪里就有市场 ;计算机从8位的51单片机,20位8086寻址,发展到32位 win2003,64位win10,都是由于信息化需求的膨胀推动着计算机一代又一代的改革创新。

对于内存的扩容,很多人都不是很清楚应用程序如何使用的物理内存地址。远了不说,单说现在常用计算机中的32位、64系统;系统是怎么样将虚拟地址转化成线性地址,线性地址又是怎样转换成物理地址的,其中又用到了哪些寄存器或是数据结构,相信很多人对此也是一知半解;也像我一样,想结合实例从地址转换的本质来掌握其精髓之处。接下来就一起学习从逻辑地址到物理地址的整个转换过程。

1.实模式与保护模式简介

CPU常见三种工作模式:实模式与保护模式,虚拟8086模式。

实模式 :CPU复位(reset)或加电(power on)的时候以实模式启动,处理器以实模式工作。在实模式下,内存寻址方式和8086相同,由16位段寄存器的内容乘以16(10H)当做段基地址,加上16位偏移地址形成20位的物理地址,最大寻址空间1MB。在实模式下,所有的段都是可以读、写和可执行的。实模式下没有分段或是分页机制,逻辑地址和物理地址相等。

由此得知:

  1. 在实模式下最大寻址空间时1M,1M以上的内存空间在实模式下不会被使用。
  2. 在实模式所有的内存数据都可以被访问。不存在用户态、内核态之分。
  3. 在BIOS加载、MBR、ntdlr启动阶段都处在实模式下。

保护模式 :对于保护模式大家并不陌生;是目前操作系统的运行模式,利用内存管理机制来实现线性地址到物理地址的转换,具有完善的任务保护机制。

保护模式常识:

  1. 现在应用程序运行的模式均处于保护模式。
  2. 横向保护,又叫任务间保护,多任务操作系统中,一个任务不能破坏另一个任务的代码,这是通过内存分页以及不同任务的内存页映射到不同物理内存上来实现的。
  3. 纵向保护,又叫任务内保护,系统代码与应用程序代码虽处于同一地址空间,但系统代码具有高优先级,应用程序代码处于低优先级,规定只能高优先级代码访问低优先级代码,这样杜绝用户代码破坏系统代码。

虚拟8086 模式: 简称V86模式是运行在保护模式中的实模式,为了在32位保护模式下执行纯16位程序。可以把8086程序当做保护模式的一项任务来执行。虚拟8086允许在不退出保护模式的情况下执行8086程序。

虚拟8086常识:

  1. 寻址的地址空间是1M字节.
  2. 可以在虚拟8086模式下运行16位DOS程序。
  3. 在V86模式下,代码段总是可写的;这与实模式相同,同理,数据段也是可执行的。
  4. 32系统编写V86模式的程序:

2. 保护模式寻址基础知识

接下来就以32位系统为例,介绍保护模式下,内存中一些地址转换相关的寄存机和数据结构。

2.1 内存地址概念

逻辑地址 :在进行C语言编程中,能读取变量地址值(&操作),实际上这个值就是逻辑地址,也可以是通过malloc或是new调用返回的地址。该地址是相对于当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,CPU不进行自动地址转换)。应用程序员仅需和逻辑地址打交道,而分段和分页机制对一般程序员来说是完全透明的,仅由系统编程人员涉及。应用程序员虽然自己能直接操作内存,那也只能在操作系统给你分配的内存段操作。一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。

线性地址 :是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。

物理地址(Physical Address) 是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了,比如在实模式下。

2.2 虚拟地址,线性地址,物理地址关系

对于保护模式下地址之间的转换,对程序员来说是透明的。那么物理内存通过内存管理机制是如何将虚拟地址转换为物理地址的呢?当程序中的指令访问某一个逻辑地址时,CPU首先会根据段寄存器的内容将虚拟地址转化为线性地址。如果CPU发现包含该线性地址的内存页不在物理内存中就会产生缺页异常,该异常的处理程序通过是操作系统的内存管理器例程。内存管理器得到异常报告后会根据异常的状态信息。特别是CR2寄存器中包含的线性地址,将需要的内存页加载到物理内存中。然后异常处理程序返回使处理器重新执行导致页错误异常的指令,这时所需要的内存页已经在物理内存中,所以便不会再导致页错误异常。

2.3 段式机制及实例分析

前面说到在线性地址转换为物理地址之前,要先由逻辑地址转换为线性地址。系统采用段式管理机制来实现逻辑地址到线性地址的转换。保护模式下,通过”段选择符+段内偏移”寻址最终的线性地址。

CPU的段机制提供一种手段可以将系统的内存空间划分为一个个较小的受保护的区域,每个区域为一个段。相对32位系统,也就是把4G的逻辑地址空间换分成不同的段。每个段都有自己的起始地址(基地址),边界和访问权限等属性。实现段机制的一个重要数据结构就是段描述符。

下面是个程序实例中显示除了各个段的值:

图中给出了代码段CS,堆栈段SS,数据段DS等段寄存器的值;从得到的值可知,SS=DS=ES是相等的,至于为什么有些段的值相等,后面会说到。 以实例中给出的地址0x83e84110 为例,哪里是段描述符,哪里是段内偏移, 又是如何将该逻辑地址转换为线性地址的呢?相信很多人都迫不及待的想知道整个转换过程,接下来就要看看逻辑地址到线性地址详细转换过程。

上面说到段式管理模式下有段选择符+段内偏移寻址定位线性地址,其实际转换过程如下图所示

从图中可知,逻辑地址到线性地址的转换,先是通过段选择符从描述符表中找到段描述符,把段描述符和偏移地址相加得到线性地址。也就是说要想得到段描述符需要三个条件:

  1. 得到段选择符。
  2. 得到段描述符表
  3. 从段描述符表中找到段描述符的索引定位段描述符。

前面我们提到了段描述符 + 偏移地址,并没有提段选择符和段描述符表。所以我们要弄清楚这几个观念段选择符,段描述符表,段描述符,以及如何才能得到这几个描述符?

2.3.1 段描述符基础知识

从上图可知,通过段选择符要通过段描述符表找到段描述符,那么段描述符表是什么,又是怎么得到段描述符表呢?

在保护模式下,每个内存段就是一个段描述符。其结构如下图所示:

图中看出,一个段描述符是一个8字节长的数据结构,用来描述一个段的位置、大小、访问控制和状态等信息。段描述符最基本内容是段基址和边界。段基址以4字节表示(图中可看出3,4,5,8字节)。4字节刚好表示4G线性地址的任意地址(0×00000000-0xffffffff)。段边界20位表示(1,2字节及7字节的低四位)。

2.3.2 段描述符表实例解析

在现在多任务系统中,通常会同时存在多个任务,每个任务会有多个段,每个段需要一个段描述符,段描述符在上面一小节已经介绍,因此系统中会有很多段描述符。为了便于管理,需要将描述符保存于段描述符表中,也就是上图画出的段描述符表。IA-32处理器有3中描述符表:GDT,LDT和IDT。

GDT是全局描述符表。一个系统通常只有一个GDT表。GDT表也即是上图中的段描述符表,供系统中所以程序和任务使用。至于LDT和IDT今天不是重点。

那么如何找到GDT表的存放位置呢?系统中提供了GDTR寄存器用了表示GDT表的位置和边界,也就是系统是通过GDTR寄存器找到GDT表的;在32位模式下,其长度是48位,高32位是基地址,低16位是边界;在IA-32e模式下,长度是80位,高64位基地址,低16位边界。

位于GDT表中的第一个表项(0号)的描述符保留不用,成为空描述符。如何查看系统的GDT表位置呢?通过查看GDTR寄存器,如下图所示

从上图看出GDT表位置地址是0×8095000,gdtl值看出GDT边界1023,总长度1024字节。前面知道每一项段描述符占8字节。所以总共128个表项。图中第一表项是空描述符。

2.3.3 段选择符结构

前面我们介绍了段描述符表和段描述符的格式结构。那么如何通过段选择符找到段描述符呢,段选择符又是什么呢?

段选择符又叫段选择子,是用来在段描述符表中定位需要的段描述符。段选择子格式如下:

7

段选择子占有16位两个字节,其中高13位是段描述在段描述表中的索引。低3位是一些其他的属性,这里不多介绍。使用13位地址,意味着最多可以索引8k=8192个描述符。但是我们知道了上节GDT最多128个表项。

在保护模式下所有的段寄存器(CS,DS,ES,FS,GS)中存放的都是段选择子。

2.3.4 逻辑地址到线性地址转换实例解析

已经了解了逻辑地址到虚拟地址到线性地址的转换流程,那就看看在前面图中逻辑地址0x83e84110对应的线性地址是多少?

首先,地址0x83e84110对应的是代码段的一个逻辑地址,地址偏移已经知道,也就是段内偏移知道,通过寄存器EIP得到是0x83e34110。段选择符是CS寄存器CS=0008,其高13位对应的GDT表的索引是1,也就是第二项段描述符(第一项是空描述符)。GDT表的第二项为标红的8个字节

通过段描述的3,4,5,8个字节得到段基址。

如上图所示第二项段描述符的3,4,5,8字节对应的值为0×00000000。由此我们得到了段机制和段内偏移。最后的线性地址为段基址+段内偏移=0×0+0x83e34110=0x83e34110。

由此我们知道在32系统中逻辑地址就是线性地址。

其实通过观察其他的段选择子会发现,所有段选择子对应的基地址都是0×0,这是因为在32系统保护模式下,使用了平坦内存模型,所用的基地址和边界值都一样。既然基地址都是0,那么也就是线性地址就等于段内偏移=逻辑地址。

总之:

  1. 段描述符8字节
  2. GDTR是48位
  3. 段选择子2个字节。

2.4 页式机制及实例分析

前面介绍了由逻辑地址到线性地址的转换过程,那么接下来就要说说地址是如何将线性地址转为物理地址。需要先了解一些相关的数据结构。

前面说到如果CPU发现包含该线性地址的内存页不在物理内存中就会产生缺页异常,该异常的处理程序通过是操作系统的内存管理器例程。内存管理器得到异常报告后会根据异常的状态信息。特别是CR2寄存器中包含的线性地址,将需要的内存页加载到物理内存中。然后异常处理处理返回使处理器重新执行导致页错误异常的指令,这时所需要的内存页已经在物理内存中,所以便不会再导致也错误异常。

32位系统中通过页式管理机制实现线性地址到物理地址的转换,如下图:

2.4.1 PDE结构及如何查找内存页目录

从上图中我们知道通过寄存器CR3可以找到页目录表。那么CR3又是什么呢?在32系统中CR3存放的页目录的起始地址。CR3寄存器又称为页目录基址寄存器。32位系统中不同应用程序中4G线性地址对物理地址的映射不同,每个应用程序中CR3寄存器也不同。也就是说每个应用程序中页目录基址也是不同的。

从上图知道页目录表用来存放页目录表项(PDE),页目录占一个4kb内存页,每个PDE长度为4字节,所以页目录最多包含1KB。没启用PAE时,有两种PDE,这里我们只讨论使用常见的指向4KB页表的PDE。

页目录表项的高20位表示该PDE所指向的起始物理地址的高20位,该起始地址的低12位为0,也就是通过PDE高20位找到页表。由于页表低12位0,所以页表一定是4KB边界对齐。 也就是通过页目录表中的页目录表项来定位使用哪个页表(每一个应用程序有很多页表)。

以启动的calc程序为例,CR3寄存器是DirBase中的值,如下图

Calc.exe程序对应的CR3寄存器值为0x2960a000,下面是对应PDT结构

2.4.2页表结构解析

页表是用来存放页表表项(PTE)。每一个页表占4KB的内存页,每个PTE占4个字节。所以每个页表最多1024个PTE。其中高20位代表要使用的最终页面的起始物理地址的高20位。所以4KB的内存页也都是4KB边界对齐。

内存寻址原理,首发于博客 – 伯乐在线

空指针漏洞防护技术(提高篇)

在《空指针漏洞防护技术-初级篇》中我们介绍了空指针及空指针漏洞的概念,在这次高级篇中介绍空指针利用及相应的防护机制。

1 提高篇之:空指针的利用

前面主要介绍了空指针的一些概念和相关的知识,了解了什么是空指针,对于由野指针导致的空指针漏洞不是今天的重点。接下来主要就针对指向零页内存的空指针漏洞做详细的介绍。

此类漏洞利用主要集中在两种方式上:

  1. 利用NULL指针。
  2. 利用零页内存分配可用内存空间

对于第一种情况可以利用NULL指针来绕过条件判断或是安全认证。比如X.0rg空指针引用拒绝访问漏洞(CVE-2008-0153 ),如下图对比修改补丁前后的对比:

从代码补丁可以看出该漏洞利用NULL指针改变程序流程来触发漏洞。

针对第二种情况,在某些情况下零页内存也是可以被使用,比如下面两种情况:

  1. 在windows16系统或是windows16虚拟系统中,零页内存是可以使用的;在windows 32位系统上运行DOS程序就会启动NTVDM进程,该进程就会使用到零页内存。
  2. 通过ZwAllocateVirtualMemory等系统调用在进程中分配零页内存(win7系统之前)。

接下来结合ZwAllocateVirtualMemory API函数的调用直观感受在win7与win8系统中零页内存分配的差异。

1.1 ZwAllocateVirtualMemory基本介绍

zwAllocateVirtualMemory函数在指定进程的虚拟空间中申请一块内存,那是不是只要在内存申请空间就会调用zwAllocateVirtualMemory函数呢?调用zwAllocateVirtualMemory需要根据实际的情况。

堆的分配、使用、回收都是通过微软的API来管理的,最常见的API是malloc和new。在底层调用是HeapAlloc,同时通过HeapCreate来创建堆。对zwAllocateVirtualMemory函数的调用情况是:

. HeapCreate->RtlCreateHeap->ZwAllocateVirualMemory,这里会直接申请一大块内存,至于申请多大的内存,由进程PEB结构中的字段决定,HeapSegmentReserve字段指出要申请多大的虚拟内存,HeapSegmentCommit指明要提交多大内存。

图中看出默认申请大小是0×100000,默认提交大小是0×2000。下图是利用Heapcreate函数调用zwAllocatevirtualMemory时的函数调用栈关系。

图中展示了函数的调用过程。

  1. HeapAlloc->RtlAllocateHeap-> ZwAllocateVirualMemory,这里的内存申请是由Heapcreate已经提交的内存中申请。堆管理器从这片内存中划分一块出来以满足申请的需要,仅当申请的内存不够的时候,才会再次调用ZwAllocateVirualMemory函数。 **也就是说,我们平时使用**** malloc ****或是**** new *\*在堆上申请一块小的内存是不会调用该函数的。** 这点大家要注意。下图中展示了利用HeapAlloc调用ZwAllocateVirualMemory的过程:

c. 直接利用zwAllocateVirtualMemory分配内存,对于zwAllocateVirtualMemory函数,微软没有给出公开的文档,但是可以通过相关资料或是逆向来了解该函数的使用方式。直接利用zwAllocateVirtualMemory函数分配内存首先加载函数所在模块,同时获取该函数的符号地址。由于该函数提供了比较全面的参数,利用此函数来分配内存空间更加灵活多变。

1.2 ZwAllocateVirtualMemory函数知识

zwAllocateVirtualMemory该函数在指定进程的虚拟空间中申请一块内存,该块内存默认将以64kb大小对齐。

NTSYSAPI NTSTATUS NTAPI **ZwAllocateVirtualMemory** (
IN HANDLE *ProcessHandle*,
IN OUT PVOID **BaseAddress*,
IN ULONG *ZeroBits*,
IN OUT PULONG *RegionSize*,
IN ULONG *AllocationType*,
IN ULONG *Protect*
);

两个主要参数:

返回值

如果内存空间申请成功会返回0,失败会返回各种NTSTATUS码。

从zwAllocateVirtualMemory说明来看,本想利用BaseAddress参数在零页内存中分配空间,但是当BaseAdress指定为0时,系统会寻找第一个未使用的内存块来分配,而不是在零页内存中分配。那么如何才能分配到零页内存呢?

1.3 零页内存分配之实例win7 vs win8

了解了zwallocatevirtualmemory的用法,就结合实例来看看如何利用该函数进行零页内存分配。前面介绍将BaseAdress设置为0时,并不能在零页内存中分配空间,就需要利用其它方式在零页内存分配空间。在AllocateType参数中有一个分配类型是MEM_TOP_DOWN,该类型表示内存分配从上向下分配内存。那么此时指定 BaseAddress为一个 低地址,例如 1,同时指定分配内存的大小 大于这个值 ,例如8192(一个内存页),这样分配成功后 地址范围就是 0xFFFFE001(-8191) 到 1把0地址包含在内了,此时再去尝试向 NULL指针执行的地址写数据,会发现程序不会异常了 。通过这种方式我们发现在0地址分配内存的同时,也会在高地址(内核空间)分配内存(当然此时使用高地址肯定会出错,因为这样在用户空间)。

下面就举个例子看如何在零页分配内存

了解windows编程的情况下,对上面的代码不难理解,获取模块句柄,获取zwAllocateVirtualMemory函数地址,并传递参数调用该函数,其中baseaddress的值为4,参数类型中包含了MEM_TOP_DOWN,即内存由上向下分配。所以如果成功的话,将把零页内存中的地址4-0都会分配出去;函数返回0表示内存分配成功。然后再给0地址赋值打印,对0地址重新赋值后再次打印,查看结果。

为了能对比win7与win8系统运行结果的差异,需要准备两个干净的系统win7和win8,这里采用的都是32位系统。

在win7系统中直接编译运行该程序,运行结果如下

从显示结果来看,利用zwAllocateVirtualMemory函数,确实在零内存页分配了4-0地址的空间,也就是说我们可以利用zwAllocateVirtualMemory在零页内存中成功分配空间。

同时在win8系统中同时编译该程序,如果F5调试运行结果如下图:

或是CTRL+F5非调试运行,其结果如下图:

在win8系统中在调用zwallocatevirtualmemory后,函数返回了非0,返回值为0Xc00000f0。最终没能在零页内存分配空间。也就是说在win8系统中对零页内存做了安全防护,导致在零页地址分配内存时失败。

接下来看看win8系统中到底对NULL Pointer也就是零页内存做了哪些防护。

2 提高篇:windows零页内存防护机制

win8系统对零页内存防护机制通过搜索引擎可以查到: **在**** WIN8 ****系统中利用了内核进程结构**** EPROCESS ****中的**** flags ****字段的**** Vdmallowed *\*标志来判断是否允许访问零页内存。** 但是知其然而不知其所以然不是我们追求的目标。我们要做的就是在不依靠其他文献的条件下通过逆向和动态调试技术来剖析win8对零页内存的防护机制。

2.1 搭建内核调试环境

探索Win8的零页内存保护机制在内核中的实现,首先需要搭建一个能调试内核的环境。利用上面给出的例子结合内核调试来分析安全机制。关于如何搭建内核环境在之前的文章中已经介绍过,这里再累赘一下。

5.1.1 虚拟机及调试环境

搭建内核调试环境,采用双机调试模式,在虚拟机中安装win8系统同时配置win8调试模式。

Vmware安装win8系统就不在详解。系统启动后:

管理员权限打开CMD

按照上面的步骤配置好后,关闭虚拟机,打开win8虚拟机配置选项,删除并行端口,添加串行端口,同时配置串行端口,如下图:

配置好vmware后启动虚拟机进入调试模式,如下图:

5.1.2 配置启动windbg

双机模式调试内核需要配置windbg工具,现在主机中安装windbg;创建一个windbg的快捷方式,并在属性->目标中填入如下内容:

"C:\program file\WinDbg(x64)\windbg.exe" -b -k com:pipe,port=\.\pipe\com_1,baud=115200,resets=0,pipe

Windbg路径根据安装路径不同而调整。

启动windbg,由于我的主机是win7系统如果直接双击windbg快捷方式启动

这是缺少必要的权限,所以在启动windbg快捷方式时,需要以管理员的权限启动,启动完成后就可以和虚拟机中的win8系统进行内核级调试,启动之后的结果如下图:

到此内核调试的基本环境创建好了,为了能够更加直观的查看windows系统函数及调用关系需要为windbg配置符号表,这样windbg可以识别出windows标准的导出符号,同时为了能够更方便的调试nullpointer,也需要加载该程序的符号表。

到此内核调试环境搭建完成。

2.2 用户态及内核态跨栈调试

该部分是动态调试的关键部分,也是注意事项最多的一节。

5.2.1 内核调试用户态程序

在上面配置好环境后,在windbg中运行G命令,启动调试。启动程序后,同时在windbg中按住CRTL+BREAK断到调试器中。此时win8系统处于中断状态,不会再运行任何指令。

调试器断下来

我们知道在win7与win8中调用zwallocatevirtualmemory时由于零页内存保护机制的原因,win8系统不能在零页内存分配空间。那么就从调试nullpointer入手,通过调用zwallocatevirtualmemory调试内核态程序来剖解安全机制。

但是现在windbg处于内核调试状态,查看所有启动的进程

需要切换到nullpointer进程中。加载了nullpointer符号表。查看main函数的反汇编如下图所示:

5.2.2 用户态进入内核态

跟进该函数,最后进入

进入了sysenter指令之前,对windows系统有所了解的话,就知道该函数利用该调用进入快速系统调用也就是说此时将由用户态进入内核态。

SYSENTER用来快速调用一个0层的系统过程。SYSENTER是SYSEXIT的同伴指令。该指令经过了优化,它可以使将由用户代码(运行在3层)向操作系统或执行程序(运行在0层)发起的系统调用发挥最大的性能。

在调用SYSENTER指令前,软件必须通过下面的MSR寄存器,指定0层的代码段和代码指针,0层的堆栈段和堆栈指针:

  1. IA32_SYSENTER_CS:一个32位值。低16位是0层的代码段的选择子。该值同时用来计算0层的堆栈的选择子。

2.IA32_SYSENTER_EIP:包含一个32位的0层的代码指针,指向第一条指令。

3.IA32_SYSENTER_ESP:包含一个32位的0层的堆栈指针。

MSR寄存器可以通过指令RDMSR/WRMSR来进行读写。寄存器地址如下表。这些地址值在以后的intel 64和IA32处理器中是固定不变的。

为了能保证我们调试的**** NT!NtAllocatevirtualMemory ****函数刚好是**** nullpointer ****调用的,需要给**** NT!NtAllocatevirtualMemory ****函数下断点,意思是只在指定进程调用该函数时才断下来。** 待函数断下来后,同时查看函数调用栈如下图 **:

从图中调用栈可知,此时断下来的NT!NtAllocatevirtualMemory刚好是nullpointer引用的内核函数。此时已经进入内核调试状态。

5.3 逆向分析nt!NtAllocateVirtualMemory

进入win8内核调试,就要结合静态分析和动态调试来挖掘有用的信息。首先找到win8内核文件。 **需要注意的是此时分析的是虚拟机中**** win8 ****系统的内核文件,不是**** win7 *\*主机的内核文件。**

5.3.1 NtAllocatevirtualMemory参数确认

在前面的调试中,windbg断在了NT! NtAllocatevirtualMemory函数的入口处。对比NtAllocatevirtualMemory函数在windbg和IDA反编译的结果:

可知,在运行完指令call __SEH_prolog4_GS,后EBP+8为第一个参数,EBP+C为第二个参数,那就看看在调用call __SEH_prolog4_GS后EBP的值,如下图:

第一个参数是进程句柄,本进程句柄刚好是-1,第二个参数基地址指针,之前我们在程序中基地址是4,也就是0×00000004,查看基地址指针的值:

刚好是0×00000004,用户态传入的第三个参数是0,这里的第三个参数也刚好是0。其他参数不在一一列举;也就是是说内核函数NtAllocatevirtualMemory与用户态函数zwallocatevirtualmemory参数是一致的。

5.3.2 查找NtAllocatevirtualMemory 零页内存安全机制

到了最重要的时刻,接下来动态调试NtAllocatevirtualMemory, 一路单步执行,看到eax=0xc00000f0时停下:

从图中可知并EAX来自于ESI的赋值。那就继续往上看,看ESI的值是从哪里来的

从图中看出,在执行test[edi+0C4],1000000h指令后,如果相等就执行了mov esi,0xc00000f0。

5.3.3 确认NtAllocatevirtualMemory零页内存安全机制

前面已经找到了返回0xc00000f0的地方,接下来就要看看条件判断的地方, EDI=0x84b2ac80是EPROCESS进程的地址,查看EPROCESS结果可知:

在结构eprocess偏移0xc4的位置刚好是标志Vdmallowed,该值是0,并且[edi=0xc4] 与上1000000后刚好是零。导致ESI=0xc00000f0,进而导致EAX=0xc00000f0。

**到此基本上已经确认了**** NtAllocateVirtualMemory ****中对**** NULLPage ****的安全机制,就是检查**** EPROCESS ****中的**** VdmAllowed *\*的标志位。**

确定条件判断确定位置

从判断语句来看V76应该是参数中的基地址,V14应该是EPROCESS的地址。看一下这两个值的来源

从上图可知v76就是baseaddress; V14追溯到keGetCurrentThread,在内核模式下FS却指向KPCR(Kernel’s Processor Control Region)结构。即FS段的起点与KPCR结构对齐。看一下KPCR结构偏移124的位置

图中可知偏移124的位置刚好的当前线程的结构地址。通过查看函数KeGetCurrentThread的实现也能证实这一点,如下图:

线程结构地址+128(0×80)的位置刚好是当前进程的内核地址,如下图所示:

之后NtAllocateVirtualMemory函数在将进程结构地址偏移0xC4值与0×1000000做比较判断做安全检查。

到目前为止,NtAllocateVirtualMemory函数对零页内存的保护机制剖析完成。总结一下:

  1. 判断基地址是否小于**** 0×1000000,
  2. 判断内核结构**** EPROCESS ****的**** Vdmallowed ****标志是否为**** 0

5.3.4 查找内核中其他对零页内存保护的函数

通过对NtAllocateVirtualMemory逆向分析和动态跟踪了解了win8中对零页内存的保护机制;那么除了NtAllocateVirtualMemory函数,在内核中是否还有其他的函数也对零页内存进行了安全检查呢?

通过在整个NT内核文件中查找零页内存保护机制,又发现了几个包含零页内存包含的函数,搜索结果如下图:

也就是说在内核文件中除了NTAllocateVirtualMemory外,还有四个函数对零页内存做检测:

  1. MiIsVaRangeAvailable:
  2. MiMapViewOfPhySicalSection
  3. MiMapLockedPagesInUserSpace
  4. MiCreatePebOrTeb

这四个函数不都是导出函数,也就是说在内核编程中有可能我们使用的一些导出函数中内部调用了这四个函数中的某一个,其内部已经做了零页内存检测。

6 总结

本文先是介绍了什么是空指针漏洞,之后对windows系统的零页内存保护机制做了剖析。

空指针漏洞:

对零页内存的安全防护:

  1. 检测分配页是否在零页内存。
  2. **检测**** EPROCESS ****结构中的**** VdmAllowed *\*标志。**

Win8**** 以后的零页内存防护也只是保证了不会在零页内存分配空间,缓解分配零页内存空间来利用漏洞;从上图可知,利用零页内存分配导致的空指针漏洞也只是众多空指针漏洞类型中的一种。通过零页内存保护机制并不能缓解所有的空指针漏洞。

空指针漏洞防护技术(提高篇),首发于博客 – 伯乐在线

空指针漏洞防护技术(初级篇)

安全历史上由于空指针所带来的漏洞及攻击数不胜数,但由于其对利用者的编程能力有要求,对分析及防护者来说有更高的要求,所以国内对空指针漏洞及相关技术的讨论不是很多。今天这篇《空指针漏洞防护技术》,由绿盟科技威胁响应安全专家坐堂讲解,大家可以从中了解空指针漏洞的基础知识,并结合Windows 8的内存防护机制实例,动手实践空指针漏洞的防护技术。

1 背景

指针对于绝大部分的编程人员来说都不陌生,说起C/C++中指针的使用既带来了编程方面的方便;同时对编程人员来说,也是对个人编程能力的一种考验,不正确的使用指针会直接导致程序崩溃,而如果是内核代码中对指针的错误使用,会导致系统崩溃,后果也是相当严重。

一般情况下我们使用指针时,错误用法集中在三个方面:

  1. 由指针指向的一块动态内存,在利用完后,没有释放内存,导致内存泄露
  2. 野指针(悬浮指针)的使用,在指针指向的内存空间使用完释放后,指针指向的内存空间已经归还给了操作系统,此时的指针成为野指针,在没有对野指针做处理的情况下,有可能对该指针再次利用导致指针引用错误而程序崩溃。
  3. Null Pointer空指针的引用,对于空指针的错误引用往往是由于在引用之前没有对空指针做判断,就直接使用空指针,还有可能把空指针作为一个对象来使用,间接使用对象中的属性或是方法,而引起程序崩溃,空指针的错误使用常见于系统、服务、软件漏洞方面。

对于第一和第二种情况,我们可以通过一些代码审计工具在发布之前就能确定导致内存泄露或是野指针存在的地方。比如常见的工具有fority,valgrind等及时发现指针错误引用导致的问题。

对于第三种情况,空指针(Null Pointer)引用导致的错误,依靠代码审计工具很难发现其中的错误,因为空指针的引用一般不会发生在出现空指针然后直接使用空指针情况。往往是由于代码逻辑比较复杂空指针引用的位置会比较远,不容易发现;并且在正常情况下不会触发,只有在特定输入条件下才会引发空指针引用。对于排查此类错误也就更加困难。

本文不会重点讨论内存泄露和野指针的内容,而是通过一些现有的漏洞和实例来分析一下Null Pointer 空指针。从NULL Pointer概念、本质结合静态逆向及内核动态调试技术来了解在win7 32位和win8 32位下系统对Null Pointer处理情况有什么不同;Win8 32位针对Null Pointer添加了哪些防护机制。

本文从浅入深,循序渐进的讲述了NULL Pointer,结合静态逆向分析和内核级动态调试技术深入剖析win8系统对零页内存的保护机制,其中还涉及到了一些内核调试的技巧,对于对NULL Pointer的概念、使用比较模糊的人员值得一读,对于从事安全研究和学习的人员也是巩固、加深、拓展的好素材。

2 空指针由Null Pointer引发的漏洞

首先来看看近年来由于空指针的错误使用导致的系统、服务漏洞:

  • Microsoft windows kernel ‘win32k.sys’本地权限提升漏洞(CVE-2015-1721)(MS15-061)

该漏洞影响了windows Server 2003 SP2和R2 SP2,Windows Vista SP2, Windows Server 2008 SP2 和R2 SP1,Windows7 SP1,Windows8 ,Windows8.1,Windows Server2012 Gold和R2,Windows RT Gold and 8.1 。利用内核驱动程序 win32k.sys漏洞可以提升权限或是引起拒绝访问服务,主要原因是空指针的错误引用导致。

  • PHP空指针引用限制绕过漏洞(CVE-2015-3411)

PHP存在安全漏洞,由于程序多个扩展中缺少路径或某些函数的路径参数的空字节检查,允许远程攻击者利用漏洞可绕过目标文件系统访问限制,访问任意文件。

  • 0rg空指针引用拒绝访问漏洞(CVE-2008-0153 )

X.Org是X.Org基金会运作的一个对X Window系统的官方参考实现,是开源的自由软件。libXfont是一个用于服务器和实用程序的X字体处理库。 X.Org libXfont 1.4.9之前版本和1.5.1之前1.5.x版本的bitmap/bdfread.c文件中的’bdfReadCharacters’函数存在安全漏洞,该漏洞源于程序未能正确处理不能读取的字符位图。远程攻击者可借助特制的BDF字体文件利用该漏洞造成拒绝服务(空指针逆向引用和崩溃),执行任意代码。

  • Paragma TelnetServer空指针引用拒绝服务漏洞(BID-27143)

Pragma TelnetServer是一款远程访问和控制Telnet服务器。Pragma TelnetServer处理协议数据时存在漏洞,远程攻击者可能利用此漏洞导致服务器不可用。TelnetServer服务器对每个入站连接启动一个telnetd.exe进程,该进程在处理TELOPT PRAGMA LOGON telnet选项(138号)期间存在空指针引用,导致进程终止。尽管终止单个进程不会影响其他进程,但终止某些进程会导致拒绝访问服务器。

  • OpenSSL SSLv2客户端空指针引用拒绝服务漏洞(CVE-2006-4343)

OpenSSL是一种开放源码的SSL实现,用来实现网络通信的高强度加密,现在被广泛地用于各种网络应用程序中。OpenSSL的协议实现在处理连接请求时存在问题,远程攻击者可能利用此漏洞导致服务器拒绝服务。SSLv2客户端的get_server_hello()函数没有正确地检查空指针。使用OpenSSL的受影响客户端如果创建了到恶意服务器的SSLv2连接,就会导致崩溃。

  • Linux Kernel空指针间接引用本地拒绝服务漏洞(CVE-2014-2678)

Linux kernel 3.14版本内,net/rds/iw.c中的函数rds_iw_laddr_check在实现上存在本地拒绝服务漏洞,本地用户通过盲系统调用没有RDS传输的系统上的RDS套接字,利用此漏洞可造成空指针间接引用和系统崩溃。

  • ISC BIND named拒绝服务漏洞(CVE-2015-5477)

ISC BIND 9.9.7-P2之前版本、9.10.2-P3之前版本,named存在安全漏洞,远程攻击者通过TKEY查询,利用此漏洞可造成拒绝服务(REQUIRE断言失败及程序退出,指针未初始化)。

此类漏洞还有很多,从给出的几个漏洞来看,空指针漏洞主要是以拒绝服务访问漏洞为主,空指针错误引用自然会到底程序出现错误,严重者会崩溃,从而引起拒绝服务访问。

3 基础篇:空指针验证方式

由空指针的错误引用导致的漏洞,其原理本身很简单:错误引用空指针,导致非法访问内存地址。

那到底什么是空指针漏洞呢?在计算机编程过程中使用指针时有两个重要的概念:空指针和野指针。那么在前面我们提到的空指针漏洞是不是就是我们在编程时所说的空指针呢?要弄清这个问题,首先需要知道编程领域中空指针和野指针分别是什么,还要弄清楚什么是空指针漏洞。

3.1 概念性验证

假如 char p,那么p是一个指针变量,该变量还没有指向任何内存空间,如果p = 0; p = 0L; p = ”; p = 3 – 3; p = 0 * 5; 中的任何一种赋值操作之后(对于 C 来说还可以是 p = (void)0;), p 都成为一个空指针,由系统保证空指针不指向任何实际的对象或者函数。反过来说,任何对象或者函数的地址都不可能是空指针。(比如这里的(void)0就是一个空指针。当然除了上面的各种赋值方式之外,还可以用 p = NULL; 来使 p 成为一个空指针。因为在很多系统中#define NULL (void)0。

3.1.1 内存管理之空指针

空指针指向了内存的什么地方呢?标准中并没有对空指针指向内存中的什么地方这一个问题作出规定,也就是说用哪个具体的地址值(0×0 地址还是某一特定地址)表示空指针取决于系统的实现。我们常见的 空指针一般指向 0 地址,即空指针的内部用全 0来表示 (zero null pointer,零空指针)。

对于NULL能表示空指针,是否还有其他值来表示空指针呢?在windows核心编程第五版的windows内存结构一章中,表13-1有提到NULL指针分配的分区,其范围是从0×00000000到0x0000FFFF。这段空间是空闲的,对于空闲的空间而言,没有相应的物理存储器与之相对应,所以对这段空间来说,任何读写操作都是会引起异常的。

从图中看出,对NULL指针分配的区域有0×10000之多,为什么分配如此大的空间?在定义NULL的时候,只使用了 0×00000000这么一个值,而在表13-1有提到NULL指针分配的分区包含了0×00000000-0x0000FFFF,是不是有点浪费空间了;这是和操作系统地址空间的分配粒度相关的,windows X86的默认分配粒度是64KB,为了达到对齐,空间地址需要从0×00010000开始分配,故空指针的区间范围有那么大。

3.1.2知识拓展之野指针

“野指针”又叫”悬浮指针”不是NULL指针,是指向”垃圾”内存的指针。

“野指针”的成因主要有三种:

1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。例如: char *p = NULL; char *str = (char *) malloc(100);

2)指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。

free和delete只是把指针所指的内存给释放掉,但并没有把指针本身干掉。free以后其地址仍然不变(非NULL),只是该地址对应的内存变成垃圾内存,p成了”野指针”。如果此时不把p设置为NULL,会让人误以为p是个合法的指针。如果程序比较长,我们有时记不住p所指的内存是否已经被释放,在继续使用p之前,通常会用语句if (p != NULL)进行防错处理。很遗憾,此时if语句起不到防错作用,因为即便p不是NULL指针,它也不指向合法的内存块。

用free或delete释放了内存之后,就应立即将指针设置为NULL,防止产生”野指针”。内存被释放了,并不表示指针会消亡或者成了NULL指针。

3)指针操作超越了变量的作用范围。例如不要返回指向栈内存的指针或引用,因为栈内存在函数结束时会被释放,这种情况让人防不胜防,示例程序如下:

另外需要注意的是如果程序定义了一个指针,就必须要立即让它指向一个我们设定的空间或者把它设为NULL,如果没有这么做,那么这个指针里的内容是不可预知的,即不知道它指向内存中的哪个空间(即野指针),它有可能指向的是一个空白的内存区域,可能指向的是已经受保护的区域,甚至可能指向系统的关键内存,如果是那样就糟了,也许我们后面不小心对指针进行操作就有可能让系统出现紊乱,死机了。所以必须设定一个空间让指针指向它,或者把指针设为NULL。

3.2源码级验证

接下来就结合两个小的实验例子看看空指针,及使用空指针的后果。

3.2.1指针使用前期对比

本例是要验证指针变量在初始化前后的状况,下图是一段代码:

代码很简单只是验证指针变量在赋值与未赋值分别指向的空间。代码中要打印的值:

  1. 指针变量初始化之前的地址
  2. 指针变量初始化之前的值
  3. 指针变量初始化之后的地址
  4. 指针变量初始化之后的值

编译之后,运行三次程序看运行的结果:

从三次运行结果来看:

  1. 每次打印指针变量的地址都不同,因为每次运行程序,P指针变量的地址都在变动。
  2. 同一次打印中指针变量在初始化之前与初始化之后的地址没有变化。因为指针变量也是变量,变量在其生命周期中值可以变动,但是地址不会改变。
  3. 每次打印中指针变量在初始化之前的值发生了变化。指针变量的值本身也是指向一块内存地址,在初始化之前该变量不指向任何内存地址,所以该变量的值就是一个随机值(也就是指向随机内存地址)。
  4. 每次打印中指针变量在初始化之后的值没有发生变化,在指针变量初始化之后P=0,所以在内存初始化的指针变量指向了内存地址都为0×00000000的位置。在每次打印中指针变量在初始化之后的值就不会发生变化。

3.2.2指针使用后期对比

针对指针变量在使用完毕后,内存释放之后的情况对比,其源码如下:

代码没什么复杂逻辑,只是想验证一下在指针变量释放后, P=NULL之前的野指针与P=NULL之后的指针状况,打印的内容:

  1. 指针变量P开始的地址与值
  2. 指针变量P在分配内存空间之后的地址与值
  3. 指针变量P在释放内存空间之后的地址与值
  4. 指针变量P在P=NULL之后的地址与值

下图是在编译之后运行的结果:

a. 指针变量在声明之后一直到最后的程序结束,指针变量的地址一直没有变动为0x35f778。 b. 定义指针变量时其值为0×00000000 c. 在申请新的内存之后,指针变量的值为申请内存的地址0x6e7a78 d. 在内存释放后,P变成野指针,但是此时P的值仍然是申请的内存地址0x6e7a78,但是此时P指针已经不能再使用。 e. 在P=NULL,p重新指向了地址0×00000000处。

由此可见,我们在释放指针变量后,指针变量会变成野指针,如果此时引用该指针会出现非法访问,因此需要在释放指针变量后将指针变量指向空。

3.3可视化内存验证

为了能够更加直观的查看指针变量在内存中的变化,下面就结合动态调试的技术看看指针变量在整个使用过程中的变化情况。下面是一段程序代码:

在这里,我们使用OD动态调试器,来看看调用printf函数的情况:

a. 第一条打印语句

printf("p address :%p, value :%08x\n", &p, p);

在调用printf前其内存情况:

可知指针变量P的值为EAX=[EBP-4],P的地址ECX=EBP-4,此时的寄存器值:

调用printf之后的值刚好是这两个值

b.第二条打印语句printf(“p intialized address :%p, value :%08x\n”, &p, p);在调用printf前其内存情况:

可知指针变量P的值为EDX=[EBP-4],P的地址EAX=EBP-4,此时的寄存器值:

image013

调用printf之后的值刚好是这两个值:

c. 第三条打印语句printf(“p free address :%p, value :%08x\n”, &p, p);在调用printf前其内存情况:

可知指针变量P的值为EAX=[EBP-4],P的地址ECX=EBP-4,此时的寄存器值:

image016

调用printf之后的值刚好是这两个值:

d. 第四条打印语句printf(“p ultimate address :%p, value :%08x\n”, &p, p);在调用printf前其内存情况:

可知指针变量P的值为EDX=[EBP-4],P的地址EAX=EBP-4,此时的寄存器值:

调用printf之后的值刚好是这两个值:

3.4 概念总结之空指针漏洞

前面对什么是空指针,什么是野指针做了讲解和验证。

在编程领域的空指针是指向NULL的指针,也就是说指向零页内存的指针叫空指针。对于未初始化的指针,释放内存而未将指针置为NULL和指针指向超出范围的情况称为野指针。那么在第二节中列举的空指针漏洞及未列举的空指针漏洞是不是都是由于引用零页内存导致的呢(比如CVE-2014-2678)?其实不然,有些漏洞是由于引用未初始化的指针或是引用超出范围的指针所导致,而这类漏洞应该说是由于错误的引用了野指针。比如最新的BIND漏洞(CVE-2015-5477):

但是到目前为止还没用听说过哪个漏洞命名为野指针漏洞,而更多的是空指针漏洞。也就是说在计算机安全领域中由空指针或是野指针导致的漏洞统一叫做空指针漏洞。

…未完待续 在下一次《空指针漏洞防护技术-高级篇》中,我们将介绍空指针利用及Windows 8中相应的防护机制。

空指针漏洞防护技术(初级篇),首发于博客 – 伯乐在线