CVE-2021-40444 Windows MSHTML 远程命令执行漏洞分析 [2]

概述

本文是对 CVE-2021-40444 Windows MSHTML 远程命令执行漏洞分析 [1] 内容的续写。

漏洞分析

IE 下的安全警告弹窗问题

ShellExecuteExW 函数处理时 uri 协议名和文件后缀名是如何处理混淆的已经搞清楚了,接下来看看 IE 里的情况。IE 里执行如下 JS:

1
2
3
var i = document.createElement("iframe");
document.documentElement.appendChild(i);
i.src = ".txt:../../../../../fuckyou.abc";

相比于 ShellExecuteExW 函数直接 open,IE 下会出现弹窗告警:

image-20241119165227984

测试发现,iframe url 不管是 .txt 还是任意的无关联程序的后缀 .wtf 作为协议开头,都会先出现安全告警弹框,只有点击“确认”后,关联程序才会执行。然而漏洞利用里所用到的 .cpl 却不会。这是为什么?

WinDBG 里给 IEFRAME.dll 里带 Dialog 关键词的函数批量上断点:

1
bm ieframe!*Dialog*

IE 里触发告警弹框前,命中了 IEFRAME!CProtocolWarnDlg::ShowProtocolWarnDialog 这个函数,函数执行完后成功触发告警弹框,说明就是这附近了。

image-20241120113735398

