Windows本地内核提权——Win32组件空指针漏洞(CVE-2018-8120)

漏洞概述

在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)&amp;ime);        //调用NtUserSetImeInfoEx函数触发漏洞,致使系统BSOD
      return 0;
    }

编译运行,成功触发漏洞,致使系统BSOD

漏洞利用

  • 原理:内核提权的常见方法是将当前进程的EPROCESS对象指针成员域Token替换为系统进程的Token指针,这相关的shellcode并不难写。但是所有进程的EPROCESS结构体都处于内核空间中,我们能控制的用户进程属于Ring3,并不能达到运行shellcode的要求,因此难点是需要使用Ring0权限去执行这段shellcode修改内核内存地址,这也就是我们利用CVE-2018-8120这个漏洞的原因。

分配零页内存

  • X86的Windows系统中,进程地址空间中从0x000000000x0000FFFF的闭区间被称为空指针赋值分区,也就是我们上面说的零页内存,正常情况下未被映射,强行对其访问则会出现漏洞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的编写。简单的来说,这种技巧就是利用系统函数GetBitmapBitsSetBitmapBits可以对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所在的目录并执行,引号内为想要执行的命令

参考资料

猜你喜欢

转载自www.cnblogs.com/fyss/p/11071780.html