CVE-2022-30190 Windows MSDT 远程代码执行漏洞分析

概述

MSDT 全称是 Microsoft Support Diagnostic Tool,主要用于 Windows 故障诊断支持。

2022 年 5 月 27 日,@nao_sec 发推,称他在 VirusTotal 发现一个恶意的 Word 文档,当受害者打开这个文档后,它会通过使用 ms-msdt URI 协议来实现远程代码执行。随后微软发布安全更新对该漏洞进行了修复,漏洞编号为 CVE-2022-30190。CVE-2022-30190 漏洞又被命名为 “Follina”。

本文是对该漏洞的学习笔记。

漏洞分析

ms-msdt URI 协议

虽然漏洞利用的传播媒介是 Word 文档,不过本质上问题是出在 ms-msdt 这个 URI 协议处理上的,因此重点看这个协议。

ms-msdt URI 注入代码的 PoC 如下:

1
ms-msdt:/id PCWDiagnostic /skip force /param "IT_RebrowseForFile=cal?c IT_LaunchMethod=ContextMenu IT_SelectProgram=NotListed IT_BrowseForFile=h$(Start-Process('calc'))i/../../../../../../../../../../../../../../Windows/SYstem32/mpsigstub.exe IT_AutoTroubleshoot=ts_AUTO"

先看 ms-msdt URI 协议的关联程序,很直观,URI 直接作为命令行参数,传递给 msdt.exe 程序执行:

image-20250317171707666

msdt!wWinMain 函数里,调用 Configuration::ParseArgs 方法解析命令行参数,调用 Mode::Run 方法进行主要的逻辑处理:

image-20250317171930456

命令行参数解析,支持通过 ms-msdt: URI 协议唤起:

image-20250317172517419

空格作为分隔符分割出键值对,- 或者 / 开头的标识为键,另一个就是值:

image-20250317173534187

因此,URI 基本的构造形如:

1
ms-msdt:/key1 val1 /key2 val2 /key3 val3

注意到 PoC 里涉及到的3个参数分别是 id、skip,以及 param。

msdt.exe 是什么

MSDT 全称 Microsoft Support Diagnostic Tool,为微软的故障诊断支持工具。先直接执行 msdt.exe 感受下它大概是个什么东西:

image-20250317173851258

由于故障的类型多种多样,想要进行区分,可以通过 id 参数指定具体的故障类型。比如 AppsDiagnostic 是和 Windows Store Apps 相关的故障:

image-20250317175454565

SpeechDiagnosticCalibrate 是和麦克风相关的:

image-20250317175534055

PoC 里所使用的 PCWDiagnostic 看起来是处理旧版本程序兼容性问题的:

image-20250317175717686

这些具体的故障类型 id,其实是根据 C:\Windows\diagnostics\index 目录下的这些 xml 配置文件里 id 元素的值来匹配决定的:

image-20250317181401681

通过 id 参数确定要调用哪个诊断包后,它就知道该诊断包相关配置文件在 Package Path 对应的路径下:

image-20250318164206415

如图所示,PCWDiagnostic 诊断包路径在 C:\Windows\diagnostics\system\PCW

image-20250318164311398

Package Path 对应的文件夹下,有个名为 DiagPackage.diagpkg 的文件,它其实也是 xml 文本文件,它进一步说明了该诊断包运行时需要哪些东西,后面会再讲到:

image-20250318165817408

正常执行不加其他额外参数时,当 UI 界面弹出后,需要点击 Next 按钮才会继续进行:

image-20250317180416642

但若指定了 skip 参数且值为 force 时,就会跳过这一步交互,直接进入到之后的处理流程:

image-20250317181154849

image-20250317181218180

诊断包确定后,msdt 会分两步走:诊断问题(Diagnose)解决问题(Resolve)

image-20250318170914458

诊断问题(Diagnose)

为了诊断问题,msdt.exe 会通过 COM 调用获取 CScriptedDiag 对象,并调用 Diagnose 接口方法:

image-20250318172303822

image-20250318172154364

这个 COM 实现在 sdiageng.dll 中,它的全称我猜测是 “Script Diagnose Engine”,作用如其名。

sdiageng.dll 的功能实现又通过 COM 依赖于 sdiagnhost.exe:

image-20250318172631598

image-20250318172645471

注:LocalServer32 表示 COM 服务端实现在一个可独立运行的应用中(如果是 InprocServer32 则表明在动态链接库里),如果要调试 COM 服务端,等 COM 客户端这边 CoCreateInstance 执行后 Windows 就会启一个 COM 服务端对应程序的进程,这时候调试器再 attach 上去即可。

