旅程的开始
我们的旅程始于我们的Fuzzer发现的一个易受攻击的驱动程序,该工具自动化了内核驱动程序漏洞扫描,揭示了通过系统缓冲区通过用户提供的输入触发的除以零错误。虽然最初看起来不起眼,但这个漏洞暗示了一个更深层次的问题——用户输入在没有安全检查的情况下渗透到驱动程序逻辑中。考虑到这一点,我们开始更仔细地查看驱动程序的内部。
驱动程序的第一道防线是使用IoCreateDeviceSecure。这个API允许驱动程序定义严格的规则,规定谁被允许通过所谓的SDDL字符串从用户模式访问驱动程序。然而,在这种情况下,使用的SDDL字符串允许任何用户与驱动程序交互。这有效地启用了随后的所有内容。
解释一下psmounterex.sys,它是Macrium Reflect的一个组件,负责为企业用户挂载备份映像的关键任务。驱动程序生成了几个系统线程来处理来自用户模式的IOCTL,允许挂载、卸载以及与本地或远程存储的备份映像交互。安全措施,如确保每个设备只有一个备份映像访问权限,并调用客户端安全和模拟协议,保护免受未经授权的文件交互,特别是考虑到驱动程序可以从标准用户帐户访问。虽然开发人员在引入针对用户模式攻击的保护方面做得非常好,我们将看到,从接收恶意输入的20个暴露函数中进行防御是非常困难的。
我们发现导致权限提升的两个主要漏洞是使用状态损坏的内核堆溢出写入和内核堆溢出读取。让我们深入研究!
写入超出堆缓冲区界限
两个漏洞中的第一个源于创建时的堆缓冲区大小与使用时的大小之间的脱节。在用于驱动程序中挂载文件的IOCTL 0x8100E000的处理程序中,使用我们从用户模式提供的大小创建了一个堆分配。这个堆分配的指针存储在DeviceExtension的偏移量+0x2C0处:
随着这个堆缓冲区的创建和我们选择的文件挂载,驱动程序启用了IRP_MJ_READ功能。这就是我们将找到第一个漏洞的地方。在处理读取请求的函数深处,在通过许多限制之后,有一些代码从我们之前挂载的文件中读取。这些代码似乎用于提取一个头,稍后再讲。从我们的文件中读取的代码看起来有点像这样:
mov r9, [rdi+2C0h]
... ... ... ... ...
mov [rsp+98h+Length], eax ; Length
mov [r11-70h], r9; Buffer
mov [r11-78h], r8 ; IoStatusBlock
xor r8d, r8d ; ApcRoutine
xor r9d, r9d ; ApcContext
xor edx, edx ; Event
call cs:ZwReadFile
我们可以看到,在挂载文件期间创建的指针在这里被用作缓冲区指针,ZwReadFile将文件内容读入其中。这里的漏洞是,用于此调用的长度参数与用于创建缓冲区的大小脱节。如果我们用4个字节创建缓冲区,并向ZwReadFile函数传递一个比那更长的长度,我们最终将从内核堆缓冲区的边界之外读取文件内容。
需要注意的一件事是,在实践中我们自己并不调用这个函数。在挂载文件后,从用户模式调用ReadFile触发Windows挂载管理器调用IRP_MJ_READ函数。它这样做的长度是0x1000或0x2000。这意味着我们可以溢出到大约0x2000字节。
读取超出堆缓冲区界限
有了堆缓冲区溢出,我们本可以尝试对内核堆上的内核结构进行一个棘手的半盲攻击。但是驱动程序很复杂,它接受了很多输入,所以我们觉得可能还有更多可以探索的地方。现在我们的目标是找到一个内核堆读取原语漏洞,可以支持我们利用写原语。拥有一个读取原语将消除利用的半盲部分,使其更加一致和多功能。因此,我们开始寻找驱动程序中将信息从内核内存发送到用户模式的功能。
驱动程序中有两个函数启用了其余的大部分功能:挂载函数,让我们称它们为MOUNT_A和MOUNT_W。前者是一个非常直接的函数,它创建了一些缓冲区并打开了一个文件句柄。这个文件用于驱动程序可以执行的大多数其他操作。后者挂载函数包含相同的逻辑,但也能够执行所谓的目录优化。由于写原语使用了两个挂载函数中不太复杂的一个,我们也将此作为读取原语的起点。经过几天对驱动程序的工作,我们确定了几个从驱动程序(存储在堆上)向用户模式输出数据的位置。不幸的是,对于我们来说,开发人员在执行所有必要的边界检查方面做得非常好。我们走到了死胡同。
直到几周后,我们带着新鲜的想法和一些新的想法重新回到了驱动程序。最初,我们对查看更复杂的挂载函数感到犹豫,它的逻辑比另外两个中的一个多20倍。然而,这也正是吸引我们的地方。更多的复杂性,更多的代码行,通常意味着更大的攻击面和更多犯错的地方。我们发现了一个错误。一个复杂且难以发现的错误,但非常强大。
漏洞的第一个迹象是在处理IOCTL编号C99D28D6h的函数中发现的。这个函数从用户模式接收一个输出缓冲区、一个偏移量和一个大小作为输入,并将由驱动程序管理的内核堆缓冲区的内容读入输出缓冲区。一个指向缓冲区的指针存储在驱动程序的DeviceExtension的偏移量+310h处:
通过将从输入缓冲区(r9)获取的偏移值乘以0x1E并加到+310h的基指针上,计算出一个偏移量。maxcount,即memmove将移动的字节数,是我们感兴趣的参数。我们知道源缓冲区是内核堆分配,目的地是一个输出缓冲区,该缓冲区输出到用户模式。如果我们能够传递一个比源缓冲区大小大的maxcount,我们将能够读取内核堆上不属于我们正在读取的堆分配的数据。maxcount是使用函数早期的输入计算的:
loc_14000BE25:
mov r9d, [rbx+4]
mov ecx, [r15+80h]
cmp r9d, ecx
jbe short loc_14000BE64
loc_14000BE64:
mov eax, [rbx+8]
cmp eax, ecx
jbe short loc_14000BE9D
我们可以看到我们的偏移值(r9)从我们给函数的输入中加载([rbx+4])。maxcount首先通过获取我们作为输入提供给函数的大小(eax)([rbx+8])并将其与从DeviceExtension的偏移量+80h处获取的值(ecx)进行比较。如果我们请求的大小大于该值,则函数失败。它对偏移值也做了同样的处理。在确认我们请求的大小和偏移值都低于从+80h获取的值之后,它从大小中减去偏移量并将其传递给函数。看起来从+80h获取的值是确定+310h缓冲区界限的值。由于+80h中的值与+310h中的实际缓冲区大小不匹配将立即创建一个漏洞,我们开始寻找这个值最初是如何设置的。
进入MOUNT_W函数。在函数的开始,我们找到了我们要找的东西:
mov rdi, [rdi+18h] #systembuffer
... .... .... ... ... ... ... ... ... ... ... ... ... ... ...
mov eax, [rdi+0Bh] #user input from systembuffer
mov [rbx+80h], eax
+80h中的值被设置为我们通过用户模式的输入缓冲区提供的一个值。在函数的稍后部分,我们找到了它的使用方式:
mov ebx, [rdi+80h]
imul ebx, 1Eh]
mov edx, ebx
mov ecx, 1 ; PoolType
mov r8d, 78457350h ; Tag
add rdx, 1Eh ; NumberOfBytes
call cs:ExAllocatePoolWithTag
所以,我们控制了加载到+80h中的值,但这个值本质上等于缓冲区的大小。因此,我们之前看到的memmove的边界检查是牢固的。没有办法绕过这个。真扫兴。
…或者有吗?如果没有一些挫折,就不会有有趣的博客了!虽然边界检查像它一样牢固,但我们仍然可以做些什么。为此,我们求助于经常被忽视的技术——状态损坏。如果我们能欺骗驱动程序,在创建期间认为缓冲区大小是一个大小,而在读取时又是另一个大小呢。
这里实际的漏洞是+310h处的指针在文件的挂载和卸载之间是持久的,而+80h处的值不是。我们可以成功地使用MOUNT_W函数挂载一个文件,并传递一个用于创建缓冲区的小大小。然后,我们可以卸载它。这让我们可以挂载另一个文件,同时保持缓冲区完好无损。如果我们看看MOUNT_A函数的开始,就会明显地看到它包含用于加载大小值的完全相同的逻辑:
mov eax, [rdi+0Bh]
mov [rbx+80h], eax
该函数中没有任何错误路径会重置这个值。因此,在使用MOUNT_W挂载和卸载文件之后,我们可以使用MOUNT_A挂载一个文件,并为+80h中的值给出一个不同的(更大的)大小。如果我们然后使用我们之前看过的函数从缓冲区读取,我们可以从它读取比实际缓冲区大得多的字节数。这导致了一个超出界限的内核堆读取。
漏洞利用原语
在漏洞利用的世界里,旅程通常始于将发现的漏洞转化为可重用的原语。这些原语作为构建漏洞利用的基础构件。让我们首先为漏洞创建一些包装器,允许我们反复与每个漏洞交互。
读取原语
首先是读取原语。这是通过状态破坏实现的,涉及两个基本步骤来滥用:
-
缓冲区分配:这一步涉及在内核堆上分配内存,我们最终将执行越界读取。值得注意的是,我们可以完全控制缓冲区的大小。此外,一个主要的缺点是,这种特定的分配每个重启只能发生一次。读取原语的分配函数原型如下:
// fname - Filename of any existing and accessible file on disk
// alloc_size - Our desired allocation size
void read_prim_alloc(char *fname, size_t alloc_size);
-
从缓冲区读取:一旦缓冲区被分配,我们可以执行读取操作,从内核内存中提取数据。在读取时我们必须谨慎,避免访问未分配的内存区域,因为这可能导致系统崩溃。此函数允许我们从内核堆分配中重复读取,使我们能够读取分配后的内核堆内存。此操作的原型如下:
// amount - Number of bytes we want to read from the buffer
uint8_t *read_prim_read(size_t amount);
写入原语,是读取原语的对应物,使我们能够修改内核堆的内容,促进对系统状态的实际更改。这个原语也由两个主要部分组成:
-
缓冲区分配:与读取原语类似,写入原语始于在内核堆上分配内存空间。然而,与读取原语不同,写入原语允许重复分配。这将在以后证明至关重要。分配函数原型与读取原语相同:
// fname - Filename of any existing and accessible file on disk
// alloc_size - Our desired allocation size
void write_prim_alloc(char *fname, size_t alloc_size);
-
超出缓冲区界限写入:超出缓冲区界限的写入是一个更复杂的操作,涉及以下步骤:
-
创建一个包含旨在溢出缓冲区的字节的文件。 -
分配写入缓冲区并指定上一步创建的文件的名称。 -
通过调用ReadFile()触发Windows挂载管理器用文件的内容溢出缓冲区。
虽然这些步骤看起来简单,但执行它们带来了重大挑战,我们将在进一步探讨中了解。
理论上的漏洞利用
我认为漏洞研究的目标是找到可以导致某种特权提升的漏洞。理想情况下,从用户到内核,或从远程到本地。然而,通常在发现漏洞后就会停止,并在没有通过现实世界的漏洞利用来验证的情况下,理论化潜在影响。这次漏洞尝试旨在通过实际的漏洞利用练习来弥合这一差距,挑战自己验证理论化的影响。
本博客的另一个目标是创建一种在面对读写堆缓冲区溢出时可重用的方法。虽然这个概念验证针对的是令牌对象,但这里讨论的方法可以适应目标的各种其他内核对象。
内核堆分配器
在我们深入实践方面之前,让我们简要回顾一下Windows内核堆管理器。自19H1版本以来,Windows内核已经过渡到使用分段堆进行内存管理,脱离了旧的内核堆分配器。分段堆的分配规则比旧内存管理器的更复杂,因此如果独立尝试此过程,建议熟悉它们。
现在,重要的是要了解分配算法根据分配的大小而变化。小于0x200字节的分配最终会进入低碎片堆的桶中,分配位置有些随机化,使得有效的堆喷射变得具有挑战性。相反,0x200字节以上且低于0xFE0字节的分配由可变大小分配器处理,操作类似于传统的最佳适应分配器,具有传统的空闲列表。你可以在这里和这里阅读更多相关内容。
鉴于我们由于存在两个内核堆溢出漏洞而需要两个分配,针对更简单的可变大小分配器是首选。这应该使为原语分配两个分配更容易。因此,我们选择了TOKEN对象。一个令牌对象大约是0x700字节的大小,非常适合可变大小分配器的200h到FE0h字节边界。此外,令牌对象是一个非常高价值的目标,允许我们直接操作进程的特权。有了我们的目标对象,下一步是理论化我们如何将内核堆操纵到可利用的状态。
攻击计划
我们的目标是使用写入原语覆盖令牌对象中的Privileges字段,从而授予我们最高权限。为了实现这一点,我们必须将写入缓冲区溢出到令牌对象中,这需要这些分配彼此靠近。在溢出到令牌对象时,保持特权字段前字段的完整性至关重要。这些字段的任何损坏都可能破坏令牌状态,可能导致系统崩溃。这就是为什么我们需要读取原语。
读取原语使我们能够验证相邻内存字段的位置和值,确保只有特权字段被改变,同时保留其余部分。因此,读取原语分配也必须靠近写入原语分配和令牌分配。理想的设置是这样的:
这种布局可以通过执行几个步骤来创建,首先使用令牌对象进行堆喷射。为此,我们首先分配许多令牌对象以填充内核堆:
然后,通过释放每个其他令牌,我们在每个分配之间制造空隙,给我们这样的布局:
接下来,我们将读取和写入原语分配到我们堆喷射留下的空隙中:
最后,我们可以利用我们可以读取写入原语分配后的内容的事实,精确覆盖(红色)令牌的Privilege成员:
当然,实践中事情从不会这么简单。让我们看看我们不得不跳过的圈套,以及从尝试将理论付诸实践中学到的经验教训。
堆喷射
堆喷射是整个漏洞利用的关键。实现一个稳定和一致的堆喷射至关重要;一个失误就可能触发蓝屏。正如前面提到的,我们想要针对的是可变大小分配器。因此,我们必须选择一个大小范围在可变大小分配器服务范围内的对象进行攻击。在这些范围内的对象中,令牌对象是最有趣的候选者,因为能够操纵它可以直接导致权限提升。选定目标对象后,现在我们需要用它们填充堆,以创建一个稍后可以利用的大的攻击面。
喷射令牌对象
喷射令牌对象很简单。只需打开你的进程的令牌并多次复制它。这应该会在内核堆上分配许多令牌对象:
token_handles = calloc(sizeof(HANDLE *), num_handles);
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &proc_token);
for (int i = 0; i < num_handles; ++i)
DuplicateToken(proc_token, SecurityImpersonation, &token_handles[i]);
在喷射中创建空隙
通过调用CloseHandle()释放你想要释放的句柄,就可以在喷射中创建空隙。然而,如果我们只是从用户模式开始释放每隔一个句柄,实际上可能并不会释放内核堆上的每隔一个令牌。要实现上述可预测的状态,需要一种更有条理的方法:
-
初始分配:最初分配对象时,它们被放置在非常不同的位置。如果我们分配几个令牌并检查连续两个的HANDLE_TABLE:
对象指针位大致指示对象在内存中的分配位置(在括号中显示)。如所示,这些指向非常不同的区域,不是连续的位置。
-
重复分配:让我们在进行了10,000个对象分配后再进行调查:
它们彼此更接近,但我们需要一个我们可以100%依赖的连续布局。如果不是,释放每隔一个句柄就没有意义,因为空隙可能不在我们需要的地方,冒着在不可利用的位置分配我们的原语的风险。
-
实现可预测性:只有在大约20,000个对象分配后,我们才看到分配的连续地址(请记住,一个令牌是710h字节大小):
这些令牌中的每一个都由用户模式中的句柄按相同顺序引用。现在,当我们释放每隔一个句柄时,我们应该实现一个具有可预测空隙的统一堆,正如我们计划的那样。
另一个障碍
在测试中,我们发现没有办法使用具有操作权限的复制令牌进行权限提升。CreateProcessWithTokenW API确实允许使用我们在堆上喷射的复制令牌创建进程。然而,这个函数对低权限用户不可用,因为它需要一个仅管理员拥有的权限,即SE_IMPERSONATE_NAME权限。我们还可以使用复制令牌通过SetThreadToken设置主线程的令牌,但似乎也无法滥用这一点。因此,我们必须调整我们的方法。
新方法:针对进程令牌
我们不是攻击一个复制的令牌,而是可以针对进程令牌。确切地说,是我们控制的一个进程的进程令牌。这样我们仍然在攻击一个令牌,正如我们最初打算的那样,我们可以重用到目前为止编写的所有代码。只是现在我们使用的是适当的主令牌,而不是复制的令牌。这些进程令牌由内核在每次创建进程时创建,并是Windows进程上所有访问检查的主要来源。只有一个问题,如果我们尝试像以前一样进行相同的堆喷射,狂喷20,000个令牌分配,我们将不得不也分配20,000个进程。这有点过分了。要实现与以前相同的可预测堆布局,但攻击一个进程令牌,而无需创建许多进程,我们需要执行一些额外的步骤:
-
初始喷射:首先,我们喷射20,000个复制令牌以稳定堆的分配状态,像以前一样。
-
二次分配:接下来,我们分配大约200个更多的复制令牌。我们将使用这些来创建空隙。复制的令牌给我们这样的状态:
-
创建空隙:然后,我们像以前一样释放每隔一个令牌来创建空隙:
-
进程分配:接下来,我们为一个我们想要提升权限的可执行文件分配进程。这些进程的令牌填补了我们刚刚创建的空隙(以橙色显示),给我们带来了一个可靠的进程令牌堆喷射:
-
最终调整:最后,我们释放剩余的复制令牌句柄,只留下进程令牌之间的空隙:
这导致了一个可靠的堆喷射,用于攻击我们旨在提升的进程的进程令牌。现在,让我们利用这些空隙为我们带来优势。
内核堆侦察
通过一致的进程令牌堆喷射和在我们需要的确切位置创建空隙,我们在利用方面取得了显著进展。然而,堆喷射只是最终利用的13个步骤中的第2步。要开发一个可靠的概念验证,还有更多需要考虑的因素和许多需要解决的约束。这一切都始于使用读取原语收集有关周围堆状态的有价值信息。
分配读取原语
首先,我们需要实际分配读取原语缓冲区。尽早分配这一点至关重要,可以在整个设置阶段指导我们。我们使用我们在开始时创建的read_prim_alloc函数,分配一个大小等于令牌对象(710h字节)的缓冲区。由于我们的堆喷射,我们有100个这种确切大小的空隙在等着我们,我们可以合理地假设它会落在其中之一。看看这如何转化为我们的堆布局:
分配写入原语
稍后,我们的目标是将写入原语缓冲区分配到读取原语后面的空隙中,如下所示:
但我们怎么知道写入原语缓冲区是否真的落在了那里?我们可以直接查看两个分配(710h * 2)字节之前的吗?嗯,不是完全这样。关于分配器处理这些类型分配的方式还有一个小细节我们还没有提到。与我们的简化图像显示的不同,这些分配并不是完全连续的。一个令牌分配,包括其头部,总共是710h字节。一个堆页面是1000h字节。为了保持灵活性,分配器避免将分配跨越一个页面的末尾和另一个页面的开始。因此,这允许每个页面上只有2个令牌分配,留下1000h – 0x710 – 0x710 = 0x1E0字节的未使用空间在每个页面的末尾。这意味着一个分配要么在页面的开始,要么在710h偏移处开始。这意味着,如果我们想要读取2个分配之前的,我们必须始终跳过1000h字节。但是,分配之间的这种额外空间引入了另一个问题。
理解分配位置
考虑这样一种情况,我们需要读取从写入原语分配之后到下一个令牌对象的Privileges成员的所有字节。如果写入缓冲区在页面的开始处分配,随后的令牌对象将在偏移710h处,紧随写入原语之后。要求我们读取令牌头部字节和直到Privileges成员的所有令牌对象字节。然而,如果写入原语缓冲区在偏移710h处分配,随后的令牌对象将在下一页的开始处,留下1E0h字节的间隙在写入原语缓冲区的末尾和下一个令牌对象的头部开始之间。要求我们额外读取1E0h字节。
由于我们无法事先知道写入原语缓冲区会在页面内的这两种位置中的哪一种上着陆,我们必须考虑这一点。这就是为什么我们必须使用读取原语对堆状态进行一些初步侦察,让我们计算出写入原语将落在这两种位置中的哪一种。
实现
为了计算这个,我们使用读取原语来检查接下来的2-3页。首先,我们通过检查令牌对象的池标记(Toke),来验证读取原语是否直接位于令牌对象之前,我们期望在令牌对象之前的头部找到这个标记:
for (i = 0; i < amount; ++i) {
if (read_buffer[i] == 'T') {
if (read_buffer[i+1] == 'o') {
if(read_buffer[i+2] == 'k') {
if(read_buffer[i+3] == 'e') {
first_token_offset = i;
break;
}
}
}
}
}
当我们确认了令牌对象的存在后,我们记录它相对于我们的读取原语缓冲区的位置。如果在从读取原语缓冲区开始计算的偏移704h处找到Toke标记,这表明读取原语位于页面的开始。如果在偏移8E4h处找到,这意味着读取原语缓冲区位于页面的中间。知道了这一点,我们就可以判断令牌之后的空隙位于两种可能位置中的哪一个。如果读取原语位于页面的开始,那么随后的令牌对象在中间,下一个空闲的空隙再次位于页面的开始,反之亦然。
有了这些信息,我们就不再必然受限于上述的布局。理想情况下,我们会将写入原语放置在第一个令牌之后的空隙中,但我们也可以将其放在2或3个空隙之后。因为如果在堆喷射和现在之间的时间,第一个令牌之后的空隙碰巧已经被填满了怎么办?为了检查这一点,我们在3页范围内寻找我们找到的第一个空隙。找到空隙仅仅意味着在我们可以期望令牌出现的偏移处,循环遍历读取原语之后跟随的字节:
if ((read_buffer[first_token_offset + 0x710] == 'T' &&
read_buffer[first_token_offset + 0x710 + 1] == 'o'))
{
first_token_offset = first_token_offset + 0x710;
continue;
}
else if ((read_buffer[first_token_offset + 0x8f0] == 'T' &&
read_buffer[first_token_offset + 0x8f0 + 1] == 'o'))
{
first_token_offset = first_token_offset + 0x8f0;
continue;
}
然后,如果找到一个空隙,我们进行一个最终检查,验证空隙之后是否有一个进程令牌(不是复制的)。这将是我们在堆喷射期间生成的其中一个进程的令牌,也将是我们稍后要攻击的令牌。我们可以通过查看令牌对象的两个成员来进行检查:
-
TokenType -
ImpersonationLevel
如果TokenType是1,而ImpersonationLevel是0,我们很可能正在查看我们创建的其中一个主进程令牌。复制令牌的值分别是2和2。以下代码执行此检查:
#define NUM_BYTES_UNTIL_END_POOL_HEADER 0xc
#define NUM_BYTES_token_HEADERS 0x50
#define NUM_BYTES_TO_NEXT_token_FROM_FIRST_token 0x1000
int num_bytes_until_tokentype = NUM_BYTES_UNTIL_END_POOL_HEADER +
NUM_BYTES_token_HEADERS +
NUM_BYTES_TO_NEXT_token_FROM_FIRST_token + 0xC0;
int num_bytes_until_implevel = NUM_BYTES_UNTIL_END_POOL_HEADER +
NUM_BYTES_token_HEADERS +
NUM_BYTES_TO_NEXT_token_FROM_FIRST_token + 0xC4;
if ((read_buffer[first_token_offset + 0x1000] == 'T' &&
read_buffer[first_token_offset + 0x1000 + 1] == 'o') &&
((read_buffer[first_token_offset + num_bytes_until_tokentype] == 1) &&
(read_buffer[first_token_offset + num_bytes_until_implevel] == 0)))
{
return 1;
}
如果所有前面的检查都通过了,我们已经确认我们创建了一个像我们假设的那样的堆布局。现在我们知道写入原语的空隙在哪里,相对于读取原语,我们知道这个空隙是在页面的开始还是中间。这也告诉我们在写入原语和我们将要攻击的进程令牌之间是否有额外的1E0h字节。有了这些信息,是时候开始准备有效载荷了。
准备有效载荷
我们花费了很多精力进行设置和计算,只有一个原因:在进行写入原语分配之前,我们必须加载溢出有效载荷。这是因为驱动程序在分配缓冲区时完全接管了用于攻击的文件,这意味着我们不能更改文件的内容。在准备溢出有效载荷时,我们的目标是用与内核堆上已经存在的完全相同的数据来覆盖数据,直到Privileges成员。限制了破坏内核数据的风险。当然,执行溢出时,我们只能将一系列字节写入文件,因此我们必须事先知道写入缓冲区将着陆的确切偏移。
使用上一步中的所有信息,我们找到一个在它之后有进程令牌的空隙,我们记录它相对于读取原语的偏移。使用这个偏移加上是否有额外1E0h字节空间的信息,我们读取直到令牌的Privileges成员的那么多字节。然后,我们将这些确切的字节写入我们将用于攻击的文件。
接下来,我们向文件追加实际的有效载荷。攻击的目的是将令牌的权限提升到最大数量的权限(与系统进程相同)通过覆盖Privileges成员。查看令牌对象的_SEP_TOKEN_PRIVILEGES结构成员,我们看到它有三个字段,总共跨越24字节:
为了获得类似系统的权限,我们必须用最大可能的值:0x1ff2ffffbc 覆盖这些字段中的每一个。有了整个有效载荷写入文件和堆布局准备好,是时候扣动扳机了。
利用
一切准备就绪,只剩下完成完整利用的最后一步。最后一个重要步骤是将写入原语缓冲区精确地分配到我们准备好的空隙中。这听起来很容易,但由于我们一次只能有一个活动的写入原语缓冲区,这仍然需要一些努力。写入原语缓冲区直接落在正确位置的机会非常低。为了将其推向正确的方向,我们必须使用一些策略,比如我们想出的这个:
-
分配写入原语。
-
使用读取原语检查写入原语是否被分配到了正确的空隙。
-
如果没有,释放写入原语。
-
分配两个新的(复制的)令牌对象以填补错误的空隙。
-
大约重复步骤1-4 10,000次。
-
如果正确的分配仍然没有完成,释放所有新分配的复制令牌对象,从步骤1重新开始。
根据我们的经验,每次失败的写入分配分配两个新对象提供了填补错误空隙的最佳机会。因为我们可能会无意中填补正确的空隙,我们在步骤6中释放了所有额外的分配,然后重试。如果我们每次错过写入分配只分配一个额外的令牌,由于分配算法和时间的原因,写入原语缓冲区最终会每次都被分配在同一个位置。这当然意味着写入分配永远不会更接近它需要去的地方,但是使用上述策略,它最终会到达。
如果前面的步骤正确执行,终于到了大结局的时刻。关键时刻:发动攻击。最后一步涉及调用设备句柄上的ReadFile()来完成写入原语。这样做会触发Windows挂载管理器中的回调,该回调调用驱动程序中的IRP_MJ_READ回调,进而用我们文件的内容溢出内核堆。如果一切顺利完成,我们在堆喷射期间生成的其中一个进程现在应该有提升的权限了!
漏洞利用的实际行动:
结论
一个由我们用于在内核驱动程序中查找漏洞的自动化工具触发的蓝屏错误使我们开始了一段漫长的旅程。在调查蓝屏之后,出现了几个更严重的错误。我们为每个漏洞开发了概念验证漏洞利用工具,并将这些工具连同根本原因分析一起发送给了软件制造商。我们强调这些漏洞需要立即修复,因为它们可能导致系统内存损坏甚至完全权限提升。制造商迅速修复了这些漏洞,但随后我们质疑我们的评估是否完全准确。虽然在内核模式内存中拥有读取和写入原语技术上可以启用权限提升,但我们想知道,考虑到这个驱动程序的限制,这是否真的可行。这个问题促成了这篇博客的创作。为了回答这个问题:是的,这是可能的,但我们只是勉强克服了所有限制。我们下次可能不会有这么好的运气!
原文始发于微信公众号(3072):psmounterex.sys 企业备份软件驱动提权漏洞分析