环境: win7 x86
一、 概要
前面的文章写了,要实现一个exploit的编写,分析的方向就要放在漏洞点的下半部分,
本文写的是通过IOCL对象进行的池溢出,参考的是b2ahex的文章,因为别的都是利用是BITMAP进行布局。
二、 自己对Exploit的理解
要写一个Exploit,首先就要梳理以下几点:
1. 目前该漏洞的利用方向是什么(好像大多数都是提权)。
2. 根据利用方向再寻找利用的位置,以及该位置的元素(参数)是否可控。
因为CVE-2018-8120的利用点是任意读写,最大利用就是提权。因此我们从提权的角度开始研究。
1. 首先要明白的事情是何为提权?
用语言描述就是权限提升,从普通用户权限提升到了管理员权限。那么对应到代码的世界是什么呢?其实就是cs、ss、EIP和ESP(也诠释了各种门的设计)(微观角度),如果在加上一条那便是ACL机制-常用就是TOKEN)(宏观角度)的控制。
举个列子:
从应用层代码到了内核层,但是由于通过漏洞我们控制了cs、ss、EIP和ESP,从而执行了我们预先安排好的代码,创建了CMD进程,并进行了token的替换,在用户层使用whoami指令查看用户,就会发现是管理员权限。
(1) 从上面自己的看法中,因此编写这个程序分为两点去考虑。
① 微观的把控,其实就是寻找可以任意读写的位置,通过这个位置,执行指定位置的shellcode。
② 宏观的把控,修改ACL的规则,常用的就是替换Token。
(2) 通过中断门实验看待这2点,我觉得可以得出,宏观的把控是为了上层更好的利用,以及简便的考虑,其实微观的把控才是最重要的。因为中断门就是把控了微观的角度,从而让R3某个函数具有R0的权限。
2. 简而言之,exploit就是对微观和宏观控制的诠释。
三、 分析
1. 找到可以利用的位置
从漏洞点的位置出发,可以看到敏感函数 qmemcpy,那么它是否就是利用点呢?还要看它的参数是否能被我们控制。
从上图不难看出,它的第一个参数V4的来源于V3,V2的参数来源于这个函数本身的参数。于是焦点就变成了这个函数的两个参数是否可控,那便就要看上一层。
这个函数的v2来源于GetProcessWindowStation函数,对于分析过这个漏洞的人,应该知道这个是可控的,而V4来源于V1。此时我们可以可以断定qmemcpy就是利用位置。
2. 顺序分析注意的点
为什么要进行这一步呢?这是因为为了保证构造的数据要走到漏洞利用的位置。
由上图可知,参数1+0x14(为NULL)+0x2c(填充目的位置)+0x48(为NULL),满足这几个条件才可以走到我们的利用点的位置。
3. 总体流程图
由于可以控制读写的位置,所以控制这个位置到池的位置,覆盖池的某些位置来达到触发shellcode代码。
其实主要是为了溢出池中对象头的TypeIndex。通过构造TypeIndex=0(这里涉及到使用零页面),通过对象执行某些回调函数,来达到执行shellcode的目的。
常见的方法就是通过closehandle来执行相应的回调。
四、 代码编写思路
采用的方式是池溢出:
池喷射最重要的就是构造池的布局,为了稳定性,可以选择泄漏内核地址(NtQuerySystemInformation)
其次池的布局已经构造完毕,但是由于只能拷贝0x15c个字节,所以我们要计算从相邻的空闲池的哪个位置开始复制。
推导公式:
开始复制的地址 +0x15c = 泄露的内核地址 -0xC +1(+1是为了将这个地址重写了,而不是刚好到这里)
公式: 开始复制的地址 = 泄露的内核地址 - 0xC +1 - 0x15c = 泄露的地址-0xC-0x15b
由于要覆盖 POOL_HEADER + OBJECT_HEADER_QUOTA_INFO + OBJECT_HEADER(中的TypeIndex) ,计算大小为 0x23个字节, 因此需要构造UserBuffer最后0x23 个字节。
代码见附件1
五、 BUG调试
刚开始我的代码是构造了一个裸函数来进行残根函数调用,但是总是堆栈总是会少一个值,需要自己push一下,才可以堆栈平衡。这是因为裸函数外部也会实现一个 add esp,4。
后来我也尝试了一下b2ahex的方法,但是发现他会push两次,比较好奇,我尝试了push一次,那么就直接BSOD。为什么会导致这个问题呢?其实可以通过分析系统写的函数来观察这个堆栈。
以ZwOpenProcess为例:
堆栈是这样的:
但是如果少压入一个值的话,堆栈就会变成:
其实这样的堆栈只会影响R0拷贝R3的参数:
在KiFastCallEntry的时候会将参数拷贝过去,但是会从残根函数返回地址+8(esp)的位置进行拷贝,如果我们不压入一个值,那么拷贝的就是随机值,就会导致蓝屏。
验证这个结论:
这是拷贝一个参数的过程。
从下图可以看出来,esp+8,才等于参数。
从上面的KiFastSystemCall函数可以看出,进入内核给edx备份一下esp,方便后续使用edx进行拷贝参数。此时的esp指向残根函数的返回地址。
通过分析,我们会发现进行拷贝的时候会将edx+8,这也告诉我们微软在设计的时候,就指定了R3进入内核前存放参数的位置。
通过源码再探池管理机制
如果想了解内核池的管理和分配,那么就要分析一下InitializePool、MiAllocatePoolPages和ExAllocatePoolWithTag的函数了。
由于ExAllocatePoolWithTag比较长,后续给出完整的流程。这个函数主要就是网上写的分配的流程,先从Lookaside、再ListHead,其次才分配大页。
先看InitializePool初始化池的函数。
此函数为指定的池类型初始化池描述符。一旦初始化,该池可用于分配和释放。
在系统初始化期间,应针对每种基本池类型调用一次此函数。
每个池描述符包含一个用于空闲块的列表头数组。每个列表头保存的块是POOL_BLOCK_SIZE的倍数。列表[0]上的第一个元素将大小为POOL_BLOCK_SIZE的空闲条目链接在一起,第二个元素[1]将POOL_BLOCK_SIZE * 2,第三个POOL_BLOCK_SIZE * 3等的条目链接在一起,最多可容纳一个页面的块数。
首先会计算PoolTrackTableSize的大小,获取的办法有两种,一种是注册表指定,一种是使用默认的值。
计算完大小后,就为其分配内存空间。
其次会使用Hash算法为这块内存初始化tag标签。
接下来,使用同样的算法,申请PoolBigPageTable表,PoolBigPageTable申请后,会有一个初始化过程。也就是将其中va成员置为0x1,以表示空闲。
其次插入到PoolTrackTable中,这个表通过分析,其实就是用来记录非分页和分页池的使用情况。也可以通过结构观察到其用途。
最后一步就是初始化非分页池的描述符,用来管理非分页池的使用情况。
总结一下:
InitializePool函数就是先初始化了一个空间用来管理各种池的分配情况,接下来就是初始化了PoolBigPageTable结构,最后就是初始化了非分页的描述符。PoolBigPageTable结构的用途目前还不清楚。
MiAllocatePoolPages函数的分析:
这个函数主要是用来分配池页面的。
首先会对输入的大小进行向上取整并+1。
BASE_POOL_TYPE_MASK = 1,说明了 0 和 1才是基础的池类型,同时也验证,只有非分页池和分页池才是基础类型。
当要分配的页面小于1(页)时,首先是从单链表中获取。
如果需要的空间 大于4096(1页),那么就在空闲的非分页池链表中分配,
MmNonPagedPoolFreeListHead链表有四个,要从哪个链表中开始分配是由 需要的页数决定的。(注意:这里是开始分配)
找到指定的链表后,判断其大小,如果满足就开始进行分配
得出的结构就是 这个池的 开始地址为 0x825c900。
从这个算法可以看出,池页面是从最后一个地址开始分配的(也可以反向推导),分配完之后有一个摘链和插链的操作。这一步操作其实是调整链表,因为4个链表是1、2、3为同一个类型,分配对应的大小,当大于等于4时,就会从最后一个链表分配。所以有种情况就是当第4个链表分配后所剩的页数不足4页时,那么就要将其插入到前面的三个链表中。
分配完毕后,调整一下记录空闲非分页池的全局变量。
虚拟地址分配了也要在物理地址(PFN)标识一下,这里可以看出在PFN中标识了起始分配置位,以及结束分配位置。
总结一下:
MiAllocatePoolPages函数就是找到空闲的结点,通过这结点计算分配的位置,最后在物理地址(PFN)上标记一下。