sdiagnhost.exe 的功能实现又通过 COM 依赖于 mscoree.dll:

image-20250318173248742

image-20250318175912858

但奇怪的是,在 mscoree.dll 里我并没有搜到对应的 CLSID,并且尝试在 DllGetClassObject 函数上下断点也没有断到。

注:后来发现对于 mscoree.dll 而言,获取 COM 接口对象时,调用的是 ShellShim_DllGetClassObject 而不是 DllGetClassObject

查了下 mscoree.dll 的作用才知道,它是 .NET 程序中的核心 DLL,它可以提供 .NET 与 COM 组件的互操作支持,允许 .NET 程序调用原生的 COM 组件,或者让 COM 组件调用 .NET 代码。

既然 mscoree.dll 是起到 .NET 与 COM 连接桥梁的作用,那想必一个具体应用的 CLSID 实现不会在 mscoree.dll 里写出来,mscoree.dll 在接收到 COM 调用请求后,很可能将逻辑分发给了其他的 .NET DLL 里。因此通过观察 Procmon 捕获到的进程行为和注册表,发现它的 COM 接口实现位置体现在:

image-20250318175957750

这个 Assembly 对应的值是对应的 .NET dll 名称,Class 就是实现的类名:

image-20250318180010148

所以 sdiagnhost.exe 的功能实现通过 COM 依赖于 mscoree.dll,mscoree.dll 作为 COM 和 .NET 间的桥梁,又将调用分发给 Microsoft.Windows.Diagnosis.SDHost.dll 这个 .NET dll。

总结梳理下这个调用依赖关系:

image-20250318181856941

库之间的功能调用传递弄清楚就好说了。回过头来继续看,诊断问题的过程中,会调用 sdiageng.dll Troubleshooter::Run 方法:

image-20250318180816132

它会走到 COM 调用 IScriptedDiagnosticHost 接口 RunScript 方法:

image-20250318181116160

经过如前所述的调用关系传递后,最终就变成了执行 .NET Microsoft.Windows.Diagnosis.ManagedHost 类 RunScript 方法:

image-20250318182107622

能看出来,RunScript 里又去调用执行了 scriptPath 参数对应路径的 PowerShell 脚本,而它的值体现在 DiagPackage.diagpkg 配置文件里的 Troubleshooter XML 节点。

比如 PCWDiagnostic 诊断包 DiagPackage.diagpkg 配置如下:

image-20250319132838815

那么 scriptPath 参数值就是 TS_ProgramCompatibilityWizard.ps1 文件路径,而由于 Parameters 没有子节点,所以 parameterNames 和 parameterValues 参数为空。

解决问题(Resolve)

类似地,为了解决问题,msdt.exe 会通过 COM 调用获取 CScriptedDiag 对象,并调用 Resolve 接口方法:

image-20250319134631896

这个过程中,最终也会调用 .NET Microsoft.Windows.Diagnosis.ManagedHost 类 RunScript 方法,参数值体现在 DiagPackage.diagpkg 配置文件里的 Resolver 节点:

image-20250319135850562

scriptPath 就是 Script Filename 值,parameterNames 有俩个:TargetPath 和 AppName,而至于它们的值 parameterValues,这个我们一会再说。

param 参数

程序的执行逻辑和架构现在大概有了个初步印象了,现在来讲最关键的 param 参数如何被解析和传递的。

解析 param 参数会用空格分割出键值对,对每个键值对用等号分割出键和值:

image-20250319142223028

然后根据键值对,构造如下的 XML 节点对象:

1
2
3
4
5
6
7
8
<Packages>
<Interaction ID="key1">
<Value>val1</Value>
</Interaction>
<Interaction ID="key2">
<Value>val2</Value>
</Interaction>
</Packages>

并将这个 XML 节点对象赋值到 Answers::s_Instance 这个 Answers 全局结构体对象 +0x0 偏移处。

这里在做的一个事情其实是,通过命令行 param 参数来指定诊断过程中需要用到的参数,这些参数本来是通过 UI 交互由用户来输入指定的。比如仅执行:

1
ms-msdt:/id PCWDiagnostic /skip force

它会弹出对话框要求你选择哪个程序有问题:

image-20250319142810508

如果选择的是 Not Listed,还会让你输入具体的程序路径:

image-20250319142905189