调用栈:

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
29
30
31
32
33
Breakpoint 190 hit
IEFRAME!CProtocolWarnDlg::ShowProtocolWarnDialog:
00007fff`63d0f59c 48895c2408 mov qword ptr [rsp+8],rbx ss:000000e3`5d3fe140=00000000800704c7
0:009> k
# Child-SP RetAddr Call Site
00 000000e3`5d3fe138 00007fff`64046b52 IEFRAME!CProtocolWarnDlg::ShowProtocolWarnDialog
01 000000e3`5d3fe140 00007fff`64045e06 IEFRAME!CHandlerActivationHost::_ShowProtocolPrompt+0x27a
02 000000e3`5d3fe240 00007fff`9bd06eff IEFRAME!CHandlerActivationHost::BeforeCreateProcess+0x66
03 000000e3`5d3fe270 00007fff`9bc1d677 windows_storage!CInvokeCreateProcessVerb::NotifyBeforeCreateProcess+0xea05f
04 000000e3`5d3fe2d0 00007fff`9bc18e54 windows_storage!CInvokeCreateProcessVerb::_PrepareAndCallCreateProcess+0x29b
05 000000e3`5d3fe350 00007fff`9bc1778b windows_storage!CInvokeCreateProcessVerb::_TryCreateProcess+0x3c
06 000000e3`5d3fe380 00007fff`9bc1740d windows_storage!CInvokeCreateProcessVerb::Launch+0xef
07 000000e3`5d3fe420 00007fff`9bc1c4b5 windows_storage!CInvokeCreateProcessVerb::Execute+0x5d
08 000000e3`5d3fe460 00007fff`9bc18c2c windows_storage!CBindAndInvokeStaticVerb::InitAndCallExecute+0x161
09 000000e3`5d3fe4e0 00007fff`9bc1dbf7 windows_storage!CBindAndInvokeStaticVerb::TryCreateProcessDdeHandler+0x60
0a 000000e3`5d3fe560 00007fff`9bc1addd windows_storage!CBindAndInvokeStaticVerb::Execute+0x1e7
0b 000000e3`5d3fe880 00007fff`9bc1acf5 windows_storage!RegDataDrivenCommand::_TryInvokeAssociation+0xad
0c 000000e3`5d3fe8e0 00007fff`9f7858b2 windows_storage!RegDataDrivenCommand::_Invoke+0x141
0d 000000e3`5d3fe950 00007fff`9f78576a SHELL32!CRegistryVerbsContextMenu::_Execute+0xce
0e 000000e3`5d3fe9c0 00007fff`9f736bbc SHELL32!CRegistryVerbsContextMenu::InvokeCommand+0xaa
0f 000000e3`5d3fecc0 00007fff`9f736a3d SHELL32!HDXA_LetHandlerProcessCommandEx+0x10c
10 000000e3`5d3fedd0 00007fff`9f78543b SHELL32!CDefFolderMenu::InvokeCommand+0x13d
11 000000e3`5d3ff130 00007fff`9f785313 SHELL32!CShellExecute::_InvokeInProcExec+0xfb
12 000000e3`5d3ff230 00007fff`9f75f9e1 SHELL32!CShellExecute::_InvokeCtxMenu+0x5b
13 000000e3`5d3ff270 00007fff`9f762290 SHELL32!CShellExecute::_DoExecute+0x151
14 000000e3`5d3ff2e0 00007fff`9f762e0b SHELL32!CShellExecute::ExecuteNormal+0x1fc
15 000000e3`5d3ff4c0 00007fff`9f76264e SHELL32!ShellExecuteNormal+0xa3
16 000000e3`5d3ff520 00007fff`64045f8e SHELL32!ShellExecuteExW+0xde
17 000000e3`5d3ff6c0 00007fff`63cce8f2 IEFRAME!CShellExecWithHandlerParams::Execute+0xca
18 000000e3`5d3ff760 00007fff`9eb1d9a0 IEFRAME!BrokerShellExecWithHandlerThreadProc+0x162
19 000000e3`5d3ff7c0 00007fff`9ed57034 shcore!_WrapperThreadProc+0x1a0
1a 000000e3`5d3ff8a0 00007fff`a025cec1 KERNEL32!BaseThreadInitThunk+0x14
1b 000000e3`5d3ff8d0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

通过 DialogBoxParamW API 注册 CProtocolWarnDlg::s_ProtocolWarnDialogProc 作为 DLGPROC 回调函数:

image-20241120131529935

image-20241120131622392

CProtocolWarnDlg::s_ProtocolWarnDialogProc 执行到 CProtocolWarnDlg::_ProtocolWarnDialogProc,会根据不同的 message 值走入不同的分支处理:

image-20241120132537674

当 messag 为 WM_INITDIALOG(0x0110)时,进入 CProtocolWarnDlg::_OnInitDialog 函数,做一些对话框初始化的工作:

image-20241120132812281

注:关于 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):

image-20241120143040381

这里 AssocQueryStringW 的 ASSOCSTR 参数值为 ASSOCSTR_EXECUTABLE,那看看官方文档里是怎么写的:

image-20241120143530451

官方文档里已经写了注意提示:“不是所有的应用都关联有对应的可执行文件,不要假定可执行文件一定会存在”。

所以来看看 .txt 在注册表里的情况,HKEY_CLASSES_ROOT\.txt(Default) 值为 txtfile

image-20241120143941585

所以再去查 HKEY_CLASSES_ROOT\txtfile\shell\open\command

image-20241120144020310

能查到关联命令,所以对于 .txt:../../../../../fuckyou.abc uri 而言,AssocQueryStringW ASSOCSTR_EXECUTABLE 能查到返回值,接着进行对话框处理。

再来看看 .cpl 在注册表里的情况,HKEY_CLASSES_ROOT\.cpl(Defalt) 值为 cplfile

image-20241120144238815

HKEY_CLASSES_ROOT\cplfile\shell\open\command 这一项是没有的,它子键名还不太一样,并不是 open,而是 cplopen

image-20241120144312072

因此对于 .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 参数,然后比较判断是否为已知的参数列表:

image-20241120165635012

如果都不是,并且也不是 NETCONNECTIONS 或者 ncpa.cpl,再比较判断是否为注册表里的几个名字:

image-20241120165954668

image-20241120170013516

若以上仍都不匹配,命令行参数拷贝到 Shell32.dll,Control_RunDLL 后面作为参数,执行 rundll32.exe:

image-20241120170118848

故会从:

1
"C:\Windows\System32\control.exe" ".cpl:../../../../../fuckyou.abc",

变成执行:

1
"C:\Windows\system32\rundll32.exe" Shell32.dll,Control_RunDLL ".cpl:../../../../../fuckyou.abc",

最终参数会传递给 LoadLibraryW 函数调用,进行 dll 加载:

image-20241120180526212

调用栈:

1
2
3
4
5
6
7
8
9
 # Child-SP          RetAddr               Call Site
00 000000e8`eb3ae780 00007fff`9f928c3b Shell32!CPL_LoadCPLModule+0x184
01 000000e8`eb3aea60 00007fff`9f928727 Shell32!LoadAndFindApplet+0x4b
02 000000e8`eb3aed10 00007fff`9f929a68 Shell32!CPL_SwitchToOrLaunch+0x2af
03 000000e8`eb3af620 00007ff6`63e142eb Shell32!Control_RunDLLW+0x38
04 000000e8`eb3afb40 00007ff6`63e16769 rundll32!wWinMain+0x2ef
05 000000e8`eb3afdb0 00007fff`9ed57034 rundll32!__wmainCRTStartup+0x1c9
06 000000e8`eb3afe70 00007fff`a025cec1 KERNEL32!BaseThreadInitThunk+0x14
07 000000e8`eb3afea0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

