Skip to content
KitploitKITPLOIT
工具博客
提交
工具博客
提交

黑客、渗透测试和网络安全工具,武装您的安全武器库!

Kitploit 是一个黑客、网络安全和渗透测试工具的目录。发现最新的项目更新,查找漏洞、分析系统、自动化测试并加强你的安全。

··订阅源·联系·隐私·© 2026 Kitploit

工具目录

分类

查看所有分类
Loading categories
CVE-2022-37969PoC — 关于 CVE-2022-37969 的教程,重点在于内核利用的方法论,而非该CVE的内部原因。 | Kitploit
工具/GitHubGitHub/emilc3978/cve-2022-37969poc
权限提升内存取证漏洞分析漏洞利用逆向工程学习与教育二进制利用
GitHubemilc3978/cve-2022-37969poc

CVE-2022-37969PoC

关于 CVE-2022-37969 的教程,重点在于内核利用的方法论,而非该CVE的内部原因。

查看仓库
28个月前尚未审核

最受欢迎

查看全部 →

发现我们社区最常用的工具。

探索所有工具

浏览我们的工具集合

查看所有工具 →
分享

目录

概述

本文旨在阐明有关 Windows 漏洞利用的一般方面。它解释了应用于 CVE-2022-37969 的基本概念。最终结果是一个可用的 PoC。它并未阐明该 CVE 的每个方面,但提供了可重用的代码片段,并解释了许多常见漏洞利用中可找到的机制。

目标用户是初级的逆向工程师、漏洞利用开发者,他们希望获得一个可用的概念验证源代码来测试和理解基本的 Windows 内部机制。本文为进一步学习提供了参考点。

要求:基本的内核调试、基本的逆向工程、基本的 Windows 内部机制、C/C++ 编程技能

背景

程序是在机器上运行的一段代码。通常,程序接收数据(输入),使用输入进行计算,并生成数据(输出)。大多数程序由人类编写,因此存在 bug。bug 是由于某些源代码编写不正确(程序员想对输入做某件事,但生成的代码与预期结果不同)而产生的。大多数 bug 在产品发布前已被纠正,但有些仍然存在。这是因为存在不同类型的 bug,有些比其他更难发现。

Windows 是一个计算机程序,由人类编写,因此它也有 bug。为什么这很重要?因为 Windows 系统可以运行处理敏感数据的程序,如银行账户、医疗数据库等。一些 bug 可以被用来非法获取受限数据的访问权限(这是漏洞利用的一个好用例)。

存在多种 bug,有些有用,有些没用。通常,bug 是由程序的输入与写错的代码行共同作用,导致程序产生异常输出或行为。找出那个输入是安全专家(或黑客)的工作。下一步是评估产生的异常输出/行为,并回答“它能否被以有用的方式利用?”。这就是 bug 被分类成不同类别的地方。例如,一个 bug 可能导致破坏某些数据结构,使目标计算机重启。它的用途有限。一个 bug 可能导致输入被写入控制受限文件访问权限的内存区域。这种 bug 更有用。

因此,黑客从所有可能的 bug 集合中搜索对其目的最有用的子集。通常来说,问题是:“我能否向目标程序提供一个特制的输入,使得我不破坏系统,但能够提升我的访问权限并获利?”

经过这一非技术性介绍,本教程的范围可以表述为:我们能否找到一个 Windows 程序,它接受畸形输入,并由于开发者代码错误,能够将我们的权限从普通用户非法提升到管理员?

目标程序:Windows CLFS(公共日志文件系统驱动程序)

Expoit name:CVE-2022-37969

类型:本地权限提升

易受攻击的 ISO 下载:在此下载

Windows 权限提升通用理论

Windows 地址空间大致分为用户空间(运行一般程序)和内核空间(运行操作系统本身和硬件组件软件——驱动程序)。普通用户不得访问内核空间,但存在一些机制,普通用户程序可以通过它们访问部分内核代码(系统调用、驱动程序过程)。为什么需要访问?为了以安全且可控的方式与操作系统交互,这是操作系统设计者提供的。

一些驱动程序使用用户提供的数据输入来操作内核空间数据结构。如果输入产生 bug,则内核可能被破坏。一个例子是 Common Log File System Driver。通过使用一些特殊输入,我们可以强制驱动程序改变保存用户权限访问级别的内核数据结构,并用 管理员 覆盖 普通用户。

为了将权限提升到管理员,需要修改什么?

我们首先明确最终目标。Windows 在一个名为 _EPROCESS 的内核数据结构中存储系统中每个运行进程的信息。_Eprocess 示例

一个重要的字段是 struct _EX_FAST_REF Token。这是另一个数据结构,进一步指向引用相应进程权限级别的数据。在下图中,System 进程有一个系统令牌,Explorer 进程有一个普通用户令牌。

Tokens

因此,要提升 Explorer.exe 的权限,我们需要将 System 的 _EPROCESS-->Token 的值复制到 Explorer 的 _EPROCESS-->Token。我们将通过将 System Token 复制到我们自己程序的 Token,并从提升后的进程启动命令提示符(子进程继承父进程的令牌)来实现类似的操作。

