0x00:前言
本篇文章主要分享HEVD这个Windows内核漏洞训练项目中的Write-What-Where漏洞在win7 x64到win10 x64 1605的一个爬坑过程,Windows内核漏洞的原理比较简单,关键点在于exp的编写,这里我从win7 x64开始说起,看此文章之前你需要有以下准备:
- Windows相应版本的虚拟机
- 配置好windbg等调试工具,建议配合VirtualKD使用
- HEVD+OSR Loader配合构造漏洞环境
如果你不是很清楚这个漏洞的基本原理的话,你可以从我的另一篇文章了解到这个漏洞的原理以及在win 7 x86下的利用,我这里就不多加赘述了
0x01:Windows 7 x64利用
让我们简单回顾一下在Windows 7 x86下我们利用的利用思路和关键代码,全部的代码参考 => 这里
利用思路
- 初始化句柄等结构
- 计算我们需要Hook的地址
HalDispatchTable+0x4
- 调用
TriggerArbitraryOverwrite
函数将shellcode
地址放入Hook地址 - 调用
NtQueryIntervalProfile
函数触发漏洞 - 调用cmd验证提权结果
关键代码
计算Hook地址
1 | DWORD32 GetHalOffset_4() |
调用问题函数执行shellcode
1 | NtQueryIntervalProfile_t NtQueryIntervalProfile = (NtQueryIntervalProfile_t)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtQueryIntervalProfile"); |
总所周知Windows 7 x64是64位的,所以我们很快的就可以想到和32位的不同,所以我们在32位的基础上只需要改一下长度应该就可以拿到system权限了,实际上还是有很多坑的,这里我分享几个我遇到的坑,第一个就是我们的shellcode需要修改,因为是64位,所以偏移都会有改变,但是原理是不会变的
- 当前线程中找到
_KTHREAD
结构体 - 找到
_EPROCESS
结构体 - 找到当前线程的token
- 循环便利链表找到system系统的token
- 替换token
1 | mov rax, gs:[188h] |
Shellcode在64位下的编译
首先第一个就是shellcode如何放置在64位的编译环境下,如果是像32位那样直接在代码中嵌入汇编是行不通的,这里我们需要以下几步来嵌入汇编代码(我使用的环境是VS2019,当然以前的版本也可以)
- 项目源文件中多创建一个ShellCode.asm文件,放入我们的shellcode
1 | .code |
- 右键ShellCode.asm文件,点击属性,生成中排除选择否,项类型选择自定义生成工具
- 在自定义工具里面的命令行和输出填写如下内容
1 | ml64 /c %(filename).asm |
- 在ShellCode.h中申明如下内容,然后在主利用函数中引用即可
1 |
|
shellcode的放置
第二个坑就是shellcode的放置,在x86中我们是如下方法实现shellcode的放置
1 | VOID Trigger_shellcode(DWORD32 where, DWORD32 what) |
因为我们现在是qword
而不是dword
,也就是说我们需要调用两次才能将我们的地址完全写进去,所以构造出如下的片段
1 | VOID Trigger_shellcode(UINT64 where, UINT64 what) |
最后整合一下代码即可实现利用,整体代码和验证结果参考 => 这里
0x02:Windows 8.1 x64利用
好了win7我们已经完成了利用,我们开始研究win8下的利用,首先我们需要了解一些win8的安全机制,我们拿在win7 x64下的exp直接拖入win8运行观察会发生什么,果不其然蓝屏了,我们查看一下在windbg中的分析
1 | *** Fatal System Error: 0x000000fc |
windbg中提示ATTEMPTED_EXECUTE_OF_NOEXECUTE_MEMORY
这个错误,我们解读一下这句话,企图执行不可执行的内存,等等,这不就是我们pwn中的NX保护吗
SMEP
我们详细来了解一下这个保护机制,SMEP保护开启的时候我们用户层的代码不能在内核层中执行,也就是说我们的shellcode不能得到执行
这个时候我们回想一下绕过NX的方法,瞬间就想到了ROP,那么我们现在是要拿ROP帮我们做哪些事情呢?我们看下面这张图,可以看到我们的SMEP标志位在第20位,也就是说我们只需要将cr4寄存器修改为关闭SMEP的状态即可运行我们的shellcode了
ROPgadgets
我们来查看一下我们的cr4寄存器的运行在我的环境下触发漏洞前后的对比
1 | .formats 00000000001506f8 // 开启 |
也就是说我们只需要将cr4修改为0x406f8即可在内核运行我们的shellcode从而提权,那么如何选择我们的ROP呢,我们来观察以下代码片段,可以看到里可以通过rax来修改cr4,那么问题就简单了,我们只需要把rax设为0x406f8不就行了吗,ROPgadgets的计算我们可以通过偏移来查找,首先我们通过前面的知识计算出内核基地址,然后在windbg中用u命令查看KiConfigureDynamicProcessor+0x40
的地址,我们用该地址减去基地址即可得到偏移,有了偏移我们加上基地址就可以得到我们ROPgadgets的位置了
1 | 1: kd> u KiConfigureDynamicProcessor+0x40 |
让我们再次看看我们在win7利用中如何进行Hook的,我们是直接把Hal_hook_address
替换为ShellCode的地址
1 | Trigger_shellcode(Hal_hook_address,(UINT64)&ShellCode); |
我们想要做的是把Hal_hook_address
先替换为我们的ROP,修改了cr4寄存器之后再执行我们的shellcode,这就需要进行多次读写的操作,显然光靠一个Trigger_shellcode
是不够的,这里隆重介绍我们的 BITMAP 对象,这个对象在Windows 8.1中可谓是一个必杀技,用好它可以实现任意读和任意写
BITMAP对象
首先我们需要了解一下这个对象的大致信息,我们直接用CreateBitmap
函数创建一个对象然后下断点进行观察,函数原型如下
1 | HBITMAP CreateBitmap( |
我们构造如下代码
1 | int main() |
这里我们需要用GdiSharedHadnleTable
这个句柄表来泄露我们hBitmap
的地址,先不用管原理是什么,总之我们现在先找到我们Bitmap的位置,可以看到我们通过一系列操作居然找到了我们的Bitmap,其分配在会话池,大小是0x370
1 | 1: kd> r |
让我们理一下这个过程,首先从命令中我们知道GdiSharedHandleTable
是在PEB中,而GdiSharedHandleTable
本身是一个保存GDI对象的句柄表,其指向的是一个叫GDICELL64
的结构,其大小是0x18:
1 | typedef struct{ |
从上面我们可以看到它可以泄露我们内核中的地址,过程就是先计算出函数返回值(rax)的低4字节作为索引,然后乘上GDICELL64
的大小0x18,再加上GdiSharedHandleTable
的地址即可得到我们Bitmap的地址,换成代码实现就是
- 首先找到我们的TEB
- 通过TEB找到PEB
- 再通过PEB找到
GdiSharedHandleTable
句柄表 - 通过计算获得Bitmap的地址
关键实现代码如下
1 | DWORD64 getGdiShreadHandleTableAddr() |
让我们来查看一下Bitmap的结构,我们只需要关注重点的位置就行了
1 | typedef struct{ |
这里我借鉴图片来说明,我们关注的点就只有一个pvScan0
结构,它的偏移是 +0x50 处,可以发现它指向我们的Pixel Data
,这个结构就是我们CreateBitmap
函数传入的第五个参数,也就是说我们传入aaaa,那么pVscan0指向地址的内容就是aaaa
任意读写
我们刚才分析了那么多,说到底都是为了一个目的 => 任意读任意写,那么如何才能任意读和写呢?这里我再介绍两个比较重要的函数SetBitmapBits
和GetBitmapBits
其原型如下
1 | LONG SetBitmapBits( |
这两个函数的作用是向pvScan0指向的地址写(读)cb byte大小的数据,说到这里貌似有一点任意读写的感觉了,光靠一个pvScan0是肯定不能任意读写的,所以这里我们考虑使用两个pvScan0,我们把一个pvScan0指向另外一个pvScan0,我们有TriggerArbitraryOverwrite
函数可以实现将一个pvScan0指向另一个pvScan0,然后我们再调用SetBitmapBits
和GetBitmapBits
函数岂不是就可以进行任意读写了,我们用图片说明:
我们任意读写的代码构造如下,read函数实现将whereRead的内容读到whatValue的位置,write函数实现将whatValue的内容写入whereWrite的位置:
1 | VOID readOOB(DWORD64 whereRead, LPVOID whatValue, int len) |
让我们平复一下激动的心情,我们现在有了任意读和写的机会了,我们只需要将我们的ROPgadgets写入我们需要Hook的位置,然后调用问题函数执行shellcode就行了,这里我们需要注意的是,我们还需要调整调整堆栈的一些信息,不然很容易就蓝屏了,这里我们进行三次读写操作
1 | readOOB(Hal_hook_address, &lpRealHooAddress, sizeof(LPVOID)); // 保存Hook地址 |
整合思路
我们最后整合一下思路
- 初始化句柄等结构
- 内核中构造放置我们的shellcode
- 申请两个Bitmap并泄露Bitmap中的pvScan0
- 调用
TriggerArbitraryOverwrite
函数将一个pvScan0指向另一个pvScan0 - 两次读写实现写入ROPgadgets
- 调用
NtQueryIntervalProfile
问题函数 - 一次写入操作实现还原Hook地址的内容
最后整合一下代码即可实现利用,整体代码和验证结果参考 => 这里
0x03:Windows 8.1 x64的一个坑
首先我们回顾一下我们在上面的利用中可能存在的一个坑
Shellcode的构造
上篇我只是简单提了一下内核中构造放置我们的shellcode,如果你看了我的源码,里面的构造函数如下所示:
1 | VOID ConstrutShellcode() |
你可能会疑惑recoverAddr
这个东西是拿来做什么用的,先不要着急我们在看看我们shellcode的实现:
1 | .code |
从上面可以看到,我在最后的地方用了几句汇编将堆栈平衡了,这其实是我调试了很久才得到的结果,我简单提一下这个过程,首先我们知道我们把shellcode放置在了0x100300的位置,我们还知道我们需要执行我们的ROP,所以我们需要在windbg中下两个硬件断点观察,注意shellcode中不能用int 3下软件断点,这样会修改堆栈的平衡导致一些问题
1 | 1: kd> u nt!KiConfigureDynamicProcessor+0x40 |
我们g运行到第一个断点,t单步到ret处,查看堆栈结构和我们现在rc4寄存器的值,可以发现我们的寄存器已经被修改
1 | 1: kd> g |
我们t单步再次观察堆栈,这里已经开始执行我们的shellcode了
1 | 1: kd> t |
我们继续单步运行到shellcode中sub rsp,30h
的位置,查看堆栈之后继续单步,我们可以看到rsp中内容被修改为了0x010033e,而0x010033e中存放的内容正是我们nt!KeQueryIntervalProfile+0x25
中的值
1 | 1: kd> t |
nt!KeQueryIntervalProfile+0x25
是哪里呢,这个值刚好是我们Hook位置的下一句汇编,我们将其放回原位即可做到原封不动的还原内核函数,这样就可以完美的提权而不蓝屏
1 | 0: kd> u nt!KeQueryIntervalProfile |
0x02:Windows 10 1511-1607 x64下的利用
好了我们整理完了win 8.1下的一些坑我们开始我们在win10中的利用,win8.1中最浪费时间的操作便是堆栈的平衡问题,那我们可不可以有更简单的方法提权呢?当然有的,我们都有任意读写的权限了不是吗,既然有任意读写的权限,那么我们完全可以用任意读写的操作实现对token的替换,我们甚至不用我们的shellcode都可以提权,这种做法非常的简便,并不需要考虑shellcode在内核中运行遇到的堆栈平衡问题,我们的关键点始终还是在泄露pvScan0的地方,我们在win 10 1607和win 10 1511中观察一下我们创建的Bitmap结构,和win 8.1进行比较,构造如下代码片段
1 | int main() |
Win 8.1 x64
1 | 0: kd> dt ntdll!_PEB -b GdiSharedHandleTable @$Peb |
Win 10 1511 x64
1 | 0: kd> dt ntdll!_PEB -b GdiSharedHandleTable @$Peb |
Win 10 1607 x64
1 | 3: kd> dt ntdll!_PEB -b GdiSharedHandleTable @$Peb |
实验中很明显的发现win 10 1607中我们的GdiShreadHanldleTable
已经不是一个指针了,我们来看看有什么升级,图片中说明了已经不能够公开这个句柄表的地址了,那是不是就没办法了呢?
当然不是!我们总能够通过各种方法来泄露我们的 PrvScan0 ,这里就需要引入另外一个比较神奇的结构gSharedInfo
1 | typedef struct _SHAREDINFO { |
其中的 aheList
结构如下,里面就保存了一个 pKernel 的指针,指向这个句柄的内核地址
1 | typedef struct _USER_HANDLE_ENTRY { |
先不管三七二十一,我们先泄露这个东西,再看看和我们的 Bitmap 有什么联系,关键代码如下
1 | LPACCEL lPaccel = NULL; |
运行一下查看结果,确实泄露了什么东西出来
解读一下上面的代码,我们首先创建了一块内存,其中的nSize选择了700的大小,因为后面我们使用CreateBitmap
创建的对象传入的第一个参数是0x710,关于CreateBitmap
中第一个参数width
对生成对象的影响我就不过多阐述了,实验加上官方文档)可以给我们解释,然后我们获取了 user32.dll 中的 gSharedInfo 对象,我们在一个循环里使用 CreateAcceleratorTable 和 DestroyAcceleratorTable 不断创建释放了 hAccel 结构,其中计算的过程和我们泄露bitmap地址的过程类似,这里就会产生一个疑问,这个泄露的东西为什么和我们的 Bitmap 一样呢,要知道我们每次创建释放hAccel时候地址是固定的(你可以多打印几次进行实验),并且这个对象也是分配在会话池(sesssion pool),大小又相等,池类型又相同,如果我们申请了一块然后释放了,再用bitmap申请岂不是就可以申请到我们想要的地方,泄露的地址也就是bitmap的地址了,我们这里为了使得到的地址固定,堆喷射后使用了一个判断语句判断是否得到了稳定的地址,得到之后我们再加上相应的偏移也就是我们的 PrvScan0 了,于是我们构造如下代码片段
1 | LeakBitmapInfo GetBitmap() |
泄露了之后就好办了,也就是只需要替换一个token就行了,我这里用的是read和write函数不断的进行汇编shellcode的模仿,在ring3层实现了对token的替换,这样我们就可以不加入我们的shellcode从而提权,而这种方法也不需要考虑堆栈平衡,非常的方便,其中获取系统的一些信息的时候使用了NtQuerySystemInformation
这个函数,通过它可以给我们提供很多的系统信息,具体的可以参阅官方文档
1 | __kernel_entry NTSTATUS NtQuerySystemInformation( |
最后整合一下思路:
- 初始化句柄等结构
- 通过
gSharedInfo
对象来泄露我们的Bitmap地址 - 调用
TriggerArbitraryOverwrite
函数将一个pvScan0指向另一个pvScan0 - 通过不断的read和write,模拟token的替换,从而提权
最后整合一下代码即可实现利用,整体代码和验证结果参考 => 这里
0x03:Windows 10 后续版本的猜想
RS2
RS2版本中貌似将我们的 pkernel 指针给移除了,也就是说我们不能再通过 gSharedInfo 结构来泄露我们的内核地址了,不过有前辈们用tagCLS
对象及lpszMenuName
对象泄露了内核地址,能够泄露的话其实其他地方都好办了,泄露的方法我这里简单提一下,首先我们需要找到HMValidateHandle
函数的地址,这个函数我们只需要传入一个窗口句柄,他就会返回在桌面堆中的tagWND
对象指针,而通过这个指针我们就可以泄露出内核地址,这个函数地址我们可以通过IsMenu
这个用户态函数获取到,我们来看一下函数的内容,可以看到 call 之后会调用到HMValidateHandle
这个函数,那么我们只需要通过硬编码计算,获取 e8(call) 之后的几个字节地址就行了
1 | kd> u user32!IsMenu |
获取到HMValidateHandle
函数之后我们只需要再进行一系列的计算获取lpszMenuName
对象的地址,我们可以依据下图 Morten 所说的计算过程计算出Client delta
获取到了之后我们只需要和前面一样进行堆喷加上判断就能够泄露出Bitmap的地址,还需要注意的是偏移的问题,需要简要修改,下面是1703的一些偏移
1 | 2: kd> dt nt!_EPROCESS uniqueprocessid token activeprocesslinks |
RS3
RS3版本中 PvScan0 已经放进了堆中,既然是堆的话,又让人想到了堆喷射控制内核池,总之可以尝试一下这种方法
但是前辈们总有奇特的想法,又找到了另外一个对象 platte ,它类似与 bitmap 结构,可以用 CreatePalette
函数创建,结构如下
任意读写的方法只是改为了GetPaletteEntries
和SetPaletteEntries
,以后可以尝试一下这个思路
0x03:后记
利用里面,win8.1的坑比较多,和win7比起来差距有点大,需要细心调试,更往后的版本主要是参阅外国的文献,以后有时间再来实践
参考资料:
[+] SMEP原理及绕过:http://blog.ptsecurity.com/2012/09/bypassing-intel-smep-on-windows-8-x64.html
[+] ROP的选择:http://blog.ptsecurity.com/2012/09/bypassing-intel-smep-on-windows-8-x64.html
[+] Bitmap结构出处:http://gflow.co.kr/window-kernel-exploit-gdi-bitmap-abuse/
[+] wjllz师傅的博客:https://redogwu.github.io/
[+] 参阅过的pdf:https://github.com/ThunderJie/Study_pdf
[+] RS2上的利用分析:https://www.anquanke.com/post/id/168441#h2-3
[+] RS3上 platte 对象的利用分析:https://www.anquanke.com/post/id/168572