本文分析了 2020 年 4 月份发布的 CVE-2020-1015 的安全补丁。这个漏洞最早由 奇虎 360 Vulcan team 的钟社房和古河在报告中发布。微软对这个问题的描述:
用户模式电能管理服务 ( User-Mode Power Service ) 在内存中处理对象时存在权限提升漏洞。攻击者成功利用此漏洞可以以高权限执行命令。
微软分配给该漏洞的漏洞利用评级为 2 。来自 Microsoft Exploitability Index 公布的信息表示这意味着攻击者可能很难创建针对该漏洞的利用代码,因为这需要专业的知识和成熟的时机,针对不同的受影响的产品还会出现不同的结果。
考虑到 2020 年 4 月份其他的关于 EoPs 的安全补丁可利用性评级为 1 ,这个 bug 作为功能齐全和稳定可靠利用的候选可能不是很理想。不管怎么说,了解一下为什么这样评级以及导致 root 的根本原因是很有趣的。
本文剩下的内容将会按照我分析这个安全补丁的步骤,给出 PoC 代码去实现一次崩溃。
Windows 10 1903 umpo.dll 补丁对比
在开始之前,我们需要获取相关文件的打补丁版本和没打补丁的版本。当前没有什么特别简单的方法,通过搜索引擎搜索关键词 “User-Mode Power Service file”,结果指向了 umpo.dll 。我们将集中精力在 umpo.dll 的变化上。打过补丁的版本可以通过下载相关的补丁提取出来,或者从一个已经更新的 Windows 10 系统上获取。没有打补丁的版本可以通过在没有更新 4 月份补丁的 Windows 10 系统上获取。
为了实现补丁对比我将会使用 Diaphora 。Diaphora 显示两个函数被修改过,分别是 UmpoRpcLegacyEventRegisterNotification
和 UmpoNotifyRegister
:
第三个函数是 UmpoNotifyUnregister
,它没有被匹配到,在 umpo.dll 补丁版本中已经被移除。
阅读这些被修改的函数名听起来像是一个 use-after-free 漏洞。
改动函数分析
现在既然已经知道改动的函数了,我们就可以开始审计它们以找到 bug 所在。快速查看这两个改动过的函数就会发现它们并不长。为了知道这个函数是怎么被调用的,我们需要检查每一个函数的交叉引用。并没有交叉引用来自于其他任何函数到 UmpoRpcLegacyEventRegisterNotification
。根据名称推测它可能是通过 RPC 调用的。然而 UmpoNotifyRegister
有一个函数调用它
UmpoRpcLegacyEventRegisterNotification
我们来深入研究一下 UmpoRpcLegacyEventRegisterNotification
。只要可能,我更喜欢做动态分析,因此,我们将启动 WinDbg 并在目标函数上设置断点
函数声明为:
__int64 UmpoRpcLegacyEventRegisterNotification(__int64 a1,
__int64 a2,
const wchar_t *a3,
int a4)
因为这很有可能是一个 RPC 调用 ( 我们稍后会验证 ),我们假设第一个参数是 RPC 连接句柄并忽略它。参数显示的非常明确:
a3
是一个 wchar_t
类型的参数,在此处是一个服务的名字。a2
保存了一些值,但是这些值并没有指向有效的内存,所以很有可能被用作某些常量或者标识符。a4
的值是 0 。我们保持跟踪 a4
查看其他可能的值。第一段有趣的代码已经标记好备注了:
v14 = 0i64;
v4 = a4;
v5 = a3;
v6 = a2;
// Return if not local client (local RPC only)
if ( !(unsigned int)UmpoIsClientLocal() )
return 5i64;
// Get some object
v7 = UmpoGetSettingEntry();
// If not NULL
if ( v7 )
{
// Get another object at v7+32
v8 = (__int64 *)(v7 + 32);
// Walk circular linked list until back at head
for ( i = *v8; (__int64 *)i != v8; i = *(_QWORD *)i )
{
// Breaks if object offset 20 == 1 and
// if object offset 24 == a2
if ( *(_DWORD *)(i + 20) == 1 && *(_QWORD *)(i + 24) == v6 )
goto LABEL_11;
}
// If not found set i to 0
i = 0i64;
LABEL_11:
// If found within circular linked list set v14 to object pointer
v14 = i;
}
通过调用 UmpoGetSettingEntry()
函数来获取一个对象的引用。这个对象包含了一个指针在偏移 32 处指向了其他的对象。这个对象看起来是一个循环遍历的循环列表。如果在偏移量 24 处的对象成员等于 a2
,并且在偏移量 20 处的对象成员等于 1 ,那么循环将中断。
第二部分有趣的代码是:
// if a4
if ( v4 )
{
// if section 1 code loop does not find anything i is 0
if ( !i )
return 0i64;
// otherwise unregister
result = UmpoNotifyUnregister(i);
}
// if a4 == 0 (our current case)
else
{
if ( i )
return 0i64;
// Get sessionID of service
v10 = WTSGetServiceSessionId();
// call UmpoNotifyRegister
result = UmpoNotifyRegister(v12, v11, v10, v6, v5, &v14);
}
这块代码揭示了 a4
参数的作用。如果 a4
参数不为 0 ,UmpoNotifyUnregister
函数会被调用,并且传入第一部分代码循环返回的地址作为参数。如果 a4
为 0 ,UmpoNotifyRegister
函数会被调用。为了方便记录文档,我们将此函数简化为:
__int64 UmpoRpcLegacyEventRegisterNotification(
// RPC Binding Handle
__int64 a1,
// Handle
__int64 a2,
// Service Name
const wchar_t *a3,
// 0 to Register 1 to Unregister
int a4)
UmpoNotifyRegister
这就引出了第二个被修改过的函数 UmpoNotifyRegister
。这个函数比之前的要稍微长一点,但是相对来说仍然很短。不太相关的部分将被忽略。在我们把最后一个函数逆向完成之后,我们已经对参数有了比较完整的理解:
__int64 __fastcall UmpoNotifyRegister(
// Set to 0
STRSAFE_LPCWSTR pszSrc,
// Set to 0
__int64 a2,
// SessionID
int a3,
// Handle
__int64 a4,
// Service Name
const wchar_t *pszSrca,
// Reference to return to calling function?
__int64 *a6)
第二个目标函数的第一个有趣的部分:
v6 = a4;
v7 = a3;
v8 = 0;
EnterCriticalSection(&UmpoNotification);
if ( service_name )
{
v9 = service_name;
v10 = 256i64;
// walk through string until null char is encountered
// or 256 characters are read
do
{
if ( !*v9 )
break;
++v9;
--v10;
}
while ( v10 );
// v11 set to error code if error if str len > 256
v11 = v10 == 0 ? 0x80070057 : 0;
if ( v10 )
// v12 set to length of string
v12 = 256 - v10;
else
v12 = 0i64;
}
else
{
v12 = 0i64;
v11 = -2147024809;
}
EnterCriticalSection
的调用是为了同步一些对象的共享访问。然后遍历 service_name
( 是我们的服务名称参数 ),直到遇到空字符,以确定字符串的长度。
第二部分是函数的主要部分。这个函数为一个结构体分配空间,我们称它为 registrant
( 在备注中我们用 r
缩写来表示 )。事实证明,这个结构组成了循环链表 ( 实际上是一个双重循环链表 ),它在分析的第一个函数的第一节中遍历了 for
循环。
然后,该函数使用来自函数调用参数的相关数据填充新对象,并将该对象插入到列表的前端。结构被定义为 ( rust 语法 ) :
struct registrant {
// pointer to next
next: usize,
// pointer to prev
prev: usize,
// set to 1 after alloc
count: u32,
// Flags
flags: u32,
// handle we pass
handle: usize,
// heap alloc which is size of service_name + 2 for null char
service_name: usize,
// sessionID
session_id: u32,
// unknown, might be two u16s
unknown: u32,
}
有了上面的结构定义,下面的代码应该是相对简单易懂 :
// if no error on service_name length check
if ( !v11 )
{
v13 = (_QWORD *)UmpoGetSettingEntry();
v11 = 8;
...
// allocate registrant struct memory 48 bytes
v14 = RtlAllocateHeap(UmpoHeapHandle, 8i64, 48i64);
v15 = v14;
// error case on alloc
if ( !v14 )
goto LABEL_20;
v16 = UmpoHeapHandle;
// r->count is set to 1
*(_DWORD *)(v14 + 16) = 1;
// r->prev is set to itself
*(_QWORD *)(v14 + 8) = v14;
// r->next is set to itself
*(_QWORD *)v14 = v14;
// allocate memory for r->service_name
v17 = (wchar_t *)RtlAllocateHeap(v16, 8i64, (unsigned int)(2 * v12 + 2));
// set r->service_name pointer to allocated memory
*(_QWORD *)(v15 + 32) = v17;
// error case on alloc
if ( !v17 )
{
LABEL_18:
if ( v15 )
UmpoDereferenceRegistrant((__int64 *)v15);
LABEL_20:
if ( v13 && v8 )
RtlFreeHeap(UmpoHeapHandle, 0i64);
goto LABEL_21;
}
// set r->flags set to 1
*(_DWORD *)(v15 + 20) = 1;
// set r->handle to a4 (handle)
*(_QWORD *)(v15 + 24) = v6;
// copy func arg service_name into r->service_name
StringCchCopyW(v17, v12 + 1, pszSrca);
// umpo!UmpoNotifyRegister+0x111: lea rax, [rsi+20h]
v18 = v13 + 4;
// set r->session_id
*(_DWORD *)(v15 + 40) = v7;
// v19 = settingEntry head->next
v19 = v13[4];
// check if linked list head->next->prev == head
if ( *(_QWORD **)(v19 + 8) == v13 + 4 )
{
// inserting at head of list
// set r->next to head->next
*(_QWORD *)v15 = v19;
// set r->prev to head
*(_QWORD *)(v15 + 8) = v18;
// set head->next->prev to r
*(_QWORD *)(v19 + 8) = v15;
// set head->next to r
*v18 = v15;
// set r+44 to 0
*(_BYTE *)(v15 + 44) = 0;
UmpoNotifyUnregister
最后一个改动是 UmpoNotifyUnregister
函数的移除。这个函数先调用 EnterCriticalSection
再调用了 UmpoDereferenceRegistrant
。UmpoDereferenceRegistrant
执行了一些错误检查,从双重链表中删除了 registrant 结构并且释放在 UmpoNotifyRegister
函数分配的堆块。
漏洞分析
我们现在已经对改动的/移除的函数有了比较好的理解。让我们看看四月的 umpo.dll 函数变化,看看我们是否能发现安全漏洞。单看 Diaphora 的结果,似乎有相当多的修改。然而,由于我们对代码有了新的理解,从安全性的角度来看,这些修改基本上可以归结为一个明显的更改。EnterCriticalSection
和 LeaveCriticalSection
已经从 UmpoNotifyRegister
移动到 UmpoRpcLegacyEventRegisterNotification
。Critical sections 用于同步进程中对共享对象的同步访问。错误主要在同步访问共享数据会产生 bug。显然,EnterCriticalSection
被移动到调用 UmpoGetSettingEntry
( 分析的第一个函数的第一部分 ) 的上方是正确的
在这一点上,漏洞变得非常明显。UmpoGetSettingEntry
返回一个全局变量,该变量包含一个指向 registrant 对象的双向循环链表头部的指针。未打补丁的代码是无法正确同步对该对象的访问。此错误将导致条件竞争。
条件竞争是漏洞,但从某种意义上说,并不是可以直接利用的。相反,它们提供了一种违反安全规定的途径。首先,竞争一定要赢得,这允许安全违规。这种违规行为必须被利用。考虑到这一点,让我们来思考一下如何利用这个条件竞争。前面提到过,修改过的函数会调用 use-after-free 漏洞类。按照这种思路,可以很容易地想象这样一种场景: 条件竞争导致了 use-after-free 触发。以下面有两个线程的场景为例:
Thread 1 enters UmpoRpcLegacyEventRegisterNotication.
Thread 1 accesses linked list registrant shared object.
Thread 1 obtains pointer to target registrant struct.
Thread 1 enters UmpoNotifyUnregister.
Thread 2 enters UmpoRpcLegacyEventRegisterNotication.
Thread 1 enters UmpoDereferenceRegistrant.
Thread 2 accesses linked list registrant shared object.
Thread 2 obtains pointer to target registrant struct.
Thread 1 frees registrant struct memory.
此时,线程 2 指向目标 registrant 结构体的指针悬空。
利用 PoC 触发崩溃
为了验证漏洞分析是正确的,我们必须编写一个简易的 PoC 去触发条件竞争和 use-after-free。根据 UmpoRpcLegacyEventRegisterNotication
函数的名称我们猜测它是由 RPC 调用的。为了验证这一点,我们将使用由 James Forshaw 编写的非常有用的工具 NtObjectManager
导入 NtObjectManager 模块后,我们可以运行下面的命令( 这里 或 这里 有关于该工具的更多信息)
它提供了我们可以从 umpo.dll 调用的所有 RPC 函数。回顾输出,我们看到我们的函数:
HRESULT UmpoRpcLegacyEventRegisterNotification(
/* Stack Offset: 0 */ handle_t p0,
/* Stack Offset: 8 */ [In] UIntPtr p0,
/* Stack Offset: 16 */ [In] /* FC_SUPPLEMENT FC_C_WSTRING Range(0, 256) */
wchar_t* p1,
/* Stack Offset: 24 */ [In] int p2);
我们将从工具中获取所有的输出,并使用它们创建 .idl 文件来进行 RPC 调用。触发崩溃的 PoC 非常简单,代码可以在这里找到。设置了必要的 RPC 绑定初始化,并启动了两个线程。
线程 1 重复注册一个 registrant 结构。线程 2 在注册和取消注册之间随机跳转。UmpoRpcLegacyEventRegisterNotication
的 service_name
参数被设置为与 registrant 结构本身( 48 bytes )相同大小的堆分配用于填充被释放的内存。运行崩溃代码一段时间后,我们实现了崩溃!
这个崩溃发生在 UmpoRpcLegacyEventRegisterNotication
, 引用了无效的内存 41414141`41414155 ( 这是我们的数据 )
感谢 Shefang Zhong, Yuki Chen 和 James Forshaw 提供的工具和知识,帮助很大!