目录
漏洞概述
漏洞原理
漏洞复现
windbg 调试本地内核
查看 SSDT 表和 SSDTShadow 表
查看窗口站结构体信息
利用 Poc 验证漏洞
漏洞利用
分配零页内存
构造能够获取 SYSTEM 进程令牌的 shellcode
查找获取 HalDispatchTable 表地址
利用 Bitmap 任意内存读写
Exp 利用漏洞
参考资料
漏洞概述
在 2018 年 5 月, 微软官方公布并修复了 4 个 win32k 内核提权的漏洞, 其中的 CVE-2018-8120 内核提权漏洞是存在于 win32k 内核组件中的一个空指针引用漏洞, 可以通过空指针引用, 对内核进行任意读写, 进而执行任意代码, 以达到内核提权的目的.
漏洞原理
该漏洞的触发点就是窗口站 tagWINDOWSTATON 对象的指针成员域 spklList 指向的可能是空地址, 如果同时该窗口站关联当前进程, 那么调用系统服务函数 NtUserSetImeInfoEx 设置输入法扩展信息时, 会间接调用 SetImeInfoEx 函数访问 spklList 指针指向的位于用户进程地址空间的零页内存.
如果当前进程的零页内存未被映射(事实上零页内存正常是不会被映射的), 函数 SetImeInfoEx 的访问操作将引发缺页异常, 导致系统 BSOD; 同样, 如果当前进程的零页内存被提前映射成我们精心构造的数据, 则有可能恶意利用, 造成任意代码执行的漏洞.
漏洞复现
windbg 调试本地内核
说明: Windbg 是 Microsoft 公司免费调试器调试集合中的 GUI 的调试器, 支持 Source 和 Assembly 两种模式的调试. Windbg 不仅可以调试应用程序, 还可以进行 Kernel Debug.
该工具使得我们可以本地调试 Windows 系统的内核, 但是, 本地调试内核模式不能使用执行命令, 断点命令和堆栈跟踪命令等命令
1, 使用管理员身份打开 cmd, 执行 bcdedit /debug on, 开启调试模式
2, 使用管理员权限打开 windbg(一定是管理员权限, 不然不起作用), 然后依次选择 File->Kernel Debugging->Local->确定
3, 经过上面的设置基本就可以进行相关本地内核调试
查看 SSDT 表和 SSDTShadow 表
在 Windows 操作系统中, 系统服务 (系统内核函数) 分为两种: 一种是常用的系统服务, 实现在内核文件; 另一种是与图形显示及用户界面相关的系统服务, 实现在 win32k.sys 文件中.
全部的系统服务在系统运行期间都储存在系统的内存区, 系统使用两个系统服务地址表 KiServiceTable 和 Win32pServiceTable 管理这些系统服务, 同时设置两个系统服务描述表 (SDT) 管理系统服务地址表, 这两个系统服务描述表 ServiceDescriptorTable(SSDT)和 ServiceDescriptorTableShadow(SSDTShadow)
其中, 前者只包含 KiServiceTable 表, 后者包含 KiServiceTable 和 Win32pServiceTable 两个表, 而且 SDDT 是可以直接调用访问的, SSDTShadow 不可以直接调用访问.
SDT 对象的结构体如下:
- typedef struct _KSYSTEM_SERVICE_TABLE
- {
- PULONG ServiceTableBase; // 系统服务地址表地址
- PULONG ServiceCounterTableBase;
- PULONG NumberOfService; // 服务函数的个数
- ULONG ParamTableBase; // 该系统服务的参数表
- } KSYSTEM_SERVICE_TABLE, *PKSYSTEM_SERVICE_TABLE;
通过 windbg 本地内核调试查看相关系统服务描述表实际结构分布:
分析: 图中显示的是 SDDT 表和 SSDTShadow 表中的结构, 每个表中的两行分别表示系统服务地址表 KiServiceTable 表和 Win32pServiceTable 表的相关数据信息. 因为上面的是 SSDT 表, 不包含 Win32pServiceTable 表, 所以第一个表中第二行数据为空.
结合上面的结构体可以看出, KiServiceTable 的地址是 0x83cbfd9c, 包含 0x191 个系统服务; Win32pServiceTable 的地址是 0x92696000, 包含 0x339 个系统服务.
再查看系统服务地址表存储具体的内容:
分析: 可以看出系统服务地址表中存储的都是四个字节的函数指针, 这些指针指向的就是后面对应的系统服务函数
查看窗口站结构体信息
窗口站是和当前进程和会话 (session) 相关联的一个内核对象, 它包含剪贴板 (clipboard), 原子表, 一个或多个桌面(desktop) 对象等.
通过 windbg 来查看窗口站对象在内核中的结构体实例:
分析: 上图就是窗口站 tagWINDOWSTATION 的结构体的定义, 其中在偏移 0x14 处的 spklList 指针指向关联的键盘布局 tagKL 对象链表首节点
查看键盘布局的结构体定义
分析: 键盘布局 tagKL 结构体中在偏移 0x2c 处的 piiex 指针指向关联的输入法扩展信息结构体对象, 这也是 SetImeInfoEx 函数内存拷贝的目标地址.
当用户进程调用 CreateWindowStation 函数等相关函数创建新的窗口站时, 最终会调用内核函数 xxxCreateWindowStation 执行窗口站的创建, 但是在该函数执行期间, 被创建的新窗口站实例的 spklList 指针并没有被初始化, 指向的是空地址.
## 分析 SetImeInfoEx 函数
说明: 函数 SetImeInfoEx 是一个 win32k 组件中的内核函数, 主要负责将输入法扩展信息 tagIMEINFOEX 对象拷贝到目标键盘布局 tagKL 对象的结构体指针 piiex 指向的输入法信息对象的缓冲区.
IDA 加载 win32k.sys 组件并手动载入符号表
选择
File-->loadfile-->pdbfile
, 然后点击弹出窗口的 OK 选项
在函数框中使用 Ctrl+F 查找 SetImeInfoEx 函数, 并使用 F5 反编译出函数的伪代码
分析: 从上面的伪代码中可以看出, 函数 SetImeInfoEx 首先从参数 a1 指向的窗口站对象中获取 spklList 指针 (a1 是窗口站地址指针, 偏移 0x14 就是 spklList 指针), 也就是指向键盘布局链表 tagKL 首节点地址的指针; 然后函数从首节点开始遍历键盘布局对象链表, 直到节点对象的 pklNext 成员指回到首节点对象为止, 函数判断每个被遍历的节点对象的 hkl 成员是否与源输入法扩展信息对象的 hkl 成员相等; 接下来函数判断目标键盘布局对象的 piiex 成员(偏移 0x2c) 是否为空, 且成员变量 fLoadFlag(偏移 0x48) 值是否为 FALSE, 如果上述两个条件成立, 则把源输入法扩展信息对象的数据拷贝到目标键盘布局对象的 piiex 成员中.
把这段伪代码变得更易读一下~
- BOOL __stdcall SetImeInfoEx(tagWINDOWSTATION *winSta, tagIMEINFOEX *imeInfoEx)
- {
- [...]
- if ( winSta )
- {
- pkl = winSta->spklList;
- while ( pkl->hkl != imeInfoEx->hkl )
- {
- pkl = pkl->pklNext;
- if ( pkl == winSta->spklList )
- return 0;
- }
- piiex = pkl->piiex;
- if ( !piiex )
- return 0;
- if ( !piiex->fLoadFlag )
- qmemcpy(piiex, imeInfoEx, sizeof(tagIMEINFOEX));
- bReturn = 1;
- }
- return bReturn;
- }
至此我们可以看出程序的漏洞: 在遍历键盘布局对象链表 spklList 的时候并没有判断 spklList 地址是否为 NULL, 假设此时 spklList 为空的话, 接下来对 spklList 访问的时候将触发访问异常, 导致系统 BSOD 的发生.
利用 Poc 验证漏洞
从之前的分析中, 我们知道触发漏洞的条件是要将 spklList 指针指向空地址的窗口站关联到进程中.
具体实现就是先通过接口函数 CreateWindowStation 创建一个窗口站, 然后调用 NtUserSetImeInfoEx 函数关联该窗口站和进程(NtUserSetImeInfoEx 系统服务函数会调用 SetImeInfoEx); 因为 NtUserSetImeInfoEx 函数未导出, 所以需要使用 Malware Defender 来 hook 得到序列号, 再通过序列号计算出服务号
运行 Malware Defender, 选择钩子 -->Win32k 服务表, 查看系统服务序列号
分析: NtUserSetImeInfoEx 的系统服务号 = 0x1000+0x226(550 的 16 进制) = 0x1226 , 其中 0x1000 代表调用 SSDTShadow 中第二个表项中的系统服务函数(第一个表项的系统服务函数为 0x0000)
使用 windbg 来查看 SystemCallStub 函数地址从而调用内核函数
Poc 实现代码:
- #include <Windows.h>
- #include <stdio.h>
- __declspec(naked) void NtSetUserImeInfoEx(PVOID imeinfoex)
- {
- __asm {
- mov eax, 0x1226 // 将 NtUserSetImeInfoEx 函数的服务号传入 eax 中
- mov edx, 0x7ffe0300 // 将 SystemCallStub 函数地址传入 edx 中
- call dword ptr[edx] // 调用 SystemCallStub 函数
- ret 0x04
- }
- }
- int main()
- {
- HWINSTA hSta = CreateWindowStationW(0, 0, READ_CONTROL, 0); // 使用 CreateWindowStation 函数创建一个窗口站
- SetProcessWindowStation(hSta);
- char ime[0x800];
- NtSetUserImeInfoEx((PVOID)&ime); // 调用 NtUserSetImeInfoEx 函数触发漏洞, 致使系统 BSOD
- return 0;
- }
编译运行, 成功触发漏洞, 致使系统 BSOD
漏洞利用
原理: 内核提权的常见方法是将当前进程的 EPROCESS 对象指针成员域 Token 替换为系统进程的 Token 指针, 这相关的 shellcode 并不难写. 但是所有进程的 EPROCESS 结构体都处于内核空间中, 我们能控制的用户进程属于 Ring3, 并不能达到运行 shellcode 的要求, 因此难点是需要使用 Ring0 权限去执行这段 shellcode 修改内核内存地址, 这也就是我们利用 CVE-2018-8120 这个漏洞的原因.
分配零页内存
X86 的 Windows 系统中, 进程地址空间中从 0x00000000 到
0x0000FFFF
的闭区间被称为空指针赋值分区, 也就是我们上面说的零页内存, 正常情况下未被映射, 强行对其访问则会出现漏洞 Poc 的情况, 系统 BOSD.
为了函数 SetImeInfoEx 能够顺利向下执行, 我们需要提前映射零页内存, 这里我们利用 ZwAllocateVirtualMemory 函数对其进行映射, ZwAllocateVirtualMemory 函数作用是在指定进程的虚拟空间中申请一块内存, 该块内存默认以 64kb 大小对齐. 以下是 ZwAllocateVirtualMemory 函数的函数原型:
- NTSYSAPI NTSTATUS NTAPI ZwAllocateVirtualMemory (
- IN HANDLE ProcessHandle,
- IN OUT PVOID BaseAddress,
- IN ULONG ZeroBits,
- IN OUT PULONG RegionSize,
- IN ULONG AllocationType,
- IN ULONG Protect
- );
分析: 将参数 BaseAdress 设置为 0 时, 并不能在零页内存中分配空间, 而是让系统寻找第一个未使用的内存块来分配使用. 在 AllocateType 参数中有一个分配类型是 MEM_TOP_DOWN, 该类型表示内存分配从上向下分配内存. 我们可以将参数 BaseAddress 指定为一个低地址同时指定分配内存的大小参数 RegionSize 的值大于这个地址值, 如参数 BaseAddress 为 1, 参数 RegionSize 为 8192, 这样也就能成功分配, 地址范围就是 0xFFFFE001(-8191)到 1 把 0 地址包含在内了, 此时再去尝试向 NULL 指针执行的地址写数据, 程序就不会异常了. 在 32 位 Windows 系统中, 可用的虚拟地址空间共计为 2^32 字节 (4 GB). 通常低地址的 2GB 用于用户空间, 高地址的 2GB 用于系统内核空间, 通过这种方式我们发现在 0 地址分配内存的同时, 也会在高地址(内核空间) 分配内存.
分配零页内存, 创建并设置窗口站
构造能够获取 SYSTEM 进程令牌的 shellcode
每个进程都在内核中都会有且仅有一个 EPROCESS 结构, 其中 EPROCESS 结构中的 Token 字段记录着这个进程的 Token 结构的地址, 进程的很多与安全相关的信息是记录在这个 TOKEN 结构中的, 所以如果我们想获得 SYSTEM 权限, 就需要将拥有 SYSTEM 权限进程的 Token 字段的值找到, 并赋值给我们创建的程序进程中 EPROCESS 的 Token 字段.
第一步, 找到拥有 SYSTEM 权限的进程的 EPROCESS 结构地址
在 Ring0 中, fs 寄存器指向一个叫 KPCR 的数据结构, 该结构体中偏移量为 0x120 的地方是一个类型为_KPRCB 的成员 PrcbData
结构体_KPRCB 中偏移量为 0x004 的地方存放着指向当前线程的_KTHREAD
通过查看_KTHREAD 结构体和 EPROCESS 组成, 我们知道_KTHREAD.ApcState.Process 指向的就是当前进程的 EPROCESS, 所以我们获取当前进程 EPROCESS 的汇编代码可以写成
- mov edx, 0x124;
- mov eax, fs:[edx];// Get nt!_KPCR.PcrbData.CurrentThread
- mov edx, 0x50;
- mov eax, [eax + edx];// Get nt!_KTHREAD.ApcState.Process
- mov ecx, eax;// Copy current _EPROCESS structure
基于以上, 我们已经明白如何获得自身进程的 EPROCESS 结构了, 进一步需要做的是获得 System 进程的 EPROCESS~
查看 EPROCESS 的 ActiveProcessLinks 成员, 它是一个_LIST_ENTRY 结构, 在 Windows 系统中, 每创建一个进程系统内核就会为其创建一个 EPROCESS, 然后使 EPROCESS.ActiveProcessLinks.Flink = 上一个创建的进程的 EPROCESS.ActiveProcessLinks.Flink 的地址, 而上一个创建进程的 EPROCESS.ActiveProcessLinks.Blink = 新创建进程的 EPROCESS.ActiveProcessLinks.Flink 的地址, 构成了一个双向链表. 所以找到一个进程就可以通过 Flink 和 Blink 遍历全部进程 EPROCESS 了, 由于 System 进程是最先创建的进程之一, 因此它必然在当前进程 (我们编写的这个程序进程) 之前, 我们可以循环访问 Flink, 判断其 PID 是否为 4(EPROCESS 的 UniqueProcessId 成员指向其所属进程的 PID)来判断其是否为 SYSTEM 进程
第二步, 将 SYSTEM 进程的 Token 字段赋值给当前进程
查找获取 HalDispatchTable 表地址
我们需要 shellcode 有 ring0 的权限去执行, 可以修改一个具有 ring0 权限的函数指针为 shellcode 指针即可实现 ring0 权限执行 shellcode.
内核函数选择 hal!HaliQuerySystemInformation 函数, 因为有一个调用它的函数 (NtQueryIntervalProfile 函数) 是一个未文档化的函数, 也就是一个不常用的函数这样我们覆盖它的函数指针后对于整个程序执行造成的影响会小一些, 相对来说安全些. 而且 NtQueryIntervalProfile 函数是在 ntdll.dll 中导出的未公开的系统调用, 可以直接在 Ring3 调用
分析: 在 NtQueryIntervalProfile 中调用 KeQueryIntervalProfile 函数
分析: 从图中可以看出 KeQueryIntervalProfile 函数调用一个在 HalDispatchTable+0x4 处的指针, 我们可以覆盖该指针使其指向 shellcode, 那么当调用 NtQueryIntervalProfile 时 shellcode 也就间接的可以在内核层 0 运行
需要用到的是 HalDispatchTable+0x4 地址, 那么也就是需要找到 HalDispatchTable 的地址即可, 我们可利用另一个未文档化的函数 --NtQuerySystemInformation, 此函数可帮助用户进程查询内核以获取有关 OS 和硬件状态的信息, 这个函数没有导入库, 我们需要使用 GetModuleHandle 和 GetProcAddress 在'ntdll.dll'的内存范围内动态加载函数.
分析:
NT 内核文件的名字会因为单处理器和多处理器以及不同位数的操作系统版本以及是否支持 PAE(Physical Address Extension)而不同, 所以需要编程获取.
HalDispatchTable 在内核中真正的地址需要使用加载模块的基地址 + HalDispatchTable 在该模块中的偏移来获取的. 我们通过 NtQuerySystemInformation 获取了 nt 模块的基址 kernelimageBase, 通过计算用户空间中 HalDispatchTable 的地址 - 用户空间中 nt 模块的地址可以获得偏移.
利用 Bitmap 任意内存读写
这是一种编写 Exp 对任意内存进行读写的方法技巧, 越来越多地被应用于 Exp 的编写. 简单的来说, 这种技巧就是利用系统函数 GetBitmapBits 和 SetBitmapBits 可以对 Bitmap 内核对象中的 pvScan0 字段指向的内存地址进行读写操作, 这样就可以通过 pvScan0 字段实现对任意内存的读写操作.
1. 首先创建两个 Bitmap 对象: gManger 和个 Worker;
创建一个 Bitmap 对象时, 一个结构被附加到了进程 PEB 的 GdiSharedHandleTable 成员中, GdiSharedHandleTable 是一个 GDICELL 结构体数组的指针 ,GDICELL 结构的 pKernelAddress 成员指向 BASEOBJECT(sizeof=0x10
)结构, BASEOBJECT 结构后面的紧跟着 SURFOBJ 结构, SURFOBJ 结构中偏移量为 0x20 处即为 pvScan0 字段
我们可以用以下方式找到 Bitmap 对象的内核地址
addr = PEB.GdiSharedHandleTable + (handle &0xffff) *sizeof(GDICELL) ;
通过如下代码获得 gManger.pvScan0 和 gWork.pvScan0 的地址
2. 利用 CVE-2018-8120 的任意内存写入漏洞, 将 gManger 对象的 pvScan0 值修改成 gWorker 对象的地址;
基本前文的漏洞分析, 我们知道 SetImeInfoEx 函数中若想执行 qmemcpy, 需跳过如下所示的 while 循环
- while ( pkl->hkl != imeInfoEx->hkl )
- {
- pkl = pkl->pklNext;
- if ( pkl == winSta->spklList )
- return 0;
- }
因此需要设置 pkl->hkl = imeInfoEx->hkl, 就是在零页地址位置伪造了一个和 tagIMEINFOEX 结构体 spklList 成员类型一样的 tagKL 结构体, 然后把它的 hkl 字段设置为 wpv 的地址, 之后再把 wpv 的地址放在 NtUserSetImeInfoEx 函数的参数 ime_info_ex 的第一个成员里面; 指定 pkl->piiex 等于 gManger.pvScan0 的地址, 也就是指定 qmemcpy 目的地址, 这样执行 qmemcpy 之后, 就可以把 gWorker.pvScan0 的值赋给 gManger.pvScan0
注意: qmemcpy 拷贝了 0x15c 个字节, 势必会影响 gManger.pvScan0 之后的内存, 后面调用 Gdi32 的 GetBitmapBits/SetBitmapBits 这两个函数就会不成功, 因为这两个函数操作 pvScan0 的方式和 SURFOBJ 结构的 lDelta,iBitmapFormat,iType,fjBitmap 还有 SURFACE 结构的 flags 字段相关的, 为了避免这个问题, 我们需要在构造的 ime_info_ex 中填上一些数值进行修复
3. gManger 对象调用 SetBitmapBits 函数将 gWorker 对象的 pvScan0 的值覆盖成 HalDisptchTable+4 的地址(HalDisptchTable 表中对应偏移处存放着 hal!HaliQuerySystemInformation() 函数指针);
4. gWorker 调用 GetBitmapBits 函数获取 HalDispatchTable+4 所指内存的值, 也就是 hal!HaliQuerySystemInformation() 函数指针, 存储起来;
5. gWork 对象调用 SetBitmapBits 函数将 HalDispatchTable+4 处的函数指针覆盖成 shellcode 函数指针;
6. 在用户进程中调用系统 API 函数 NtQuerySystemInformation, 进而调用 HalDisptchTable 表中的 hal!HaliQuerySystemInformation() 函数指针, 也就是执行 shellcode;
7. gWorker 调用 SetBitmapBits 函数将 HalDisptchTable+4 的地址处的 hal!HaliQuerySystemInformation() 函数指针还原, 保证下面的运行不出错;
Exp 利用漏洞
打开 cmd, 进入 Exp-CVE-2018-8120.exe 所在的目录并执行, 引号内为想要执行的命令
参考资料
Windows 本地内核提权漏洞分析 https://www.ichunqiu.com/course/64309
关于 CVE-2018-8120 之最新 Windows 提权漏洞分析
CVE-2018-8120 分析 http://www.mamicode.com/info-detail-2430921.html
CVE-2018-8120 在 Windows 7 x64 环境下的漏洞利用分析 https://www.freebuf.com/column/173797.html
Windows 本地内核提权 --Win32 组件空指针漏洞(CVE-2018-8120)
来源: http://www.bubuko.com/infodetail-3101020.html