对于 .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 函数里设置的:

image-20241203185354589

CCacheServerContainer::GetNextDirectory 生成随机目录名字符串,然后通过 CCacheClientFileManager::SetSecureDirectory 将它赋值给 CCacheClientFileManager 结构体 +0x28 偏移处的成员。跟进 CCacheServerContainer::GetNextDirectory,发现这个随机目录名是通过调用 RPC 方法生成的:

image-20241203185559348

这里先介绍下 Windows RPC 的一些基础知识。Windows RPC(Remote Procedure Call)是微软实现的一种通信协议,允许分布式系统中的不同进程之间通过网络或本地通信进行数据交换和功能调用。RPC 的主要特点是通过隐藏底层网络通信细节,使调用远程过程看起来像调用本地过程一样简单:

image-20241211175225264

关于 RPC,更多内容可以参考微软官方文档。RPC 底层实现和具体原理暂不关注,这里主要是需要搞清楚怎么去找到程序所调用的 RPC 服务端注册的接口方法。问了下 ChatGPT,它告诉我,RPC 调用通常基于接口 UUID 和方法编号,所以关键是找到这俩东西

NdrClientCall4 函数签名如下:

1
2
3
4
5
CLIENT_CALL_RETURN RPC_VAR_ENTRY NdrClientCall4(
[in] PMIDL_STUB_DESC pStubDescriptor,
[in] PFORMAT_STRING pFormat,
...
);

相关结构体定义如下(可以参照微软官方教程写个简单的 idl 编译看看对着参考):

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
29
typedef const MIDL_STUB_DESC *PMIDL_STUB_DESC;

typedef struct _MIDL_STUB_DESC MIDL_STUB_DESC;

#pragma pack(push, 8)
struct _MIDL_STUB_DESC
{
void *RpcInterfaceInformation;
void *(__stdcall *pfnAllocate)(size_t);
void (__stdcall *pfnFree)(void *);
// ......
};
#pragma pack(pop)

typedef struct _RPC_CLIENT_INTERFACE
{
unsigned int Length;
RPC_SYNTAX_IDENTIFIER InterfaceId;
RPC_SYNTAX_IDENTIFIER TransferSyntax;
PRPC_DISPATCH_TABLE DispatchTable;
// ......
} RPC_CLIENT_INTERFACE, __RPC_FAR * PRPC_CLIENT_INTERFACE;

