内核池溢出漏洞利用实战之Windows 7篇

一、前言

本文重点围绕HitmanPro独立扫描版(版本3.7.15-build 281)的内核池溢出漏洞(CVE-2017-6008)展开。这个工具是反病毒软件Hitman.Alert解决方案的一部分,并以SophosClean可执行文件的方式集成在了英国公司Sophos的解决方案中。早在2017年2月,Sophos公司就收到了此漏洞的报告并在2017年5月发布3.7.20-Build 286版本更新了补丁。我们使用Ioctfuzzer(https://github.com/Cr4sh/ioctlfuzzer)发现了首次crash。Ioctfuzzer是一款对输入输出请求包(以下简称IRP)进行模糊测试的强大易用的工具,我们利用此工具捕获到了API函数DeviceIoControlFile并利用该函数作为中间人代理。对于收到的每一个IRP包,它都会先行发送几个伪造的IRP包然后再转发原始IRP包。扫描伊始就出现了崩溃,崩溃发生在BAD_POOL_HEADER代码初始化阶段。阅读下文之前,我们强烈建议读者了解一些windows下的IOCTL和IRP知识。MSDN文档提供了大量可以帮助你更好地理解本文的信息。本文将要利用的是64位系统下的情景,这比32位系统下更难利用。

二、详细分析

2.1 崩溃数据分析

首先需要弄清楚BAD_POOL_HEADER错误码的含义,池是内核中动态分配的常见场所,此代码意味着处理池头部时出现了问题。池头是位于块开头的提供块有关信息的结构,如图1所示。

图1 池头结构图

池头很可能已经损坏才导致崩溃,为了验证此设想,我们利用调试器、转储工具还有测试器产生的日志快速找到了有缺陷的IRP包如下:

IOCTL Code: 0x0022e100

Outbuff: 0x03ffe1f4, Outsize: 0x00001050

Inbuff : 0x03ffd988, Insize: 0x00000200

//Device/Hitman Pro 37 [/??/C:/Windows/system32/drivers/hitmanpro37.sys]

这里有几点关键信息:

C:/Windows/system32/drivers/hitmanpro37.sys:处理IRP的驱动程序。由于池损坏导致了崩溃,因此该驱动一定与崩溃有关。

IOCTL Code: 0x0022e100:该IOCTL代码提供了大量信息,稍后会作分析。通过逆向还可以获知以上驱动是如何处理IRP的。

Outsize / Insize:用来在池中分配一些缓冲区,也可能与池损坏有关。

参考MSDN文档,从IOCTL代码中可以得到如下信息:

DeviceType = 0x22

Access = 0x3

Function = 0x840

Method = 0x0

Method 0x0=METHOD_BUFFERED

“对于METHOD_BUFFERED这种传输类型,IRP提供了一个指向位于Irp->AssociatedIrp.SystemBuffer的缓冲区的指针,该缓冲区代表调用DeviceIoControl和IoBuildDeviceIoControlRequest时的输入缓冲区和输出缓冲区,驱动器就在输入输出缓冲区之间传输数据。

对于输入数据,缓冲区大小由驱动器IO_STACK_LOCATION结构中的DeviceIoControl.InputBufferLength参数指定。

对于输出数据,缓冲区大小由驱动器IO_STACK_LOCATION结构中的DeviceIoControl.OutputputBufferLength参数指定。

系统为单个输入/输出缓冲区分配的空间大小是两个值中较大的那个。“

最后,为了弄清楚在正常情况下IOCTL是如何发送的,我们逆向了HitmanPro.exe可执行文件,利用IOCTL代码和逆向工具IDA快速定位到了问题函数。

可见,分配给DeviceIoControl的Outsize和Insize与崩溃数据吻合,这种情况下,IRP管理器分配的系统缓冲区大小在正常情况下至少为0x1050字节。

2.2 逆向驱动器

我们已经掌握了很多崩溃有关的信息,是时候逆向驱动器hitmanpro37.sys来看看IOCTL的句柄了。首先,参照IOCTL代码定位调度IRP的函数,通常它是包括一些switch跳转的庞大函数,还好该驱动器并不大我们很快找到了调度器:

跟踪跳转逻辑,我们找到了处理存在漏洞的IOCTL的函数,IRP提供的SystemBuffer首先被传给IoGetDeviceObjectPointer函数的ObjectName参数:

然后,

非常不错进行到这里了,还记得IOCTL用到的METHOD_BUFFERED方法吗?

“系统为单个输入/输出缓冲区分配的空间大小是两个值中较大的那个。”

这意味着我们完美控制了SystemBuffer的值,驱动器使用硬编码的值0x1050调用memset,如果SystemBuffer值小于0x1050,调用memset会使池损坏进而导致崩溃,这里我们称之为内核池溢出漏洞。虽然这么说,但是我们到目前为止还没有任何办法控制往此缓冲区写入。它被设置为0然后被DeviceObject中的地址和名字填充,这只有管理员权限才能控制得了,因此此漏洞只会导致操作系统崩溃,该漏洞编号是CVE-2017-6007。

2.3 扭转

到此我们并不甘心,又逆向了更多的处理程序,我们随机挑选了一个处理程序,这真的很有趣:

SystemBuffer(我们的输入)参数用在了一个子函数中,如果子函数返回正确的值,一些数据会通过mwmcpy拷贝到SystemBuffer中,此函数的控制码是0x00222000:

DeviceType = 0x22

Access = 0x0

Function = 0x0

Method = 0x0

还是利用了同样的方法:METHOD_BUFFERED。

如果我们足够幸运的话,这里可能会有类似的漏洞出现,然而,驱动器的这部分代码非同寻常:

a.我们没有在可执行程序HitmanPro中找到任何利用控制码0x0022200发送IRP的函数,因此在驱动器的这个位置下断点不会触发任何异常。

b.我们无法确定这个函数的确切功能,但我们找到了一个漏洞,这已经足够啦。

因此,逆向之旅又开始了。处理驱动后写成了如下伪代码:

驱动器利用SystemBuffer提供的句柄获取到FILE_OBJECT,如果FILE_OBJECT空闲就会调用ObQueryNameString来获取FILE_OBJECT指向的文件名并存放在临时缓冲区,然后从临时缓冲区复制文件名到SystemBuffer。

驱动器通过如下参数调用memcpy:

a. dest = SystemBuffer ; 大小由我们控制

b. src = 我们提供的句柄文件名,写入和大小均可控

c. n = src缓冲区的大小;

唯一的限制就是ObQueryNameString函数,该函数是受保护的,如果源太大就不会复制任何内容到目标区域。

由于目标区域是硬编码0x400大小的缓冲区,我们就不能给出大于0x400的文件名,当然,0x400个字节对于利用缓冲区溢出已经足够了。

三、利用

3.1 介绍

既然是内核池溢出漏洞,我们就有很多攻击方式可以利用了。要想利用此漏洞,Tarjei Mandt的文章思路再好不过了,如果你想完全了解下一步发生了什么,它将是你必读的文章。这里我们采用的攻击方式是配额进程指针覆盖,我们选择它是因为这是最优雅的方式之一,32位和64位系统均能实现利用,在此攻击中,我们必须覆盖下一个块的进程指针。

该池头的最后4个字节有一个指向EPROCESS结构的指针,当有池块被释放时,如果PoolType设置了Quota bit,该指针会减小某些与EPROCESS对象有关的值:

a. 该对象的引用计数(一个进程是一个对象)

b. QuotaBlock字段指向的值

减值之前会有一些检查,我们不可以直接利用对象的ReferenceCount,不过可以伪造一个EPROCESS结构,并在QuotaBlock字段设置任意指针以减随机的值(内核空间也可以哦)。

kd> dt nt!_EPROCESS

+0x000 Pcb : _KPROCESS

+0x098 ProcessLock : _EX_PUSH_LOCK

+0x0a0 CreateTime : _LARGE_INTEGER

+0x0a8 ExitTime : _LARGE_INTEGER

+0x0b0 RundownProtect : _EX_RUNDOWN_REF

+0x0b4 UniqueProcessId : Ptr32 Void

+0x0b8 ActiveProcessLinks : _LIST_ENTRY

+0x0c0 ProcessQuotaUsage : [2] Uint4B

+0x0c8 ProcessQuotaPeak : [2] Uint4B

+0x0d0 CommitCharge : Uint4B

+0x0d4 QuotaBlock : Ptr32 _EPROCESS_QUOTA_BLOCK

[...]

typedef struct _EPROCESS_QUOTA_BLOCK {

EPROCESS_QUOTA_ENTRY QuotaEntry[3];

LIST_ENTRY QuotaList;

ULONG ReferenceCount;

ULONG ProcessCount;

} EPROCESS_QUOTA_BLOCK, *PEPROCESS_QUOTA_BLOCK;

3.2 溢出实现

为了实现配额进程指针溢出攻击,我们需要利用我们的溢出覆盖两个东西:

a.下一个块的池类型,因为我们需要确定已经设置了Quota bit

b.下一块的进程指针,用一个指向伪造的EPROCESS结构的指针替换它

因为我们必须获取到下一块的进程指针,所以无论如何必须要覆盖下一块的整个池头,然而我们不能往池头发随机的数据否则会触发BSOD。

我们必须确定如下字段是正确的:

a.块大小

b.前一个块大小

c.池类型

满足此条件的唯一方式是准确获取要覆盖的块,这可以通过池喷射技术来实现。

这里不会详细阐述如何实现池喷射,但基本思路就是获取这种类型的池:

看起来类似这样:

我们的溢出效果:

溢出前:

溢出后:

3.3 Payload

好了,我们可以在任何地址实现减任何值了,下一步做什么呢?我们搜索到了一篇很好的Cesar Cerrudo[4]的文章,文中讲述了几种提权的技术。还有一点也很有趣,在TOKEN结构中有一个Privileges字段:

typedef struct _TOKEN

{

[...]

/*0x040*/ typedef struct _SEP_TOKEN_PRIVILEGES

{

UINT64 Present;

/*0x048*/ UINT64 Enabled;

UINT64 EnabledByDefault;

} SEP_TOKEN_PRIVILEGES, *PSEP_TOKEN_PRIVILEGES;

[...]

}TOKEN, *PTOKEN;

该字段是包含位掩码的结构体,位掩码Enabled定义了进程可执行的操作。该位掩码默认值为0x80000000,具有SeChangeNotifyPrivilege权限,从该位掩码中去掉一位变成了0x7fffffff,就拥有了更大的权限,MSDN文档提供了该位掩码的可用的权限列表:

https://msdn.microsoft.com/fr-fr/library/windows/desktop/bb530716(v=vs.85).aspx

但是我们没有_TOKEN结构的地址,我们也不应该有因为那是内核地址。幸运的是,我们可以利用众所周知的NtQuerySystemInformation通过其句柄获取任何对象的内核地址。还可以通过调用OpenProcessToken()函数为我们的token赋予句柄,如果你想更深入了解NtQuerySystemInformation()函数和常见的内核地址溢出你应该参考这里。

我们决定触发这个漏洞以获取SeDebugPrivilege权限,该权限可以实现控制系统所有进程,你可以获取任何你想要的权限。SeDebugPrivilege权限可以允许我们在系统进程中启动线程并反弹一个系统shell。

四、结论

注意,这个exploit不能在windows 8及更高版本系统中使用,毕竟微软在防御内核漏洞方面做了大量工作。实际上,虽然此exploit不能用在windows 8及更高系统版本上并不意味着这些版本不能被攻破,你可以在github上看到我的exploit源代码,windows 10系统下如何利用类似的漏洞这是Nuit du Hack XV大会的主题。