2018年5月,微软修补了一个有趣的漏洞(CVE-2018-0824),漏洞由微软安全响应中心 (MSRC)的Nicolas Joly报道:
当它无法正确处理序列化对象时,“Microsoft COM for Windows”中存在一个远程代码执行漏洞。
成功利用此漏洞的攻击者可以使用一个特制文件或脚本执行操作。在电子邮件攻击场景中,攻击者可以通过向用户发送专门制作的文件并诱使用户打开该文件来利用漏洞。在基于Web的攻击场景中,攻击者可以托管一个包含旨在利用此漏洞的特制文件的网站(或利用受损网站接受或托管用户提供的内容)。但是,攻击者无法强制用户访问该网站。相反,攻击者必须诱使用户点击链接,通常通过电子邮件或Instant Messenger消息中的诱饵,然后诱使用户打开特制文件。
此安全更新通过更正“Microsoft COM for Windows”处理序列化对象的方式来解决漏洞。
咨询发布后,关键字“COM”和“序列化”几乎一下就跃到我的眼前。由于去年我已经在Microsoft COM上花了几个月的研究时间,所以我决定研究一下它。虽然这个漏洞可以导致远程代码执行,但我只对特权升级方面感兴趣。
在我详细介绍之前,我想简单介绍一下COM以及反序列化/编组是如何工作的。由于我远不是COM专家,所有这些信息要么是基于Don Box的伟大著作《Essential COM》,要么是令人印象深刻的“COM in 60 seconds”。我略过了一些细节(IDL/MIDL、Apartments、Standard Marshalling等),只是为了让介绍简短。
COM和编组的介绍
COM(组件对象模型)是一个Windows中间件,具有可重用代码(=component)作为主要目标。为了开发可重复使用的C++代码,微软的工程师以面向对象的方式设计了COM,并考虑了以下几个关键方面:
l 可移植性
l 封装
l 多态性
l 接口与实现的分离
l 对象可扩展性
l 资源管理
l 语言独立性
COM对象由接口和实现类定义。接口和实现类都由GUID标识。一个COM对象可以使用继承来实现多个接口。
所有的COM对象都实现了IUnknown接口,它类似于C ++中的以下类定义(由GitHub托管iunknown.cpp):
class IUnknown |
|
{ |
|
public: |
|
virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObject) = 0; |
|
virtual ULONG STDMETHODCALLTYPE AddRef( void) = 0; |
|
virtual ULONG STDMETHODCALLTYPE Release( void) = 0; |
|
}; |
所述的QueryInterface() 方法用于将COM对象投射到COM对象实现的不同接口,AddRef() 和Release() 方法用于引用计数。
为了保持简短,我选择继续使用现有的COM对象,而不是创建一个人为的示例COM对象。
“控制面板”COM对象由GUID {06622D85-6856-4460-8DE1-A81921B41C4B}标识。想要了解有关COM对象的更多信息,我们可以手动分析注册表,或者使用伟大的工具“OleView .NET”。
“控制面板(Control Panel)”COM对象实现了几个接口,我们可以在OleView .NET的屏幕截图中看到:COM对象(COpenControlPanel)的实现类可以在shell32.dll中找到。
想要以编程方式打开“控制面板”,我们使用COM API(由GitHub 托管的rawcontrolpanel.cpp):
HRESULT hr = 0; |
|
GUID guidControlPanel = { 0x06622d85,0x6856,0x4460,{ 0x8d,0xe1,0xa8,0x19,0x21,0xb4,0x1c,0x4b } }; |
|
IUnknown* pUnknown = nullptr; |
|
IOpenControlPanel* pControlPanel = nullptr; |
|
|
|
hr = CoInitialize(0); |
|
hr = CoCreateInstance(guidControlPanel, NULL, CLSCTX_ALL, IID_IUnknown, (LPVOID*)&pUnknown); |
|
hr = pUnknown->QueryInterface(IID_IOpenControlPanel, (LPVOID*)&pControlPanel); |
|
hr = pControlPanel->Open(nullptr, nullptr, nullptr); |
l 在第6行我们初始化COM环境
l 在第7行中,我们创建了一个“控制面板”对象的实例
l 在第8行中,我们将实例转换到IOpenControlPanel接口
l 在第9行中,我们通过调用“打开(Open)”方法打开“控制面板”
运行后检查调试器中的COM对象,直到第9行向我们显示对象的虚拟函数表(vTable):
对象的vTable中的函数指针指向shell32.dll中的实际实现函数。原因是COM对象被创建为一个所谓的InProc服务器,这意味着shell32.dll 被加载到当前的进程地址空间中。当传递CLSCTX_ALL时,CoCreateInstance()会首先尝试创建一个InProc服务器。如果失败,则尝试其他激活方法(请参阅CLSCTX枚举)。
通过将CLSCTX_ALL参数更改为函数CoCreateInstance()到CLSCTX_LOCAL_SERVER,并再次运行程序,我们可以注意到一些差异:
对象的vTable现在包含来自OneCoreUAPCommonProxyStub.dll的函数指针。并且与Open()方法对应的第四个函数指针现在指向OneCoreUAPCommonProxyStub!ObjectStublessClient3()。
原因是我们将COM对象创建为进程外服务器。下图尝试给你一个架构概述(从Project Zero借用):
COM对象中的函数指针指向代理类的函数。当我们执行IOpenControlPanel::Open()方法时,方法OneCoreUAPCommonProxyStub!ObjectStublessClient3()在代理上被调用。代理类本身最终调用RPC方法(例如RPCRT4!NdrpClientCall3)将参数发送到RPC服务器进程外的服务器,这些参数需要被序列化/编组,以将它们发送到RPC;在进程外服务器中,参数被反序列化/解组,并且Stub调用shell32!COpenControlPanel::Open()。对于像字符串这样的非复杂参数,序列化/编组是很简单的,因为它们是按值发送的。
那么像COM对象这样的复杂参数呢?从IOpenControlPanel::Open()方法定义可以看出,第三个参数是一个指向IUnknown COM对象的指针(由GitHub托管的rawopen.cpp):
HRESULT Open( |
|
[in] LPCWSTR pszName, |
|
[in] LPCWSTR pszPage, |
|
[in] IUnknown *punkSite |
|
); |
答案是,一个复杂的对象可以通过引用(标准编组)或序列化/编组逻辑来进行编组,通过实现IMarshal接口(自定义编组)来定制。
IMarshal接口有几种方法,我们可以在下面的定义中看到(由GitHub托管的rawimarshal.cpp):
class IMarshal : public IUnknown |
|
{ |
|
public: |
|
virtual HRESULT STDMETHODCALLTYPE GetUnmarshalClass ( REFIID riid, void *pv, DWORD dwDestContext, void *pvDestContext, DWORD mshlflags, CLSID *pCid) = 0; |
|
virtual HRESULT STDMETHODCALLTYPE GetMarshalSizeMax ( REFIID riid, void *pv, DWORD dwDestContext, void *pvDestContext, DWORD mshlflags, DWORD *pSize) = 0; |
|
virtual HRESULT STDMETHODCALLTYPE MarshalInterface (IStream *pStm, REFIID riid, void *pv, DWORD dwDestContext, void *pvDestContext, DWORD mshlflags) = 0; |
|
virtual HRESULT STDMETHODCALLTYPE UnmarshalInterface (IStream *pStm, REFIID riid, void **ppv) = 0; |
|
virtual HRESULT STDMETHODCALLTYPE ReleaseMarshalData (IStream *pStm) = 0; |
|
virtual HRESULT STDMETHODCALLTYPE DisconnectObject (DWORD dwReserved) = 0; |
|
}; |
在COM对象的序列化/编组过程中,COM将调用IMarshal::GetUnmarshalClass()方法,返回用于解组的类的GUID。然后调用IMarshal::GetMarshalSizeMax()方法来为编组数据准备一个缓冲区。最后调用IMarshal::MarshalInterface ()将自定义编组数据写入IStream对象。COM运行时通过RPC将“Unmarshal类”和IStream对象的GUID发送到服务器。
在服务器上,COM运行时使用CoCreateInstance()函数创建“Unmarshal类” ,使用QueryInterface将其转换为IMarshal接口,并最终在“Unmarshal类”实例上调用IMarshsal::UnmarshalInterface()方法,将IStream作为参数传递。
这也是所有痛苦开始的地方……
分解补丁
在下载Windows 8.1 x64的补丁并解压文件后,我发现两个与微软COM相关的补丁DLL:
l oleaut32.dll
l comsvcs.dll
使用Hexray的IDA Pro和Joxean Koret的Diaphora我分析了微软所作的改变。
在oleaut32.dll中,几个函数被改变了,但与反序列化/编组无关:
在comsvcs.dll中,只有4个函数被修改:
显然,有一种方法与众不同:CMarshalInterceptor::UnmarshalInterface()。
CMarshalInterceptor::UnmarshalInterface()方法是IMarshal接口的UnmarshalInterface()方法的实现。正如我们在介绍中已经知道的那样,这个方法在反编组过程中被调用。
BUG
对Windows 10 Redstone 4(1803)进行了进一步的分析,包括3月补丁(来自MSDN的ISO)。在方法一开始,CMarshalInterceptor::UnmarshalInterface()将20个字节从IStream对象读入堆栈的缓冲区。
之后,将缓冲区中的字节与CMarshalInterceptor类的GUID (ECABAFCB-7F19-11D2-978E-0000F8757E2A)进行比较。如果流中的字节匹配,我们会到达函数CMarshalInterceptor::CreateRecorder()。
在函数CMarshalInterceptor::CreateRecorder()中调用COM-API函数ReadClassStm。该函数从IStream中读取CLSID(GUID)并将其存储到堆栈上的缓冲区中。然后将CLSID与CompositeMoniker的GUID进行比较。
IMoniker接口继承自IPersistStream接口,允许实现它的COM对象从/向一个IStream对象加载/保存自己。Monikers可以唯一标识对象,并可以通过调用IMoniker实例的BindToObject()方法来定位、激活和获取对象的引用。
如果CLSID与CompositeMoniker的GUID不匹配,我们就沿着右边的路径进行操作。在这里,使用从IStream中读取的CLSID作为第一个参数来调用COM-API函数CoCreateInstance()。如果COM找到特定的类并且能够将其转换为IMoniker接口,我们就可以到达下一个基本块。接下来,在新创建的实例上调用IPersistStream::Load()方法,该实例从IStream对象恢复保存的Moniker状态。
最后,我们调用BindToObject()来触发所有的邪恶……
利用这个BUG
为了开发,我遵循了Project Zero在bug追踪器问题“DCOM DCE/RPC Local NTLM Reflection Elevation of Privilege” 中描述的相同方法。
我正在创建一个实现IStorage和IMarshal接口的伪COM对象类。
IStorage接口的所有实现方法都将被转发到一个真实的IStorage实例,稍后我们将看到这一点。由于我们正在实现自定义编组,因此COM运行时需要知道哪个类将用于反序列化/解组伪对象。因此,COM运行时调用IMarshal::GetUnmarshalClass()。触发Moniker,我们只需要返回“QC Marshal Interceptor Class”类(ECABAFCB-7F19-11D2-978E-0000F8757E2A)的GUID。
最后一步是实现IMarshal::MarshalInterface()方法。正如你已经知道,COM运行时调用的该方法将一个对象编组到IStream中。
想要触发对IMoniker::BindToObject()的调用,我们只需将所需的字节写入IStream对象,以满足CMarshalInterceptor::UnmarshalInterface()中的所有条件。
我尝试使用CoCreateInstance()创建CLSID {06290BD3-48AA-11D2-8432-006008C3FBFC}的Script Moniker COM对象。但是,嘿,我得到了一个“REGDB_E_CLASSNOTREG”错误代码。看来微软引入了一些变化。显然,Script Moniker将不再工作。所以我想用“URLMoniker/hta file”来利用这个bug。但幸运的是,我记得在CMarshalInterceptor::CreateRecorder()方法中,我们检查了CompositeMoniker CLSID。
因此,沿着左边的路径,我们有一个基本块,其中从流中读取4个字节到堆栈缓冲区(var_78)。接下来,我们调用CMarshalInterceptor::LoadAndCompose(),使用IStream 、一个指向IMoniker接口指针的指针以及来自堆栈缓冲区的值作为参数。
在此方法中,使用OleLoadFromStream() COM-API函数从IStream读取并创建一个IMoniker实例。在该方法的后面,递归地调用CMarshalInterceptor::LoadAndCompose()来组成一个CompositeMoniker。通过调用IMoniker::ComposeWith(),一个新的IMoniker被创建为两个标记的组合。指向新的CompositeMoniker的指针将被存储在传递给当前函数的指针中作为参数。正如我们在前面的截图中看到的那样,稍后将在CompositeMoniker上调用BindToObject()方法。
我记得Haifei Li’s 的博客文章中有一种方法,可以通过编写一个File Moniker和一个New Moniker来创建一个Script Moniker。在掌握了这些知识之后,我实现了IMArSal::MARSHALTIOFACE()方法的最后一部分。
我将一个SCT文件放在“c:temppoc.sct”中,该文件从ActiveXObject运行记事本。然后,我尝试将BITS作为目标服务器,但无法正常工作。
使用OleView .NET我发现BITS不支持自定义编组(请参阅EOAC_NO_CUSTOM_MARSHAL)。但SearchIndexer与CLSID {06622d85-6856-4460-8de1-a81921b41c4b}服务被作为SYSTEM运行,并允许自定义编组。
因此我创建了一个PoC,它具有以下main()函数。
对CoGetInstanceFromIStorage()的调用将激活目标COM服务器并触发FakeObject实例的序列化。由于COM-API函数需要IStorage作为参数,我们必须在我们的FakeObject类中实现IStorage接口。
运行POC后,我们终于有了一个“notepad.exe”作为SYSTEM运行。
POC可以在我们的github上找到。
补丁
微软现在正在检查从线程本地存储中读取的flag。flag设置在与编组无关的不同方法中。如果未设置flag,函数cMARSHALLCTORK::unMARHALLATFACE()将在不从istRAMAM读取任何内容的情况下提前退出。
审核人:yiwang 编辑:边边
发表评论
您还未登录,请先登录。
登录