概述
本文是对 CVE-2021-40444 Windows MSHTML 远程命令执行漏洞分析 [1]
内容的续写。
漏洞分析
IE 下的安全警告弹窗问题
ShellExecuteExW 函数处理时 uri 协议名和文件后缀名是如何处理混淆的已经搞清楚了,接下来看看 IE 里的情况。IE 里执行如下 JS:
1 | var i = document.createElement("iframe"); |
相比于 ShellExecuteExW 函数直接 open,IE 下会出现弹窗告警:
测试发现,iframe url 不管是 .txt
还是任意的无关联程序的后缀 .wtf
作为协议开头,都会先出现安全告警弹框,只有点击“确认”后,关联程序才会执行。然而漏洞利用里所用到的 .cpl
却不会。这是为什么?
WinDBG 里给 IEFRAME.dll 里带 Dialog 关键词的函数批量上断点:
1 | bm ieframe!*Dialog* |
IE 里触发告警弹框前,命中了 IEFRAME!CProtocolWarnDlg::ShowProtocolWarnDialog
这个函数,函数执行完后成功触发告警弹框,说明就是这附近了。
调用栈:
1 | Breakpoint 190 hit |
通过 DialogBoxParamW API 注册 CProtocolWarnDlg::s_ProtocolWarnDialogProc
作为 DLGPROC 回调函数:
CProtocolWarnDlg::s_ProtocolWarnDialogProc
执行到 CProtocolWarnDlg::_ProtocolWarnDialogProc
,会根据不同的 message 值走入不同的分支处理:
当 messag 为 WM_INITDIALOG(0x0110)时,进入 CProtocolWarnDlg::_OnInitDialog
函数,做一些对话框初始化的工作:
注:关于 WM_INITDIALOG,官方文档的说明:
Sent to the dialog box procedure immediately before a dialog box is displayed. Dialog box procedures typically use this message to initialize controls and carry out any other initialization tasks that affect the appearance of the dialog box.
可以看到,if 判断里,当下列任意函数执行返回 0 时,就会调用 EndDialog,终止对话框处理:
CProtocolWarnDlg::_SetWebsiteInfo
CProtocolWarnDlg::_SetProtocolHandlerInfo
CProtocolWarnDlg::_SetProtocolUriInfo
关键就在 CProtocolWarnDlg::_SetProtocolHandlerInfo
这个函数里,它调用 AssocQueryStringW 查询 uri scheme 的注册表关联项,如果查到了就接着执行后续对话框的处理,如果没查到就直接调用 ShellExecuteExW
函数执行了(默认 verb 就是 open):
这里 AssocQueryStringW 的 ASSOCSTR 参数值为 ASSOCSTR_EXECUTABLE,那看看官方文档里是怎么写的:
官方文档里已经写了注意提示:“不是所有的应用都关联有对应的可执行文件,不要假定可执行文件一定会存在”。
所以来看看 .txt
在注册表里的情况,HKEY_CLASSES_ROOT\.txt
的 (Default)
值为 txtfile
:
所以再去查 HKEY_CLASSES_ROOT\txtfile\shell\open\command
:
能查到关联命令,所以对于 .txt:../../../../../fuckyou.abc
uri 而言,AssocQueryStringW ASSOCSTR_EXECUTABLE 能查到返回值,接着进行对话框处理。
再来看看 .cpl
在注册表里的情况,HKEY_CLASSES_ROOT\.cpl
的 (Defalt)
值为 cplfile
:
但 HKEY_CLASSES_ROOT\cplfile\shell\open\command
这一项是没有的,它子键名还不太一样,并不是 open
,而是 cplopen
:
因此对于 .cpl:../../../../../fuckyou.abc
uri 而言,AssocQueryStringW ASSOCSTR_EXECUTABLE 查不到返回值,直接调用 ShellExecuteExW open 了,因此也就没有弹框告警。URI 传到 ShellExecuteExW 里之后的逻辑在前篇已经分析过了,不用再多说。
发散思考:从这里也能看出来,ShellExecuteExW 对于文件后缀关联命令的查询逻辑,能处理比单纯地 AssocQueryStringW ASSOCSTR_EXECUTABLE 更复杂的情况。那么 ShellExecuteExW 的文件后缀关联逻辑到底是怎么样的,如何才能更全面更准确地查询某个文件后缀所关联的程序命令?
cpl 加载任意 dll
接下来看看,.cpl
后缀关联程序是如何导致任意 dll 加载的。
control.exe!WinMainT
函数里,先判断命令行参数里是否带有 name 或者 page 参数,然后比较判断是否为已知的参数列表:
如果都不是,并且也不是 NETCONNECTIONS
或者 ncpa.cpl
,再比较判断是否为注册表里的几个名字:
若以上仍都不匹配,命令行参数拷贝到 Shell32.dll,Control_RunDLL
后面作为参数,执行 rundll32.exe:
故会从:
1 | "C:\Windows\System32\control.exe" ".cpl:../../../../../fuckyou.abc", |
变成执行:
1 | "C:\Windows\system32\rundll32.exe" Shell32.dll,Control_RunDLL ".cpl:../../../../../fuckyou.abc", |
最终参数会传递给 LoadLibraryW 函数调用,进行 dll 加载:
调用栈:
1 | # Child-SP RetAddr Call Site |
对于 .cpl:../../../../../fuckyou.abc
这样的路径,ShellExecuteExW 函数会将它按照 Shell namespace 下的规则解析,而 LoadLibraryW 只是将它当作本地文件路径解析,两者的差异最终导致了路径穿越加载本地任意 dll 文件。
IE 的网站资源缓存文件路径
现在既然能加载本地任意路径的 dll 文件了,那下一个问题自然是如何将 dll 文件传到目标的系统上。
我们先看看能不能利用 IE 对于请求的资源会缓存到本地的特性来传文件。IE 的临时缓存文件,是放在一个随机字符串名构成的目录下面的,比如:
1 | C:\Users\admin\AppData\Local\Microsoft\Windows\INetCache\IE\VM8GS6YZ\ietest[1].htm |
这自然又引入另一个问题:IE 临时文件路径里的随机目录名是如何生成的,它真的随机吗?
通过调试和定位,发现随机目录名是在 wininet.dll!CCacheClientContainer::InitializeFileManager
函数里设置的:
CCacheServerContainer::GetNextDirectory
生成随机目录名字符串,然后通过 CCacheClientFileManager::SetSecureDirectory
将它赋值给 CCacheClientFileManager
结构体 +0x28 偏移处的成员。跟进 CCacheServerContainer::GetNextDirectory
,发现这个随机目录名是通过调用 RPC 方法生成的:
这里先介绍下 Windows RPC 的一些基础知识。Windows RPC(Remote Procedure Call)是微软实现的一种通信协议,允许分布式系统中的不同进程之间通过网络或本地通信进行数据交换和功能调用。RPC 的主要特点是通过隐藏底层网络通信细节,使调用远程过程看起来像调用本地过程一样简单:
关于 RPC,更多内容可以参考微软官方文档。RPC 底层实现和具体原理暂不关注,这里主要是需要搞清楚怎么去找到程序所调用的 RPC 服务端注册的接口方法。问了下 ChatGPT,它告诉我,RPC 调用通常基于接口 UUID 和方法编号,所以关键是找到这俩东西。
NdrClientCall4 函数签名如下:
1 | CLIENT_CALL_RETURN RPC_VAR_ENTRY NdrClientCall4( |
相关结构体定义如下(可以参照微软官方教程写个简单的 idl 编译看看对着参考):
1 | typedef const MIDL_STUB_DESC *PMIDL_STUB_DESC; |
NdrClientCall4 第一个参数 pStubDescriptor 是一个指向 _MIDL_STUB_DESC
结构体的指针,_MIDL_STUB_DESC
结构体偏移 +0x0 处的第一个成员 RpcInterfaceInformation 虽然定义里类型是 void *
,不过这里来看它类型是指向 RPC_CLIENT_INTERFACE
结构体的指针,RPC_CLIENT_INTERFACE
结构体前 4 个字节是 sizeof(RPC_CLIENT_INTERFACE)
,而偏移 +0x04 处就是调用的 RPC 接口的 uuid。
NdrClientCall4 第二个参数 pFormat 虽然类型定义是字节数组,但其实也是有一定结构的,pFormat 偏移 +0x6 处 2 个字节为 RPC 接口的方法编号。
如果是调用的 NdrClientCall3,则更好判断。NdrClientCall3 函数签名:
1 | CLIENT_CALL_RETURN RPC_VAR_ENTRY NdrClientCall3( |
第一个参数 pProxyInfo 的 pStubDesc 成员就是 指向 MIDL_STUB_DESC
的指针,找接口 uuid 和上面一样。而第二个参数 nProcNum 就是接口的方法编号。
分析后发现,CCacheServerContainer::GetNextDirectory
这里调用的 RPC 接口 uuid 是 cad784cb-4c1b-4d96-b8f7-4716b568b13c
,方法编号是 0x19。
找到接口 uuid 和方法编号后,可以通过 RpcView 来定位服务端的实现位置。Interfaces 窗口里搜 uuid,直接定位到接口实现的 dll 和相关的函数内存地址:
Procedures 窗口里找到对应的方法编号的地址,算下内存偏移就是了。
RpcView 虽然不像 IDA 那样可以自动下载符号信息,但也支持配置符号路径,这样看起来更直观。先用 symchk 下载符号信息(symchk 是 Windows SDK 里的工具,没有的话先下载 Windows SDK):
1 | :: 下载 C:\Windows\System32\wininet.dll 的符号保存到 C:\Symbols 目录下 |
然后 RpcView 里 Options - Config symbols:
配置好后重启 RpcView,看起来就更直观了:
所以 wininet!CCacheServerContainer::GetNextDirectory
里的 RPC 调用是去调了 wininet!s_UrlCacheGetNextDirectory
。跟进后定位到,随机目录名是通过 wininet!MakeRandomName
函数实现的:
注:虽然还是在 wininet.dll 这个库里,但由于这是 RPC 的服务端实现,自然应该去 attach RPC 服务端所在的进程调试,这里 RpcView 查出来是 taskhostw.exe 这个进程上。
MakeRandomName 函数里通过调用 BCryptGenRandom 生成随机数,作为索引去取数字+大写字母构成的字符表里的字符,最终生成 8 个字符的随机字符串。
那看来随机性这块应该是没什么问题了,所以攻击者若想要通过将文件驻留到目标计算机上的可预测路径,还需借助其他的办法,这也就引出了该漏洞最后一环:cab 文件解压路径穿越。
cab 文件解压路径穿越
cab 即 cabinet 的缩写,是 Windows 系统上特有的压缩包文件格式。它的文件结构也比较简单,可以参考官方文档。
可以直接使用 Windows 自带的 makecab 程序制作 cab 文件,最简单的可以像下面这样只打包压缩单个文件做成 cab:
1 | makecab.exe source.txt dest.cab |
输入的文件名会体现在 CFFILE 结构体的 szName 成员:
看到这里,了解过 zip 解压穿越漏洞的自然就会考虑,如果 cab 被解压,cab 里面的文件名被拼接到系统路径时,是否可能存在路径穿越的问题?
我们先把这个 cab 丢到 ie 里加载看看:
1 | var obj = document.createElement("object"); |
Procmon 监控,发现除了生成了 IE 的临时缓存文件外,还在 Temp 下生成了另一个随机文件:
调用栈:
根据调用栈可以推理出来,urlmon 这里对下载的文件进行信任校验时,会先看看是否可以从文件里提取出 Install Scope。所谓 Install Scope 就是应用安装的范围是给所有用户(Machine Scope),还是仅当前用户(User Scope)。
不过 GetSupportedInstallScopesFromFile 函数从文件里提取 Install Scope 时是有判断条件的:
GetSupportedInstallScopesFromFile 函数会先看文件名后缀是否为 cab,不是 cab 的话就不会从中提取。而如果是 cab 后缀的话,则会新建一个 cab 临时目录,并尝试将 cab 压缩包里的 .inf
后缀文件解压到该目录下,之后从这个 inf 里读取出 Install Scope。处理完后,将该 inf 文件和 cab 临时目录删除。
所以关键就看 inf 是被如何解压提取的了。
从 cab 中解压提取文件是通过调用 cabinet.dll 里的 FDICopy 方法来进行的,并且调用这个方法需要传入一个回调函数,这里回调函数设置的是 urlmon!fdiNotifyExtract
:
cabinet!FDICopy
里,传参 fdintCOPY_FILE 调用回调函数:
urlmon!fdiNotifyExtract
回调函数里,先通过调用 catDirAndFile 函数将 cab 里的压缩项文件名拼接和临时目录拼接得到完整的绝对路径,然后通过 Win32Open 调用 CreateFileA 打开文件,获取文件 handle:
urlmon!catDirAndFile
里进行路径拼接时其实是有做路径规范化处理和路径穿越检查的:
但问题就在于,它这里调用的 PathCchCanonicalizeA 在做路径规范化时,是不会处理 \
斜线的。
This function does not convert forward slashes (/) into back slashes (). With untrusted input, this function by itself, cannot be used to convert paths into a form that can be compared with other paths for sub-path or identity. Callers that need that ability should convert forward to back slashes before using this function.
所以微软意思就是:用这个函数我们是会对路径进行规范化处理,但斜线我们不管,开发者你们先自己处理好喽。
结果很明显,微软自己的开发者都被搞迷糊了,因为这里 PathCchCanonicalizeA 不处理斜线对吧,但后续 urlmon!Win32Open
里调用的 CreateFileA 是会处理斜线的:
所以因为这个文件路径解析上的差异,导致通过构造 cab 文件结构 CFFILE 里的 szName 为诸如 ../../test.inf
的文件名,就可以路径穿越了。
不过只是这样的话还有个问题,尽管路径穿越写进去了,但很快就会在 urlmon!DeleteExtractedFiles
函数调用里被删掉了:
urlmon!DeleteExtractedFiles
里 if 分支判断为 true 需要满足 3 个条件,catDirAndFile 和 SetFieAttributesA 都是返回 true 的,没什么想法,至于 cab->entry->unextracted
,它一开始默认的值是为 1 的:
但在解压提取出文件后,这个值就被设置为 0 了:
所以关键看看能不能让 cab->entry->unextracted
保持为 1 了。urlmon!MarkExtracted
里调用 StrCmpIIA 比较文件名肯定是会匹配到的,所以要让 unextracted 为1,就只能去思考怎么避免调用到 MarkExtracted 函数。
MarkExtracted 函数是在 FDICopy 注册的回调函数 fdiNotifyExtract 中被调用到的,当传入的 type 为 fdintCLOSE_FILE_INFO
就会走进来:
找下在哪个地方传参 fdintCLOSE_FILE_INFO
调用的回调函数,是在 cabinet!FDIGetFile
里 LABEL_13
下:
FDIGetFile 函数是从 cab 里提取文件内容写入提取的文件中的,当这个文件的字节数全部写完了,cb 就为 0,就会走 LABEL_13
删文件:
那问题就变成了怎么让文件内容写进去,且 cb 不为 0。经过调试分析后发现,当 cab 文件结构里 CFDATA->cbUncomp
的值比 CFFILE->cbFile
小时,cb 经过减法计算后就可以保持大于 0 的值,就不会走入 LABEL_13
。这个也很好理解,类似于程序理解为文件还没有写完,也就不会走 close 和 delete 了。
所以只用再把 CFFILE->cbFile
值改的比实际文件内容字节大即可:
成功跨路径写入文件并保留:
Word 场景加载触发
漏洞的核心逻辑已经分析完了,Word 打开文档触发利用的场景这里就留个坑不讲了。也许之后会补上。
参考资料
- DialogBoxParamW function (winuser.h) - Win32 apps | Microsoft Learn
- DLGPROC (winuser.h) - Win32 apps | Microsoft Learn
- AssocQueryStringW function (shlwapi.h) - Win32 apps | Microsoft Learn
- ASSOCSTR (shlwapi.h) - Win32 apps | Microsoft Learn
- LoadLibraryW function (libloaderapi.h) - Win32 apps | Microsoft Learn
- Remote Procedure Call Tutorial - Win32 apps | Microsoft Learn
- Fuzzing Windows RPC with RpcView | itm4n’s blog
- Microsoft Cabinet Format | Microsoft Learn
- PathCchCanonicalize function (pathcch.h) - Win32 apps | Microsoft Learn
- CreateFileA function (fileapi.h) - Win32 apps | Microsoft Learn