诸如此类,每个诊断包在诊断过程中都有各自需求的参数需要用户通过 UI 界面输入,如果不想通过 UI 交互指定,那就可以通过命令行 param 参数来设定。

这部分信息体现在 DiagPackage.diagpkg 配置文件里的 Interactions 节点:

image-20250319143217804

每个 SingleResponseInteraction 都代表一个用户交互,ID 是它的标识。反应到命令行 param 参数中,ID 就是键,用户输入的结果就是值。

诊断问题所执行的 PowerShell 脚本里,如果想要获取到这样的用户交互输入,可以通过调用 Get-DiagInput 命令,比如:

image-20250319143903864

Get-DiagInput 是在 .NET dll 里注册绑定的命令:

image-20250319170328357

它的作用就是获取 id 对应的参数值,并调用 sdiageng.dll 里的 CScriptedDiagInteraction::Interact 方法,记录供后续使用:

image-20250319170509097

比如上面那段,/param IT_LaunchMethod=test,PowerShell 脚本里取到返回的值就是 test 并赋值给 $LaunchMethod 变量。这样实际上就是通过命令行 param 参数,来控制执行的 PowerShell 脚本里 Get-DiagInput 返回赋值的变量。

那重点就变成审计 PowerShell 脚本代码了。先看 TS_ProgramCompatibilityWizard.ps1,这是诊断问题(Diagnose)的过程中会执行的脚本。

这里注意当到 IT_SelectProgram 参数值是 NotListed 时,IT_BrowseForFile 参数值赋值给 $selectedProgram 变量:

image-20250319173139111

$selectedProgram 值会传递给 Test-Selection 函数检查:

image-20250319173747280

这里需要想办法构造 $appPath 的值,使得 appValid 被设置为 true,而这需要满足两个条件:

  1. test-path -literalpath $appPath 返回 true

  2. $appPath 文件路径后缀以 .exe 或者 .msi 结尾

Test-Path 本是用于检查对应路径的文件是否存在的,然而 Test-Path 存在一个 bug,当要测试的路径包含 ..\ 跳转符时,并且 ..\ 的层级跳转超出了系统盘之外,就会报异常,但仍然会返回 true:

image-20250319215707602

注:这个问题现在仍然存在,不太理解开发者的想法。

这就导致其实只要 ..\ 跳转层级够多,Test-Path 的检查就形同虚设,因此剩下仅需使得文件路径以 .exe 或 .msi 结尾即可。

然后从 $selectedProgram 里提取出文件名部分(不含后缀),赋值给 $appName:

image-20250319223913706

TS_ProgramCompatibilityWizard.ps1 脚本末尾,将 $selectedProgram$appName 分别作为 TARGETPATHAPPNAME 参数值,传递给 Update-DiagRootCause 命令:

image-20250319224205192

这也是 .NET 中注册的命令,它的作用主要是更新 Rootcause Resolver 的参数值,先前在 DiagPackage.diagpkg 里已经看到过了:

image-20250319135850562

诊断问题(Diagnose)的步骤结束后就是解决问题(Resolve),这个过程中通过调用 .NET ManagedHost 类 RunScript 方法来执行 RS_ProgramCompatibilityWizard.ps1 脚本,但这次就先暂停一下,先别着急再看 RS_ProgramCompatibilityWizard.ps1 脚本代码,回头来看看 .NET ManagedHost 类 RunScript 方法。不同于先前诊断问题(Diagnose),这次解决问题(Resolve)调用 RunScript 时 Parameters 是有内容的:

image-20250319231510416

注意看 parameterValues 值里 TargetPath 那部分是可控的,也就是先前脚本里的 $selectedProgram 变量值,而它会被拼接到 text 字符串里,随后通过 PSCommand 类 AddScript 方法添加到 PowerShell 命令中并被执行。拼接的过程中没有对字符串里的 $ 符号进行反引号转义,最终导致了 PowerShell 代码注入漏洞。

简化后的 PoC:

1
ms-msdt:/id PCWDiagnostic /skip force /param "IT_LaunchMethod=Whatever IT_SelectProgram=NotListed IT_BrowseForFile=$(Start-Process('calc'))/../../../../../../../../../../../../../../x.exe"

可以看到,整个程序的设计架构还是有点复杂的,从 C++ binary 到 .NET 再到 PowerShell,数据在各个组件之间通过 COM 相互流转,没点耐心和经验去逆向和分析,感觉真不太能挖得出来这种洞。

结合 Word 环境下的利用和补丁分析,还是以后有机会再补吧。

参考资料