typedef struct _RPC_SYNTAX_IDENTIFIER {
GUID SyntaxGUID;
RPC_VERSION SyntaxVersion;
} RPC_SYNTAX_IDENTIFIER, __RPC_FAR * PRPC_SYNTAX_IDENTIFIER;

typedef const unsigned __int8 *PFORMAT_STRING;

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CLIENT_CALL_RETURN RPC_VAR_ENTRY NdrClientCall3(
MIDL_STUBLESS_PROXY_INFO *pProxyInfo,
unsigned long nProcNum,
void *pReturnValue,
...
);

typedef struct _MIDL_STUBLESS_PROXY_INFO MIDL_STUBLESS_PROXY_INFO;

#pragma pack(push, 8)
struct _MIDL_STUBLESS_PROXY_INFO
{
PMIDL_STUB_DESC pStubDesc;
PFORMAT_STRING ProcFormatString;
const unsigned __int16 *FormatStringOffset;
PRPC_SYNTAX_IDENTIFIER pTransferSyntax;
ULONG_PTR nCount;
PMIDL_SYNTAX_INFO pSyntaxInfo;
};
#pragma pack(pop)

第一个参数 pProxyInfo 的 pStubDesc 成员就是 指向 MIDL_STUB_DESC 的指针,找接口 uuid 和上面一样。而第二个参数 nProcNum 就是接口的方法编号。

分析后发现,CCacheServerContainer::GetNextDirectory 这里调用的 RPC 接口 uuid 是 cad784cb-4c1b-4d96-b8f7-4716b568b13c,方法编号是 0x19。

找到接口 uuid 和方法编号后,可以通过 RpcView 来定位服务端的实现位置。Interfaces 窗口里搜 uuid,直接定位到接口实现的 dll 和相关的函数内存地址:

image-20241212123652100

Procedures 窗口里找到对应的方法编号的地址,算下内存偏移就是了。

RpcView 虽然不像 IDA 那样可以自动下载符号信息,但也支持配置符号路径,这样看起来更直观。先用 symchk 下载符号信息(symchk 是 Windows SDK 里的工具,没有的话先下载 Windows SDK):

1
2
:: 下载 C:\Windows\System32\wininet.dll 的符号保存到 C:\Symbols 目录下
"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\symchk.exe" /s srv*C:\Symbols*https://msdl.microsoft.com/download/symbols C:\Windows\System32\wininet.dll

然后 RpcView 里 Options - Config symbols:

image-20241212124415500

配置好后重启 RpcView,看起来就更直观了:

image-20241212124511718

所以 wininet!CCacheServerContainer::GetNextDirectory 里的 RPC 调用是去调了 wininet!s_UrlCacheGetNextDirectory。跟进后定位到,随机目录名是通过 wininet!MakeRandomName 函数实现的:

image-20241212161738081

注:虽然还是在 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 成员:

image-20241219105325601

看到这里,了解过 zip 解压穿越漏洞的自然就会考虑,如果 cab 被解压,cab 里面的文件名被拼接到系统路径时,是否可能存在路径穿越的问题?

我们先把这个 cab 丢到 ie 里加载看看:

1
2
3
var obj = document.createElement("object");
obj.setAttribute("codebase", window.location.origin + "/test.cab#version=5,0,0,0");
obj.setAttribute("classid", "CLSID:aca50fdc-7a68-43d2-a06f-26d958b88a56");

Procmon 监控,发现除了生成了 IE 的临时缓存文件外,还在 Temp 下生成了另一个随机文件:

image-20241219105831273

调用栈:

image-20241219110107014

根据调用栈可以推理出来,urlmon 这里对下载的文件进行信任校验时,会先看看是否可以从文件里提取出 Install Scope。所谓 Install Scope 就是应用安装的范围是给所有用户(Machine Scope),还是仅当前用户(User Scope)。

不过 GetSupportedInstallScopesFromFile 函数从文件里提取 Install Scope 时是有判断条件的:

