查看原文
其他

【Part II】 CVE-2018-8897 原理深度漫游、漏洞利用、调试实战

shayi 看雪学院 2019-09-18

第一部分回顾:【Part I】CVE-2018-8897 原理深度漫游、漏洞利用、调试实战


Exploitation POC 设计思想


实现 LPE(本地权限提升)的源码托管在 GitHub 仓库:

https://github.com/nmulasmajic/syscall_exploit_CVE-2018-8897 

从它的 C 头文件、源文 件,以及汇编文件数量看来,此 POC 足可媲美中型的软件工程项目,反映出要成功利用该漏洞并做出有实际意义攻击行为的复杂度(对比同样利用该漏洞实现 BSOD 的简单项目:

https://github.com/nmulasmajic/CVE-2018-8897 ,所以本文不会分析后者)。



LPE exploit 假设运行的目标平台是 64 位体系结构(触发漏洞的指令序列中含有 syscall),所以它的汇编代码部分(asm.asm)操纵 64 位寄存器,比如 rsp、rax、r11 ~ r15 ,以及相关指令的 64 位版本。


该项目还分别附带一个 Visual Studio 的解决方案和工程文件,方便各位使用 64 位的 Visual Studio 编译、运行、调试。


syscall_exploit.sln 中的配置信息指出,该项目使用 VS 15 版本构建,所以我们最好是在64 位 Windows 10 上用对应的 SDK 工具生成可执行文件。


值得留意:该项目中的一些函数仿效了 Windows NT 内核架构的命名规范,比如MmProbeAndLockPages()、IoMapImage() 等等,所以有 NT 内核编程背景的童鞋可能感到困惑。


在碰到相名称,你只需提醒自己:仅仅是 exploit POC作者实现的用例程,与真正的内核里程无关即可! 


下面是我整理出的一中,各模的关系(设计思想),整个程序的行流 程以 exploit/线程模movss_popss.cpp 为控制主线,调用其它模块导出的服务,而这些服务最终利用 Windows 用户态 API 函数,实现特定目的(比如调整进程工作集大小、锁定进程内存页、设置线程优先级......等等);模块之间的例程可能会相互调用,如此配合达成完整的攻击(漏洞触发逻辑由 asm.asm 负责):



源码分析 


在本节中,我不会按照源码目录中的列表顺序逐个分析模块,因为那样太枯燥乏味而且浪费时和篇幅,相反,我会遵循代表主 exploit movss_popss.cpp 模块逻辑来分析,在涉及必要的其它模(和所需知),我会解,但不会于深入细节以至于迷失整体行


以下技术内容要求读者具备有关 Windows 平台上的内存管理、进/线程设施、PE 文件格式、理器同步等基先参考相关主籍有助于阅读节。


当遇到硬件体系结构相关的知识点时(比如 asm.asm 中的那些指令和寄存器),请回顾我在第一部分你准前置知


movss_popss.cpp 的高层视图如下,其代码行数约 300:



在用 include 语句包含的头文件内,只有 stdafx.h 引用平台 SDK(软件开发工具包)预置的 各种系统头文件(除了此 exploit 作者自行编写的 ntint.h 外),比如 :

  • windows.h;

  • Psapi.h(声明了 EnumDeviceDrivers() ,它提供接口给应用程序检索系统上每个设备驱动的加载基址——包括主 exploit 进程会用到的 ntoskrnl.exe 和 CI.DLL——后面我们会考察相关逻辑;

  • C++ 标准输入输出库中的 vector 类(此 exploit 用它来存放 EnumDeviceDrivers() 返回的设备驱动加载基址)



其它重要的知识点


✔ 宏 
STACK_PATCH_POINT 是一个 16 进制负值,代表我们要在用户-内核共享的栈边界外 ,写入恶意内容(包含返回地址)的起始偏移量。

在整个 exploit 项目中对 STACK_PATCH_POINT 执行一次全局交叉引用搜索,可知它在作为CPU core 1 上运行的 Worker 线程——Cpu1CorruptStack() 内部逻辑中,参与了变量PatchPoint 的赋值计算:



其中,_CPU0StackPointer (其 RAW 数据类型为 unsigned long long)是用户-内核共享 的 64 位栈指针,用作主 exploit 进程与 Worker 线程之间的一种同步机制,它的初始值为 0:



运行在 CPU core 0 上的 AsmExecuteExploit() 为内核栈开辟 0x3000 字节的空间(典型大小,亦即 12KB,或三张内存页):



一旦 Worker 线程(CPU core 1)检测到已建立起用户-内核共享的栈指针,它就结束等待,并借助 STACK_PATCH_POINT 计算要 patch 恶意内容的栈位置(因为栈内存向低地址扩展,加上一个负数 -0xA30,等同于在栈边界外分配 2.6KB 左右的空间,这个值看似能符合某内核版本上的 nt!_KPRCB 结构体大小,但现在还不能肯定,等到调试章节见分晓):



✔ 如此一来,exploit 在共享栈边界外的 patch 点由 PatchPoint(其 RAW 数据类型为unsigned int*)引用(间上图代码段);


类型限定符 volatile 告诉编译器无需对它应用常规的代码优化,因为它的值在运行时刻持续改变着——
Worker 线程调用 AsmClobberValue() 反复向 PatchPoint 指向的栈外内存写入post-exploit 所需的恶意内容,与此同时,其它内核例程也可能向该位置写入合法值,这就是AsmClobberValue() 无限循环、以及使用 volatile 的原因



volatile 对 Visual C++ 编译器的特殊含义,可以参考 MSDN 文档:

https://docs.microsoft.com/en-us/cpp/cpp/volatile-cpp?view=vs-2019


✔ 从 8 字节的 _CPU0StackPointer 强制转型为 4 字节的 PatchPoint,可能造成截断并丢失 精度,已经就此潜在问题发 e-mail 给 exploit 作者,尚未得到答复;


由于主 exploit 进程内用来创建 Cpu1CorruptStack() 线程的接口——CreateThread()——并 未提供指定目标线程要运行在哪个 CPU core 上的参数,所以 Cpu1CorruptStack() 内部第一 件事就是通过 SetThreadAffinityMask() 来调度至 CPU core 1 上运行,然后设定自身的调度优先级为 THREAD_PRIORITY_TIME_CRITICAL




因为 CPU0 上运行的主 exploit 进程——main()——先启动,必须给予较低优先级(THREAD_PRIORITY_LOWEST),其净效果是让它运行得比 CPU1 上后启动的 Cpu1CorruptStack() 线程慢,这样 CPU1 会进入等待 CPU0 建立共享栈指针的状态,然后向 PatchPoint 引用的内存位置写入 post-exploit 所需的信息:



在 CPU1 写入PatchPoint 期间,轮到 CPU0 进入等待状态(在 asm.asm 的AsmExecuteExploit() 中自旋),一旦写操作完成,CPU1 发信号通知(将全局变量_CPU1Ready 的值改为 TRUE ) CPU0,后者即刻执行实际触发漏洞的指令序列——mov ss, [_StackSelector]


syscall


如下图所示(我删减掉了会分散注意力的注释):



综上所述,可以给出一幅完整的多线程 exploit 执行流-时间图与同步机制,该图对于理解源码和调试都非常重要:



回到 movss_popss.cpp 的高层视图,让我们开始分析整个 exploit 的入口点——main() 数。


归纳来讲,主 exploit 进程必须在调用汇编过程 AsmExecuteExploit() 触发漏洞前,完成下 列这些任务(严格按照先后顺序进行):

✔ 设定进程自身较高的调度优先级,避免被其它进程抢占过多 CPU0 的时间片;

✔ 设定内部线程较低的调度优先级,让它执行得比后续要创建的 Cpu1CorruptStack() 慢一 些;(前文已经讨论并贴上相关的代码段,所以从略)

✔ 调用 SysCheckCompatability() → Kernel32.dll!GetSystemInfo() 确保运行 exploit 的当 前机器至少有 2 颗 CPU core,否则终止 exploit 过程:



✔ 调用 SysFindDrivers() → Psapi.dll!EnumDeviceDrivers() 枚举系统上已经加载的设备 驱动程序;如前文所述,本 exploit 利用 C++ 标准库的 vector 类(实例 Drivers)来存放已 加载的驱动列表:



接着通过 Psapi.dll!GetDeviceDriverBaseNameW() 遍历 Drivers 对象,索取 ntoskrnl.exe


 CI.dll(禁用驱动程序强制签名必须的内核模块)的加载基址,分别存储到两个全局变量里—— _NtoskrnlBaseAddress _CiBaseAddress(前者会参与 post-exploit 用到的一些内核例程入口点计算逻辑,留待后文再叙):



✔ 综合利用 symbols.hpp 模块曝露的 FSymbols、FModule 类接口;以及 symbols.cpp模块中的 SymFindKernelOffsets(),开始从微软符号服务器下载 ntoskrnl.exe 和 CI.dll 的符号文件(PDB,Program Debug dataBase),然后加载并解析 pre-exploit 和 post-exploit 用 到的内核符号(包括内核例程和变量)偏移量(参考前面我给出的模块设计思想图)。


FModule 类有一个最重要的 GetType() 方法,它最终利用微软 Dbghelp.dll 动态库导出的一 些调试相关函数(主要是 Dbghelp.dll!SymGetTypeFromNameW() Dbghelp.dll!SymGetTypeInfo()),获取指定内核符号的偏移量,并存储到它返回的 FType实例 Type 的 Offset 成员内:



下面是 symbols.cpp!SymFindKernelOffsets() 解析 _KPCR.CurrentPrcb 偏移量的代码片 段,这个值被放入程序全局变量 _CurrentPrcbOffset 里,后者以关键字 extern "C" 声明,可 被其它模块引用:



你可以从上面的绿色注释看到,_CurrentPrcbOffset 会参与伪造的 _KPCR (spoofed GSBASE)内相应字段的计算(留待后文讨论)。


再如,_ExAllocatePoolWithTagOffset 表示程序解析到的
nt!ExAllocatePoolWithTag() 内核函数 RVA(相对虚拟地址,或偏移量),在AsmKernelPayload() 中(具体负责 post-exploit 逻辑),会把它加上已知的_NtoskrnlBaseAddress,这样就在用户空间算出了 nt!ExAllocatePoolWithTag() 的入口点 地址,从而能够调用它(以 Ring0 特权级)来完成特定的 post-exploit 任务(如下列连续三张截图所示):



所以我们看到,movss_popss.cpp!main() → symbols.cpp!SymFindKernelOffsets() 在 用户态查询一系列内核基础设施信息的逻辑,这些信息稍后分别被用于 pre-exploit 和post-exploit 阶段。


总结如下图(按用途归类):



✔ 让主 exploit 进程自身运行在 CPU core 0 上 (通过 Kernel32.dll!SetThreadAffinityMask())



✔ 调用 ps.cpp!PsPrepareProcess(),首先增加主 exploit 进程自身的工作集大小到接近40 MB,根据理论《Thrashing: Its Causes and Prevention;Denning 1968b》,这样可以确 保更多的进程页位于物理内存中,减少出现缺页异常(#PF)的概率;


进一步而言,主 exploit 进程的栈段将在漏洞成功触发后,与内核共享,所以还需将此栈段锁在物理内存中,避免被换出至 pagefile.sys,否则当 #DBhandler——KiDebugTrapOrFault()—— 访问被换出的共享栈内存页时,会抛出双误异常(#DF,内核在处理异常的过程中,再次出现异常),亦即 #DB + #PF = #DF;这会使得执行流程转移到不受我们控制的地方,导致 post-exploit 失败:



注意,宏 TARGET_MEMORY_SIZE 的值为 65536 Bytes;表示后续逻辑分配的一些假冒内 核结构体的大小;



前面讲过,MmProbeAndLockPages() 仅仅只是 exploit 作者模仿 NT 内核函数命名规范的 自定义例程罢了,最终还是由 Kernel32.dll!VirtualLock() 等 Windows API 负责具体的锁定事务:



相同的道理适用于任何其它被内核共享访问的用户内存,比如我们预备构造的假冒 _KPCR, 原作者之所以称它为【spoofed GSBASE】,乃因在 post-exploit 阶段会执行特权指令swapgs 把原本的 GS 寄存器内容(GSBASE)从假冒的 _KPCR 替换为正真的 _KPCR,来读写敏感的内核信息;


而不知情的内核逻辑仍旧会访问 IA32_KERNEL_GS_BASE MSR 地 址 C0000102H 处的 spoofed GSBASE(请复习第一部分对 swapgs 指令的讨论),这就需 要把它锁在物理内存中,避免非预期的 #DF:



如果你在互联网上搜索并稍微了解一下 nt!_KPCR 与其它关键内核结构体之间的联系,就会 猜到,除了 _SpoofedGSBase,我们还需分配并锁定另外一些【赝品】:



必须指出:


第一,这些 _Spoofed 前缀指针指向的用户内存区域大小都是 64 KB;其次,ps.cpp!PsPrepareProcess() 只是分配了相应内存,返回到 movss_popss.cpp!main() 后续逻辑会按照 NT 内核架构的规范,把这些各自独立的内存区域联系起来,这个组织过程会 用到四枚前面已初始化的 _Spoofed 前缀指针,以及相关的偏移量信息:



如此严谨的逻辑都是为了确保整个 exploit POC 的可靠与健壮性,必须尽可能考虑到任何导致 漏洞利用失败的情况,比如内核例程通过假冒的 _KPCR 指针字段访问到尚未初始化的内存时,就会触发 BSOD


上图还将当前线程的原始合法 
GSBASE(即 _TEB)通过 rdgsbase 指令(可在 Ring3 特权 级执行)保存到 _OriginalGSBase 指向的用户内存区域,供日后恢复:



当整个假冒的 _KPCR 生态链构建完毕,就调用 asm.asm!AsmExecuteExploit(),后者把假 冒的 _KPCR 写入 GS 寄存器,准备触发漏洞(wrgsbase 指令同样可在 Ring3 下执行):



漏洞触发后,asm.asm!AsmKernelPayload() 会以 Ring0 特权级执行它的 post-exploit 逻辑,此时就可以突破 swapgs 指令的 CPL 限制,把正宗的 nt! _KPCR 交换到 GS 寄存器内,然 后读写其内容:



它在完成任务前,再次交换 GS 寄存器(当前保存正宗的 nt!_KPCR)与IA32_KERNEL_GS_BASE MSR 寄存器(当前保存假冒的 _KPCR),然后调用movss_popss.cpp!RestoreToUsermode() ,恢复成用户模式下的执行环境(跳转到 r8 寄 存器指向的地址处,最终执行的 iretq 指令会转移到被压入栈上的 RestoreToUsermode() 例 程入口):



再次执行 wrgsbase 的结果就是,把 GS 寄存器的内容复原成最初的状态—— _TEB 线程环境块;


如果你的模拟执行力不够
,那么参考下面这张有关 GS 寄存器与 IA32_KERNEL_GS_BASEMSR 寄存器的内容在整个 exploit 执行期间的变化,或许会有所帮助:



上图体现了整个 exploit POC 的思想精髓之一:绕过特权级指令的限制。 


到目前为止,我们看到为了执行一次成功且可靠的 CVE-2018-8897 exploitation,所需的准备工作何其复杂,牵涉的知识点何其广泛。


接下来,主 exploit 进程就会创建运行在 
CPU core 1 上的 Cpu1CorruptStack() 线程,后者 的内部逻辑已经在前文中讨论过,这里不再重复;


唯独提醒一点:一旦它运行起来就会很快进入自旋(_mm_pause 指令),等待 CPU core 0 上运行的 AsmExecuteExploit()将全局的_CPU0StackPointer 变为有信号状态;


接着反过来轮到 AsmExecuteExploit() 等待Cpu1CorruptStack() 设置全局的 _CPU1Ready 信号,意味着 post-exploit 所需的共享栈内容已准备就绪,可以执行漏洞触发逻辑;请回顾我在前面给出的同步机制时间流示意图。


Worker 线程创建完毕后,主 exploit 进程需要实施的最后一项准备工作就是,在特定用户内存处设置数据访问断点,以便后续的漏洞触发汇编指令能抛出 #DB 异常;这首先要求保存当前SS 寄存器的内容(栈段选择符)到全局的 _StackSelector 与 _CopyStackSelector(用作日后恢复栈段的一份副本)中:



其次,具体设置硬件断点的函数位于单独的模块内ntint.cpp!WinSetDataBreakpoint(), 它接收的所有 4 个参数都是由 Intel 文档所描述的,设置一个硬件断点所需的全部信息(参见前文的预备知识):断点地址、大小、类型(使用 Access),以及保存该地址的调试寄存器(使用 DR0):



该函数使用变量 DR7 来初始化 CONTEXT 结构(https://docs.microsoft.com/zh-cn/windows/desktop/api/winnt/ns-winnt-context)的 Dr7 字段;


初始化逻辑遵守 Intel 文档的接口(下图源码中的注释很清楚地解释了设置这些DR7比特的意图,例如要为当前任务还是所有任务启用硬件断点,断点类型是读、写、访问,还是指令获取、断点大小等。其中后两者已经在该函数原型声明时指定),使用一系列的移位和 bitwiseor 操作来构建DR7:



并使用带内存对齐功能的 malloc:_aligned_malloc(),为 CONTEXT 结构分配内存、将DR7 关联到前者的 Dr7 字段、将传入的断点地址关联到 Dr0 字段。


最后调用 Windows 用户态 API:kernel32!SetThreadContext()→ntdll!NtSetContextThread(),申请设置硬件断点:



换言之,全局的 _StackSelector 亦即我们要设置内存访问断点之处,它最终被漏洞触发逻辑使用:



如上所示,AsmExecuteExploit() 设立 12 KB(0x3000h)的内核栈、保存此刻的栈指针(rsp)到全局变量 _CPU0StackPointer 内以便日后恢复、利用 mfence 指令确保 CPU 0 上 的内存存储操作mov [_CPU0StackPointer], rsp先执行,然后才是CPU1Worker线程的while 循环判断(涉及加载 _CPU0StackPointer 变量到某个寄存器内比较,请参考Cpu1CorruptStack() 源码)、收到 _CPU1Ready 信号后将假冒的 KPCR 写入 GS 寄存器, 然后触发漏洞。


根据漏洞发现者的描述,movss, [_StackSelector] 导致抛出的#DB异常被挂起,进入syscall handler KiSystemCall64() 时,CPU 重新识别 #DB 异常,而 KiSystemCall64() 内 的栈段切换逻辑未能执行,只完成了从 CPL = 3 到 DPL = 0 的代码段跳转,控制流就转移到KiDebugTrapOrFault() 里,结果就是,后者盲目信任先前模式为内核,并使用攻击者传递的 假冒 GS 寄存器内容和用户栈段:



第二部分到这里算是圆满结束了,该 exploit 转入内核空间的执行流程通过调试会比仅阅读源码更清晰,所以我在第三部分的调试章节会对此加以验证,并动态分析 post-exploit(AsmKernelPayload())的逻辑,包括修改 CR4 寄存器来禁用 SMEP(Supervisor Mode Execution Protection)、偷取 system 进程的令牌、禁用驱动程序强制签名(DSE)......等等。


其实,我们可以把 AsmKernelPayload() 视为一个 post-exploit 框架,向其内添加任何希望 的破坏、颠覆逻辑,比如禁用 PatchGuard、然后就可以挂钩系统服务描述符表(SSDT)、 修改 EPROCESS 双线链表实现进程隐藏(这暗示我可能会写一个额外的 Part IV )。而且关键的是,CVE-2018-8897 赋予黑客在 Ring3 态就能执行此类攻击的权限,比用签名后的 Rootkit 来实施,具有更大的实践意义!




- End -




看雪ID:shayi  

https://bbs.pediy.com/user-533967.htm  



本文由看雪论坛 shayi 原创

转载请注明来自看雪社区



热门图书推荐

 立即购买!




⚠️ 注意



2019 看雪安全开发者峰会门票正在热售中!

长按识别下方二维码即可享受 2.5折 优惠!





热门文章阅读

1、攻破 Windows AMD 64 平台的 PatchGuard - 清除执行中的 PatchGuard

2、分析强壳的虚拟机原理

3、VMProtect 3.31的OEP之旅




公众号ID:ikanxue

官方微博:看雪安全

商务合作:wsc@kanxue.com





↙点击下方“阅读原文”,查看更多干货

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存