要完成这些操作,我们需要以下机制:

  1. 获取内核中 _EPROCESS 数据结构的地址
  2. 读取 System 进程的 Token 字段值
  3. 获取 Explorer 的 _EPROCESS 数据结构的地址
  4. 将 System 的令牌值写入 Explorer 的 _EPROCESS 结构中 Token 偏移处

通过 PID 定位目标进程的 _EPROCESS 数据结构

引言:Windows 多年来的性质:随着新漏洞的发现,Windows 需要通过补丁来缓解这些漏洞。同时,随着新技术的出现,Windows 需要更新以保持竞争力。一个关键需求是与先前版本的向后兼容性。有时安全性通过模糊性来实现。数据结构和函数定义从手册中移除,但功能仍然保留。通过逆向工程,研究人员能够将这些功能用于各种目的。

为了找到 _EPROCESS 的内核地址,我们将使用一个未文档化的函数:NtQuerySystemInformation(参数见链接)。通过使用 SystemInformationClass 参数,我们可以指定要检索的信息类型。我们将通过指定 SystemExtendedHandleInformation 值(#define SystemExtendedHandleInformation 0x40)来检索一般进程信息。

使用 NtQuerySystemInformation 的一个注意事项是,我们事先不知道返回数据的长度,但 NtQuerySystemInformation 有一个帮助机制。如果用一个错误大小的数组来请求所需数据,它会返回 ERROR 并告知应该请求的正确数据大小。这可以用于以下面正确的方式读取进程信息:

  1. 使用一个假的 SystemInformationLength 参数调用 NtQuerySystemInformation
  2. 读取返回的 ReturnLength 参数值
  3. 使用之前返回的正确 SystemInformationLength 值再次调用 NtQuerySystemInformation

返回的数据结构类型是 PSYSTEM_HANDLE_INFORMATION_EX。这是一个未文档化的数据结构。(见链接)它引出一个 SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX 数据结构,其 Object 字段包含相应进程的 _Eprocess 数据结构的内核地址。

因此,逻辑将是遍历所有 PSYSTEM_HANDLE_INFORMATION_EX 元素,将 SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX 的 UniqueProcessId 字段与目标进程 PID 进行比较,并选择相应的 Object 字段以在内核中找到其 _Eprocess 地址。

代码片段:

proc_1 proc_2

NtQuerySystemInformation 被声明为一个函数指针,其地址在运行时通过 loadlibrary 和 getprocaddress 动态获取。

  1. typedef NTSTATUS(WINAPI* ptr_NtQuerySystemInformation)(int, PVOID, ULONG, PULONG);
  2. ntdll=LoadLibrary(L"ntdll.dll");
  3. MyNtQuerySystemInformation = (ptr_NtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation");

对于读取和写入 Token,我们将依赖于 clfsw32.sys 和命名管道中的漏洞。

使用管道在内核中读写数据

这有点像一个黑盒,在其他文章中有详细说明,但这里将解释此功能所需的最少知识,以便获得对漏洞利用过程的基本理解。管道是进程间通信机制。进程可以使用管道相互传递信息。管道表示为内核数据结构,其中一些字段可以从用户空间填充。一个例子是管道属性。

(稍后我们将把这与 CLSF 漏洞联系起来,以获得从用户空间在内核空间中的任意读写能力)

内核中的数据分配

如需进一步阅读,请查阅 fengshui-spraying-big-kids-pool。机制的简化解释如下:

内核根据我们要分配的大小有两种分配内存的方式:用于小于 4KB+头部的对象的小池(small-pool),和用于大于 4KB+头部的对象的大池(big-pool)。大池页很重要,因为可以从用户空间枚举它们。这意味着普通用户可以找到所有包含大池页的内核起始地址。

如何做到?每个大池页都有一个名为 Tag 的字段(可用于获取有关存储在那里的数据类型的信息)。系统中的所有大池页可以使用 NtQuerySystemInformation 并以 SystemBigPoolInformation 作为 SystemInformationClass 参数值来枚举。然后,我们可以从所有页面中按 Tag 过滤,并获得我们感兴趣的大池页的地址。例如,CLFS 使用带有 'Clfs' 标签的大池页。我们可以获取内核中分配了 CLFS 对象的所有页面的地址。

proc_1

回到管道,我们可以以相同的方式操作,分配一个足够大以使用大池机制的管道,枚举大池页,搜索管道特定的 Tag 并过滤那些页面。

因此,我们可以在用户空间中泄漏内核分配我们管道的位置。那么,如何控制进入内核的数据并读取它呢?

为此,我们依赖管道属性。与大池标签类似,管道属性是一个数组,可以包含描述管道的信息(由用户填充)。使用未文档化的函数 NtFsControlFile,我们可以任意读写管道属性数据结构。由于未文档化,只提供了一个设置 PipeAttribute 向量然后读取它的概念验证。允许对读写原语进行的唯一修改是控制输入/输出缓冲区的内容及其大小。

在内核中读写管道属性示例:

这里我们分配一个足够大的管道以使用大池页(0x2000),将输入和输出缓冲区设置为受控值。注意,输入值的前两个字节必须为 0x5a 0x00 才能工作。

pipewr

这里我们读回之前在内核中写入的内容,同样只更改输出缓冲区。

peprd

结果:

piperes

管道的大池页在内存中是什么样子的,以及为什么之前的操作对我们有用?

以下是内核中管道大池页的内容:通过查询大池页,我们找到了内核中管道数据结构的起始位置。在地址 pipe_begin+0x20 处,我们发现一个指向我们的输入缓冲区+0x2 的指针。

pipe_kern

当我们调用管道属性读取函数时,操作系统将执行以下操作:

  1. 识别内核中管道的位置
  2. 加上 0x20,解引用指针,并将内核空间中的内容转储到我们的用户缓冲区。

为什么这有用?想象一下,如果我们能够将 pipe_begin+0x20 处的指针替换为 System 进程安全令牌的位置,然后调用管道属性读取,我们就能在用户空间中泄漏该令牌的值。替换是通过 CLFS.SYS 漏洞完成的。

内存喷射

理解这一技术对于理解漏洞利用至关重要。

大多数漏洞利用在本质上不是确定性的,而是概率性的。即使利用漏洞的代码是正确的,漏洞利用也可能不工作。通过将目标程序置于不稳定状态,漏洞利用开发者必须确保在执行漏洞利用后系统不会崩溃。想象一个程序,当被利用时,它提供了从依赖于程序中变量值的地址进行读取的能力。

例如: 假设 target=0x1000000+ var_1&0xff+ var_2&0xff00。 黑客无法控制 target、var_1 或 var_2。 但漏洞利用提供了从地址 target 读取的能力。 我们可以从 0x1000000 到 0x100FFFF 之间的任何地址读取,这在某些情况下可能有也可能没有用。

例如2: 假设一个漏洞利用提供了从匹配特定模式的地址读取一个 QWORD 并将其内容放入其安全令牌值的能力。假设我们之前已经获得了 System.exe 的安全令牌值。 我们如何利用该漏洞来提升权限?

read_addr=0x1000000+alfa&0xFFFF00

我们无法控制 alfa 参数。

题外话但非常重要:内核可以访问当前正在运行内核代码的进程对应的用户空间。

我们可以从哪些地址读取?嗯,0x1000000, 0x1000100(alfa=1), 0x1000200(alfa=2), ..., 0x1FFFF00(alfa=ffff00)。

为了确保漏洞利用成功,程序员应该执行以下操作:

  1. 在用户空间中分配一块约 0x1000000 大小的内存:memory=virtualalloc(dest=0x1000000,size=0x1000000,....)
  2. for (i=0x1000000;i<0x2000000;i+=0x100) ((QWORD*)i)[0]=system_token_value;
  3. 步骤2的代码在任何可能的 alfa 值处用系统令牌值填充内存。因此,无论 alfa 参数的值如何,系统令牌都将保证出现在该地址。

这实际上就是内存喷射。按照特定模式操作内存,该模式匹配漏洞利用产生的概率表达式的所有可能值。

在我们在情况下,这个工作需要一个条件:内存可以在地址 0x1000000 处分配。这是一个限制,在真实的漏洞利用中我们也必须处理。

公共日志文件系统

在澄清了一些技术方面后,现在需要对 CLFS 有一个基本了解。微软链接。CLFS 用于应用程序日志、数据库日志、事务等。

漏洞利用通常需要特定的内存布局才能工作。布局的形式由漏洞利用时程序内部的变量值决定。

本教程不会详细解释有漏洞的代码或日志文件系统的格式。它将提供对产生漏洞利用的过程的基本理解。

日志文件是一种特殊类型的文件,具有特定格式,可以通过 CLSF 驱动程序和 DLL API 进行交互。在这个系统中还有日志容器的概念。日志容器也是日志文件,但在内存中链接到主日志文件(它们被添加到的日志文件)。

这种结构的工作方式如下:

  1. 创建或打开一个主日志文件
  2. 创建或打开次要日志文件
  3. 使用 AddLogContainer API 将次要日志文件作为容器添加到主日志文件

此操作强制在主日志文件内分配新的内存空间,并将修改主日志文件内存布局中的不同对象。内存布局的修改在概念上看起来像这样:

containet_concept

漏洞利用机制的高层概述

与每个重要的数据结构/文件一样,在使用之前,CLFS 驱动程序会对日志文件的格式进行健全性检查:

  1. 防篡改(完整性):文件的哈希值存储在文件中的一个恒定偏移处。为了成功打开文件,驱动程序计算文件哈希值并与存储的值进行比较。如果值不相同,则意味着文件在创建后被篡改,并抛出错误。
  2. 范围和文件结构检查:检查各种头部和长度,以确保文件的格式正确。

第二点是漏洞的起点。攻击者能够修改先前创建的日志文件,重新计算其哈希值,并编辑哈希字段以使驱动程序通过完整性测试。修改涉及文件头部长度。精心构造的一组值使得一个本来会失败并抛出错误的 range-length 检查通过。因此,驱动程序接受虚假长度作为有效长度,并正常继续执行代码。虚假长度用于计算文件内一个偏移量,在该偏移量处将写入一个硬编码地址。这使攻击者能够控制先前写入操作发生的地址。

该漏洞必须与管道内核读写操作链接才能完成漏洞利用。可视化证明:

range-check

在上图中,我们看到 CLFS.sys 中的函数 AllocSymbol 负责虚假的范围检查。有漏洞的长度检查是那个会返回 0xC0000023 错误代码的检查(BaseLogRecord + v9 + Size_1 + 0x1338 > v8 + *(0x68))。它输入到条件中的部分由攻击者控制。可以向公式中注入一个值,使得本应失败的检查通过。通过控制 v8 和 v9,我们可以将条件的真值操纵为 FALSE,防止返回错误,并导致使用攻击者控制的 v9 值任意计算 v10 变量的值。

V10 进一步用于将 0 作为设置值的 memset。因此,攻击者获得了任意将内存设置为 0 的能力。这还不是全部。在最终返回之前,*a3=v10。A3 是一个通过地址发送给 AllocSymbol 函数的参数。因此,AllocSymbol 的调用者在从 AllocSymbol 返回后收到 V10 的值。

AllocSymbol 的调用者是 FindSymbol。

find-symbol

在 FindSymbol 内部,V33 是 AllocSymbol 中名为 a3 的参数。因此 v33 获得 v10 的值。然后,v33 地址处的值被设置为一个常量 (0xc1fdf006),并进一步前缀以值 0x30。

constant-fix

因此,攻击者获得了用常量设置任意内存位置的能力。

为什么这很重要?如果我们能够用一个从用户空间和内核空间都可访问的常量地址覆盖函数指针的默认值,并保证该指针会被调用,我们就获得了代码执行控制。尽管这是一般思路,但存在一些技术障碍,将在后面进一步解释。

CLSF 指针破坏,或者应该覆盖哪个位置以劫持执行流之前我们讨论了CLFS容器。当一个容器被添加到文件中时,在父文件中会填充一个结构。该结构中有一个名为 CClfsContainer* pContainer 的指针。当对应的容器被释放时,在父文件中会进行清理操作,并解引用 pContainer,将 [[pContainer]+0x18] 和 [[pContainer]+0x8] 的值作为函数指针使用。

因此,能够控制 pContainer 并执行用于添加和删除的特定容器API,就保证了控制流重定向的能力。

pContainer 位于何处?

CLFS文件结构是未公开的,但确实存在一些个人对文件结构进行逆向的尝试。

日志文件结构的鸟瞰图:

bird_eye_file_Structure

以及基块的详细视图:

detailed_base_blok

结构与内存视图之间的差异:

CLFS文件分配在大型池页面中,标签值为 Clfs。当在用户空间获取CLFS日志地址时(与获取管道对象内核空间地址的方法相同),内核将返回基块起始的地址(在上图中偏移量0x800处)。在偏移量 0xb98 处,我们看到一个名为 regContainers 的数据结构。这是一个32位值的数组。每个值与一个容器相关,表示该容器内部数据结构在基块文件中的偏移量(从 0x870 开始计数)。

容器文件的内存布局如下:

  1. CLFS_CONTAINER_CONTEXT 结构

container

在 CLFS_CONTAINER_CONTEXT 结构 的偏移量 0x18 处,就是我们的目标 pContainer。

显然,pContainer 的偏移量是固定的,即 regContainers 数组的第一个元素。其值为 0x1468。

证明 pContainer 被解引用并作为函数指针使用:

解引用并访问 pContainer 作为函数指针是利用的关键。这发生在 CLFS.SYS 驱动的 Remove container 函数中。因此,当容器被移除时,利用会被触发。

证明:

container_removal

让我们回顾移除容器的步骤,以证明指针 pContainer 确实被作为函数指针访问:

  1. 在第22行,调用了 GetBaseLogRecord API,该API返回基块的内核地址 + 0x70(它将文件指针移动到头之后)。
  2. 在第23行,初始化了结果的副本(BaseRecord_1)。
  3. 在第41行,变量 a4 的另一个副本被初始化为 v10。
  4. 在第50行:有一个有趣的向量索引构造:LODWORD(a4) = *( (_DWORD*)BaseLogRecord_1 + StartingIndex_1 + 0xCA); 如果文件只添加了一个容器,那么 StartingIndex_1 为零。GetBaseLogRecord 返回的值被转换为DWORD(32位)向量,并按0xCA个元素进行索引。注意,(_DWORD*)BaseLogRecord_1 + 0 + 0xCA 并不只是简单地将0xCA加到 BaseLogRecord_1 上。由于类型转换,这等价于 BaseLogRecord_1[0xCA]。这在对应的反汇编代码中有所体现:mov eax, [rdi+r12*4+328h],其中 rdi 是基址,r12 是 StartingIndex_1(注意它乘以4,即32位元素的大小(DWORD)),并与0x328相加,0x328 = 0xCA * 0x4。

最终,a4 的值为 BaseBlock+0x70+0+328 = (BaseBlock+0x398),这将我们带到 RgConainers 的第一个值(0x398+0x800=0xB98)。

  1. 在第76行,通过强制转换为 QWORD 向量(元素大小为8)并按3个位置索引,从 v10(a4的副本)得到 v13。注意反编译代码的第16行,a4 的类型是 _CLFS_CONTAINER_CONTEXT。因此,v10 是该结构中的第三个字段,对应 pContainer。
  2. 在第82和83行,解引用 pContainer,分别加上0x18和0x8,解释为函数指针并执行(call cs:__guard_dispatch_icall_fptr 实际上是调用 jmp eax 指令)。

这证明了通过重写 pContainer 的值,我们可以改变驱动的控制流,并将其指向攻击者控制的地址。

使用对象进行内核喷射

之前已经解释过,有时你需要用预定模式的值填充一块内存,以确保利用的成功执行。这是因为在利用发生时,攻击者只能控制构成程序状态的一部分变量。 在之前简单的内存喷射示例中,我们只使用了写入向量中的值来满足某个条件。

在真实情况下,条件更加复杂。我们需要在内存中以特定顺序排列日志文件,并且要求它们之间的偏移量已知。

首先,让我们在循环中创建许多日志文件,并研究操作系统如何为它们分配内存。 实验将使用以下模式:

  1. 识别系统中默认存在的CLFS文件。
  2. 分配一个新的CLFS文件。
  3. 识别新文件基块的内核地址。
  4. 重复约50个文件。
  5. 研究文件分配到的地址。

以下代码实现了这一点:

page_study

结果如下:

result_addresses

让我们对地址进行排序:

ordered_addresses

通过检查地址之间的偏移量,可以观察到分配中有一种伪模式:存在连续的分配,它们之间的偏移量是恒定的。例如,从 ffffd80faf444000 到 ffffd80faf4ee000,连续两次分配之间的偏移量为 0x11000。

这个假设对于利用的运行至关重要。我们将依赖它。

另一个假设:取一些间隔 0x11000 的页面。例如:ffffd80faf444000、ffffd80faf455000、ffffd80faf466000、ffffd80faf477000、ffffd80faf488000、ffffd80faf499000。所有这些页面都对应于已打开的CLFS文件。如果我们关闭一个文件,该页面将被释放,相应地址的内存将变为空闲。 在内存中看起来像这样(假设我们关闭了对应于 ffffd80faf466000 页面的文件): ffffd80faf444000、ffffd80faf455000、xxxxxxxxxxxxxxxx、ffffd80faf477000、ffffd80faf488000、ffffd80faf499000

如果我们再次打开该文件,操作系统将有很高的概率在同一地址(ffffd80faf466000)分配一个页面,以填补空洞并使内存连续。--> 这对于利用的执行也非常关键。

到目前为止利用的示意图

现在,我们介绍我们将采用的策略示意图,以确保我们将 *pContainer 指针覆盖为我们控制的用户空间地址。

利用示意图

  1. 分配一组clfs文件,得到如下内存布局:

first_allocation

  1. 识别一个序列(理想情况下最长)的文件,它们之间相隔0x11000。

second_Allocation

在上图中,start(aux1)+0x11000=start(A), start(A)+0x11000=start(B), start(B)+0x11000=start(aux2)。

  1. 我们将使用 Logfile A 来覆盖 Logfile B 的 *pcontainer 指针,并通过 关闭 Logfile B 来触发利用,使用特殊的删除后关闭代码。

  2. 向 Logfile B 添加一个日志容器,以分配并更新字段,表明B有一个正确的容器文件。我们可以将aux2作为容器添加到B。

  3. 关闭Logfile A,以便我们可以在磁盘上编辑它。重要的是选择A和B位于间隔0x11000的最大序列中间,以便在内存中创建一个空洞,操作系统会优先填充它。如果操作系统未填充,利用将失败。

  4. 重新计算A的哈希,编辑其哈希字段以保持完整性,然后再次打开A,希望内核将其放置在同一地址,否则利用将失败。

  5. 对A调用AddLogContainer,使用一个普通的日志文件,以触发B的*pContainer指针被覆盖。

  6. 删除B文件,以便调用RemoveContainer,执行流被转移到已损坏且指向用户分配代码的B的*pContainer。

下图描绘了前面提到的步骤。

steps_first

代码 - 获取正确的内存布局

我们首先分配50个CLFS文件。每次分配后,我们查询内核内存中的所有CLFS页面,并为每个文件创建一个所有分配地址的列表。接下来,我们识别出两个间隔0x11000的文件(方案中的A和B)。(并存储在first、second变量中)

allocate_clfs

识别地址后,搜索两个间隔0x11000的文件:A->first,B->second。

find_tw_files

接下来关闭A(first),在磁盘上编辑它(损坏其头部),然后再次打开它。

malform_first_file_o

编辑A的头部并重新计算其哈希

这一步需要进一步解释,因为我们需要计算一些精确的值,当在AllocSymbol中触发可利用代码时,这些值会导致覆盖B的 *pConainter 指针。

根据之前对AllocSymbol和FindSymbol函数的分析,我们知道必须修改A文件,使其将IF条件的逻辑值覆盖为FALSE,并向v9变量注入一个值,该值会导致在B的*pContainer位置指针处进行写入。

首先,v9应该设置为什么值?

AllocSymbol 计算 v10(写入的目标地址)为: v10 = BaseLogRecord + v9 + 0x1338。这是在A地址空间的上下文中计算的。因此,BaseLogRecord 是 A 的内核页面地址 + 0x70。v9 由攻击者控制,0x1338 是常量。

B的 *pContainer 相对于其内核页面地址的位置在哪里?

这在之前已经详细说明,因此从B的内核页面地址,我们需要加上0x398以到达B的regContainer向量,然后按第一个元素索引以获取第一个CONTAINER_CONTEXT结构的偏移量。如前所述,对于第一个容器,索引确定为0x1468(从B的BaseBlock+0x70开始计数)。

因此,B的 *pContainer 的位置是 B的内核地址 + 0x70 + 0x1468 + 0x18(CONTAINER_CONTEXT结构中的第三个元素)。

B的内核地址 = A的内核地址 + 0x11000(因为我们以这种方式构造了内存)。

A的BaseRecord地址 = A的内核地址 + 0x70。

V10 = A的内核地址 + 0x70 + v9 + 0x1338。

v10需要覆盖B的 *pContainer。

因此,v10需要等于:v10 = B的内核地址 + 0x70 + 0x1468 + 0x18。

代入B的内核地址:v10 = A的内核地址 + 0x11000 + 0x70 + 0x1468 + 0x18。

化简v10:A的内核地址 + 0x70 + v9 + 0x1338 = A的内核地址 + 0x11000 + 0x70 + 0x1468 + 0x18。

解出 v9 = 0x11000 + 0x70 + 0x1468 + 0x18 - 0x1338 - 0x70 = 0x11148。

现在,A中哪些字段需要被覆盖?--> 有两类字段:

  1. 强制 AllocSymbol 中的 IF 条件评估为 FALSE 的字段。
  2. 对应 v9 的字段,用于计算到B的 *pContainer 的确切距离。

第一类字段不详细说明。V9 对应磁盘上A文件内部偏移量0x1b98处的字段。关于为什么这些字段会触发利用,不再详细解释,有兴趣的读者可以自行阅读。

以下是需要修改的值:

disk_a_modification

注意(小端序),使用的值是0x11149而不是0x11148。这是因为我们需要控制 *pContainer 中的高字节。最低有效字节的形式为X0(10、20或30...)。我们可以对每个值进行内存喷射,以考虑到这种情况。

注意,v10还将用于将一个长度为0xa0的内存区域以00填充(使用memset)。

first_memset

然后,在同一地址处,用常量值进行覆盖:

overwrite_constant

注意,常量的形式为 0x30c1fdf006X0。

这是在向A文件添加日志容器时发生的,但我们还没有介绍在修改A的字段后重新计算哈希的过程。

磁盘上的日志文件视图与内存中的视图略有不同。我们只修改了 基块 的内容,其长度为 0x7a00,在磁盘上从文件偏移量 0x800 开始。用于对基块进行哈希的算法是CRC32。保存哈希值的字段也存储在基块内,位于偏移量 0x80c 处。

重新计算哈希的过程:

  1. 将 0x80c 处的旧CRC32值清零。
  2. 根据硬编码的值和偏移量编辑字段。
  3. 计算从 0x800 开始、长度为 0x7a00 的基块的CRC32。
  4. 用新的CRC32值替换清零的值。

负责触发利用的完整代码:

要将代码执行路由到用户空间,我们需要:

  1. 获取A和B CLFS文件的内存布局(已解释)。
  2. 关闭A,在磁盘上编辑损坏的头部,然后再次打开(已解释)。
  3. 向B(代码中命名为second)添加一个日志容器,以初始化容器结构。该容器需要是一个常规的正常日志文件(从之前分配的50个文件中选取)。
  4. 准备代表已更改执行路径的用户内存(将在下一步解释)。
  5. 向A(代码中命名为first)添加一个日志容器,使用一个常规的正常日志文件作为容器。向损坏版本的A添加容器将触发对B(second)的 *pContainer 值的覆盖,覆盖值为 0x30c1fdf006X0 形式的常量。
  6. 使用 NtSetInformationFile 将B(second)设置为关闭后自动删除。使用此API至关重要,因为仅仅关闭其句柄不会触发 RemoveContainer API。

ntsetinfofile

  1. 关闭B并触发重定向。

用户内存喷射与布局

对于使用 *pContainer 重定向执行流的目标用户内存,存在一些条件。我们不能在程序处于内核模式时,从用户空间随机开始执行指令。

本节的目的是在用户空间泄露SystemToken的值,而不导致系统崩溃(BSOD)。

如何从内核地址读取并将结果存储在用户空间?快速回顾教程中关于管道部分的说明:

second_pipes

这里我们:

  1. 分配一个管道(内核对象),其大小足够大以分配在BigPool中。
  2. 在管道属性部分写入一些信息(字母字符串 BCDEFBBBB)。
  3. 使用BigPool页面的Tag字段在用户空间获取管道对象的地址。
  4. 使用Windbg研究内核中的管道结构。
  5. 使用NtFsControlFile PipeReadAttribute 从内核读取数据回到用户空间。

将管道对象与我们获取系统令牌值的目标联系起来,其思路是使用 NtFsControlFile PipeReadAttribute 从系统令牌的地址读取,而不是从我们使用PipeWrite Attribute传递给内核的缓冲区起始处读取。

这意味着我们必须修改 PIPE_BEGIN+=0x20 处的指针值,使其指向系统令牌所在地址。

该地址是已知的,在教程的早期阶段我们就定位并解析了EPROCESS结构时获得了它。

这里我们需要找到一种机制,允许我们在管道结构的固定位置进行写入。

为此,我们使用通过利用CLFS AddLogContainer函数获得的代码重定向。有人可能认为编写一个直接进行原始替换的shellcode就足够了,但(尽管未经测试)这很可能行不通。这是因为驱动在内核上下文中运行,并从用户进程区域执行代码。

为了规避这个限制,我们必须找到能够实现任意值写入的内核ROP。即找到一些内核代码片段,它们位于函数的末尾并以 ret 指令结束,并将它们的地址作为重定向目标提供。这样,代码仍然由内核执行。

这是主要思路,但CLFS驱动调用 *pContainer 代码的方式带来了限制:

mem_spray_user

为了构建可用的内存模式,我们必须研究RemoveContainer函数访问 *pContainer 代码的方式。

在上图中,我们高亮显示了解引用 *pContainer 并将其用作函数指针的代码部分。

RDI 是通过利用AllocSymbol写入 *pContainer 的常量。如你所见,该常量并不完全固定,其第一位字节会变化(形式为6X0,X可以是任何值)。

mov rax, [rdi] 解引用该常量值。为了避免无效读取(以及BSOD),我们必须确保 rdi 指向一个有效的地址。为此,我们必须从地址 0x30C1FDF00000 至少喷射到 0x30C1FDF006FF。这是通过使用 VirtualAlloc 分配一块内存来完成的。当然,如果系统因任何原因无法分配内存,利用将失败。

LPVOID dirtySpray = VirtualAlloc((LPVOID)0x000030c1fdf006d0, 0x1000000, 0x3000, 0x4);

然后,对于X的每个可能值,我们在 0x30C1FDF000X0 到 0x30C1FDF00FX0 处存储另一个值,该值对应有效的用户内存,选择一个任意值,比如 0x5000000。这样,对于任何形式为 0x30C1FDF006X0 的rdi,我们确保rax将等于 0x5000000。

在此步骤之后,我们看到还有两次解引用操作:mov rax, [rax+0x18] 和 mov rax, [rax+0x8]。

为了确保这些地址处的内存仍然一致,我们必须在 0x5000008 和 0x5000018 处存储指向内核函数的指针(ROP1和ROP2,将在后面计算)。

这是生成喷射模式的算法。

algo_spray_first_stage

注意:当引入ROP时,模式会演变,因为ROP也带有参数需要考虑,但到目前为止,这是避免无效内存寻址的最小要求。

这是调试器中第一阶段喷射内存的样子(观察值 0x5000000 放置的位置——X0对齐):

sprayed_mem_constant

0x5000000 处的内存看起来像这样(将详细说明的ROP):

sprayed_mem_ROP

以及内核调试器中代码重定向的过程:

debugger_redir

注意调试器中 RDI 寄存器和指令指针的值。RDI 解引用一次后得到 0x5000000 并存储在 RAX 中。[RAX+0x18] 是第二个ROP,进一步往下 [RAX+0x8] 将是第一个ROP的地址。

内核ROP——它们是什么,为什么有用

在此阶段,我们有以下利用的组成部分需要链接起来:

  1. 系统令牌的地址。
  2. 使用CLFS利用进行代码重定向。
  3. 一个带有属性对象的管道。

目前的目标是将代码重定向与一段能够覆盖管道指向属性缓冲区的指针(将其替换为系统令牌地址)的代码链接起来,并使用管道读取属性将信息获取回用户空间。正如我们所说,被重定向的代码也必须在内核中。此外,我们只有两个函数可用。本教程不会介绍这两个特定函数是如何被发现的,但可以推测存在一个常用的ROP候选列表。

我们将研究两个函数:

  1. ntoskrnl.exe 中的 SeSetAccessStateGenericMapping ,第二个被调用([rax+0x8])
  2. CLFS.SYS 中的 ClfsEarlierLsn ,第一个被调用([rax+0x18])

ClfsEarlierLsn 分析:

ealier

当使用错误参数调用时,该函数的唯一作用是将 EDX 设置为 0xFFFFFFFF 并返回。为什么需要这样做,在分析第二个ROP时会清楚。

SeSetAccessStateGenericMapping 分析:

sesetAccess

需要逐行详细讨论。

输入:进入该函数时:

  1. RAX 的值无关紧要,因为它在函数的第一行被覆盖。
  2. RCX 是 RDI 的值,即常量 0x30C1FDF006X0。
  3. [RCX+0x48] 的格式为 0x30C1FDF00YX8,始终按 0x8 对齐,并且不会与 0x30C1FDF006X0 冲突(后者按 0 对齐且包含 0x5000000)。

函数代码执行过程:

  1. mov rax, [rcx+48h] 将从 0x30C1FDF00YX8 解引用,并将值移入 RAX。
  2. movups xmm0, xmmword ptr [rdx] 将从 RDX 移动16字节到 XMM0 寄存器。请记住,从 ClfsEarlierLsn 出来后,RDX 是 0xFFFFFFFF。
  3. movdqu xmmword ptr [rax+8], xmm0 将 XMM0 的值移动到 [RAX+0x8]。

本质上,这是从一个地址读取并存储在内核中。可以这样理解:

mem_move

因此,为了用指向系统令牌的指针覆盖管道的属性缓冲区指针,我们必须在 0xFFFFFFFF 处分配16个八位字节,并在那里存储系统令牌的地址。然后,我们必须使用目标地址 -0x8 的值反复喷射内存 0x30C1FDF00YX8。即,管道属性缓冲区偏移量 -0x8 的值,也就是 PIPE_KERNEL_PAGE_ADDRESS+0x20-0x8。

以下是在实际代码中如何实现这一点的:

mem_pattern

另一个未解释的方面:如何在用户空间获取ROP的内核地址?

这是一个技巧。首先,你可以使用 NtQuerySystemInformation 获取任何内核模块的基地址,参数如下:NtQuerySystemInformation(SystemModuleInformation, (HANDLE)ModuleInfo, ModuleInfoSize, &retlen)。

然后,通过过滤 ModuleInfo->Modules[i].Name 值来匹配所需的库。这将获取内核中的基地址。

然后,我们利用这样一个事实:基地址与导出地址之间的偏移是恒定的,无论该可执行文件加载在哪个地址空间。

我们在用户空间加载模块(你可以使用 LoadLibrary 做到这一点),并使用 GetProcAddress 获取导出地址。计算导出地址与用户基地址之间的差值,并将其添加到内核基地址(通过 NtQuerySystemInformation 找到)。这样,我们就在用户空间中获得了加载在内核空间中的导出地址。

读取系统令牌的值

一旦属性指针被破坏并设置为指向系统令牌的位置,使用正确参数调用 MyNtFsControlFile 应从该地址读取并将系统令牌值泄露到用户空间。

read_token

接下来做什么?

拥有了正确的系统令牌值后,要完成利用,我们必须将其写入我们自己的进程。即,用系统令牌的值覆盖执行利用的进程的令牌。

执行此更改所需的步骤不涉及任何新技术或方法,而是依赖于重用之前的代码。覆盖我们自己令牌值的算法意味着在内核中的某个地址执行写入操作。这是通过ROP函数 SeSetAccessStateGenericMapping 和 ClfsEarlierLsn 实现的。这意味着我们需要第二次触发CLFS漏洞。没错,我们必须再次进行容器分配和内存喷射,并希望操作系统不会崩溃。

覆盖我们自己令牌值的步骤:

  1. 在内核中定位我们自己令牌的存储位置。(类似于第一次定位系统令牌地址的方法)
  2. 再次使用CLFS文件进行系统内存喷射和对象喷射。
  3. 在用户喷射内存中设置缓冲区内容,使得 DESTINATION_ADDRESS 是我们自己系统令牌的地址,而源地址(位于地址 0xFFFFFFFF 处)是第一次运行利用时获得的系统令牌的值。
  4. 第二次触发CLFS漏洞。
  5. 生成一个 cmd.exe 并检查权限。

由于第二次不需要从内核读取,这次我们不需要使用管道。

由于这些步骤不需要额外的知识,教程可以在此结束,并提供一个具有 nt authority\system 的 cmd.exe 的最终证明。

final_proof

概要

本教程的主要焦点并非CVE本身的内幕,而是对创建Windows权限提升示例过程的用户友好描述。许多漏洞利用共享相同的方法和构建块,如内存喷射或处理某些Windows数据结构。大多数情况下,从拥有理论上的Windows内部知识到有效编写利用漏洞的算法之间存在着巨大的鸿沟。

下载工具