image-20241219111414672

GetSupportedInstallScopesFromFile 函数会先看文件名后缀是否为 cab,不是 cab 的话就不会从中提取。而如果是 cab 后缀的话,则会新建一个 cab 临时目录,并尝试将 cab 压缩包里的 .inf 后缀文件解压到该目录下,之后从这个 inf 里读取出 Install Scope。处理完后,将该 inf 文件和 cab 临时目录删除。

所以关键就看 inf 是被如何解压提取的了。

从 cab 中解压提取文件是通过调用 cabinet.dll 里的 FDICopy 方法来进行的,并且调用这个方法需要传入一个回调函数,这里回调函数设置的是 urlmon!fdiNotifyExtract

image-20241219113435436

cabinet!FDICopy 里,传参 fdintCOPY_FILE 调用回调函数:

image-20241219113758779

urlmon!fdiNotifyExtract 回调函数里,先通过调用 catDirAndFile 函数将 cab 里的压缩项文件名拼接和临时目录拼接得到完整的绝对路径,然后通过 Win32Open 调用 CreateFileA 打开文件,获取文件 handle:

image-20241219114029250

urlmon!catDirAndFile 里进行路径拼接时其实是有做路径规范化处理和路径穿越检查的:

image-20241219114354508

但问题就在于,它这里调用的 PathCchCanonicalizeA 在做路径规范化时,是不会处理 \ 斜线的。

PathCchCanonicalize 官方文档里也写了:

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 是会处理斜线的:

image-20241219115311169

image-20241219115445646

所以因为这个文件路径解析上的差异,导致通过构造 cab 文件结构 CFFILE 里的 szName 为诸如 ../../test.inf 的文件名,就可以路径穿越了。

不过只是这样的话还有个问题,尽管路径穿越写进去了,但很快就会在 urlmon!DeleteExtractedFiles 函数调用里被删掉了:

image-20241219120135133

urlmon!DeleteExtractedFiles 里 if 分支判断为 true 需要满足 3 个条件,catDirAndFile 和 SetFieAttributesA 都是返回 true 的,没什么想法,至于 cab->entry->unextracted,它一开始默认的值是为 1 的:

image-20241219133541228

但在解压提取出文件后,这个值就被设置为 0 了:

image-20241219133654971

所以关键看看能不能让 cab->entry->unextracted 保持为 1 了。urlmon!MarkExtracted 里调用 StrCmpIIA 比较文件名肯定是会匹配到的,所以要让 unextracted 为1,就只能去思考怎么避免调用到 MarkExtracted 函数。

MarkExtracted 函数是在 FDICopy 注册的回调函数 fdiNotifyExtract 中被调用到的,当传入的 type 为 fdintCLOSE_FILE_INFO 就会走进来:

image-20241219141235926

找下在哪个地方传参 fdintCLOSE_FILE_INFO 调用的回调函数,是在 cabinet!FDIGetFileLABEL_13 下:

image-20241219141520895

FDIGetFile 函数是从 cab 里提取文件内容写入提取的文件中的,当这个文件的字节数全部写完了,cb 就为 0,就会走 LABEL_13 删文件:

image-20241219144551582

那问题就变成了怎么让文件内容写进去,且 cb 不为 0。经过调试分析后发现,当 cab 文件结构里 CFDATA->cbUncomp 的值比 CFFILE->cbFile 小时,cb 经过减法计算后就可以保持大于 0 的值,就不会走入 LABEL_13。这个也很好理解,类似于程序理解为文件还没有写完,也就不会走 close 和 delete 了。

所以只用再把 CFFILE->cbFile 值改的比实际文件内容字节大即可:

image-20241219145403450

成功跨路径写入文件并保留:

image-20241219145706285

Word 场景加载触发

漏洞的核心逻辑已经分析完了,Word 打开文档触发利用的场景这里就留个坑不讲了。也许之后会补上。

参考资料