Windows Bug Class:使用 IDispatch 访问受陷阱保护的 COM 对象
作者:James Forshaw,Google Project Zero
面向对象的远程处理技术,如DCOM和.NET Remoting,使得开发能够跨越进程和安全边界的面向对象服务接口变得非常容易。这是因为它们被设计为支持广泛的对象,不仅包括服务中实现的对象,还包括任何其他兼容远程处理的对象。例如,如果你想在客户端-服务器边界上公开一个XML文档,你可以使用预先存在的COM或.NET库,并将该对象返回给客户端。默认情况下,当对象被返回时,它是通过引用进行封送(marshaled by reference)的,这导致对象停留在进程外服务器中。
这种灵活性有许多缺点,其中之一就是本文的主题——被困对象(trapped object)漏洞类别。并非所有可以远程处理的对象都必然是安全的。例如,前面提到的XML库(包括COM和.NET)都支持在XSLT文档的上下文中执行任意脚本代码。如果XML文档对象可以通过边界访问,那么客户端就可以在服务器进程的上下文中执行代码,这可能导致权限提升或远程代码执行。
有多种场景可能引入此类漏洞。最常见的情况是不安全对象被无意中共享。一个例子是CVE-2019-0555。这个漏洞的出现是因为在开发Windows Runtime库时需要XML文档对象。开发者决定在现有的XML DOM Document v6 COM对象中添加一些代码,以公开运行时特定的接口。由于这些运行时接口不支持XSLT脚本功能,因此假设在跨越权限边界时公开是安全的。不幸的是,恶意客户端可以查询仍然可访问的旧IXMLDOMDocument接口,并使用它来运行XSLT脚本,从而逃逸沙箱。
另一种情况是存在异步封送(asynchronous marshaling)原语。在这种情况下,对象既可以通过值(by value)也可以通过引用(by reference)进行封送,而平台默认选择通过引用作为默认机制。例如,FileInfo和DirectoryInfo这两个.NET类都是可序列化的,因此可以通过值封送发送到.NET远程处理服务。但它们也继承自MarshalByRefObject类,这意味着它们也可以通过引用进行封送。攻击者可以利用这一点,向服务器发送对象的序列化形式,当服务器反序列化时,将在服务器进程中创建对象的新实例。如果攻击者可以读回创建的对象,运行时将通过引用将其封送回攻击者,使对象被困在服务器进程中。最后,攻击者可以调用对象上的方法,例如创建新文件,这些操作将以服务器的权限执行。这种攻击在我的ExploitRemotingService工具中实现。
我将提到的最后一种情况与本文最相关,即滥用远程处理技术用于查找和实例化对象的内置机制,以创建意外对象。例如,在COM中,如果你能找到一条代码路径,使用任意CLSID调用CoCreateInstance API,并将该对象传递回客户端,那么你就可以利用它在服务器上下文中运行任意代码。这种形式的一个例子是CVE-2017-0211,这是一个跨越安全边界公开结构化存储(Structured Storage)对象的漏洞。存储对象支持IPropertyBag接口,该接口可用于在服务器上下文中创建任意COM对象,并将其返回给客户端。这可以通过在服务器中创建XML DOM Document对象,通过引用封送回客户端,然后使用XSLT脚本功能在服务器上下文中运行任意代码以提升权限来利用。
IDispatch 的作用是什么?
IDispatch接口是OLE自动化(OLE Automation)功能的一部分,这是COM的原始用例之一。它允许COM客户端在运行时与服务器进行后期绑定(late binding),以便可以从VBA和JScript等脚本语言使用该对象。该接口在进程和权限边界之间完全受支持,尽管它更常用于进程内组件,如ActiveX。
为了便于在运行时调用COM对象,服务器必须向客户端公开一些类型信息,以便客户端知道如何打包参数以通过接口的Invoke方法发送。类型信息存储在开发人员定义的磁盘上的类型库(Type Library)文件中,客户端可以使用IDispatch接口的GetTypeInfo方法查询该库。由于类型库接口的COM实现是通过引用封送的,返回的ITypeInfo接口被困在服务器中,对其调用的任何方法都将在服务器的上下文中执行。
ITypeInfo接口公开了两个客户端可以调用的有趣方法:Invoke和CreateInstance。事实证明,Invoke对我们的目的来说并不那么有用,因为它不支持远程处理,只有在当前进程中加载了类型库时才能调用。然而,CreateInstance被实现为可远程调用的,这将通过调用CoCreateInstance从CLSID实例化一个COM对象。关键的是,创建的对象将在服务器的进程中,而不是客户端。
但是,如果你查看链接的API文档,会发现没有可以传递给CreateInstance的CLSID参数,那么类型库接口如何知道要创建什么对象呢?ITypeInfo接口表示类型库中可以存在的任何类型。GetTypeInfo返回的类型只包含有关客户端要调用的接口的信息,因此调用CreateInstance只会返回错误。然而,类型库也可以存储"CoClass"类型的信息。这些类型定义了要创建的对象的CLSID,因此调用CreateInstance将会成功。
我们如何从接口类型信息对象转到表示类的对象呢?ITypeInfo接口为我们提供了GetContainingTypeLib方法,该方法返回对包含的ITypeLib接口的引用。然后可以使用该接口枚举类型库中所有支持的类。如果远程公开,一个或多个类可能不安全。让我们通过使用我的OleView.NET PowerShell模块来演练一个实际示例。首先,我们想找到一些同时支持IDispatch的目标COM服务。这将为我们提供潜在的权限提升途径。
PS> $cls = Get-ComClass -Service
PS> $cls | % { Get-ComInterface -Class $_ | Out-Null }
PS> $cls | ? { $true -in $_.Interfaces.InterfaceEntry.IsDispatch } |
Select Name, Clsid
Name Clsid
---- -----
WaaSRemediation 72566e27-1abb-4eb3-b4f0-eb431cb1cb32
Search Gathering Manager 9e175b68-f52a-11d8-b9a5-505054503030
Search Gatherer Notification 9e175b6d-f52a-11d8-b9a5-505054503030
AutomaticUpdates bfe18e9c-6d87-4450-b37c-e02f0b373803
Microsoft.SyncShare.SyncShareFactory Class da1c0281-456b-4f14-a46d-8ed2e21a866f
Get-ComClass的-Service开关返回在本地服务中实现的类。然后我们查询所有支持的接口,我们不需要此命令的输出,因为查询的接口存储在Interfaces属性中。最后,我们筛选出任何公开IDispatch的COM类,得到5个候选类。接下来,我们将选择第一个类WaasRemediation,并检查其类型库以寻找有趣的类。
PS> $obj = New-ComObject -Clsid 72566e27-1abb-4eb3-b4f0-eb431cb1cb32
PS> $lib = Import-ComTypeLib -Object $obj
PS> Get-ComObjRef $lib.Instance | Select ProcessId, ProcessName
ProcessId ProcessName
--------- -----------
27020 svchost.exe
PS> $parsed = $lib.Parse()
PS> $parsed
Name Version TypeLibId
---- -------- ---------
WaaSRemediationLib 1.0 3ff1aab8-f3d8-11d4-825d-00104b3646c0
PS> $parsed.Classes | Select Name, Uuid
Name Uuid
---- ----
WaaSRemediationAgent 72566e27-1abb-4eb3-b4f0-eb431cb1cb32
WaaSProtectedSettingsProvider 9ea82395-e31b-41ca-8df7-ec1cee7194df
脚本创建COM对象,然后使用Import-ComTypeLib命令获取类型库接口。我们可以通过使用Get-ComObjRef封送它,然后提取进程信息来检查类型库接口是否真的在进程外运行,结果显示它在svchost.exe实例中运行,这是共享服务可执行文件。通过接口检查类型库很麻烦,为了更容易显示支持的类,我们可以使用Parse方法将库解析为更易于使用的对象模型。然后我们可以转储库的信息,包括其类的列表。
不幸的是,对于这个COM对象,类型库支持的唯一类已经注册在服务中运行,因此我们一无所获。我们需要的是一个仅在本地进程中注册运行,但由类型库公开的类。这是可能的,因为类型库可以同时由本地进程内组件和进程外服务共享。
我检查了其他4个COM类(其中一个注册不正确,未被相应服务公开),没有找到有用的类来尝试利用。此时你可能会决定放弃,但事实证明有一些可访问的类,它们只是被隐藏了。这是因为一个类型库可以引用其他类型库,这些库可以使用相同的接口集进行检查。让我们看看:
PS> $parsed.ReferencedTypeLibs
Name Version TypeLibId
---- ------- ----------
stdole 2.0 00020430-0000-0000-c000-000000000046
PS> $parsed.ReferencedTypeLibs[0].Parse().Classes | Select Name, Uuid
Name Uuid
---- ----
StdFont 0be35203-8f91-11ce-9de3-00aa004bb851
StdPicture 0be35204-8f91-11ce-9de3-00aa004bb851
PS> $cls = Get-ComClass -Clsid 0be35203-8f91-11ce-9de3-00aa004bb851
PS> $cls.Servers
Key Value
--- -----
InProcServer32 C:\Windows\System32\oleaut32.dll
在示例中,我们可以使用ReferencedTypeLibs属性来显示解析库时遇到哪些类型库。我们可以看到stdole的单个条目,这基本上总是会被导入。如果你幸运的话,可能还有其他导入的库可以检查。我们可以解析stdole库以检查其类列表。类型库导出了两个类,如果我们检查StdFont的服务器,可以看到它只被指定为在进程中创建,现在我们有了一个寻找漏洞的目标类。要获取stdole类型库的进程外接口,我们需要找到一个引用它的类型。引用的原因是,诸如IUnknown和IDispatch之类的通用接口是在库中定义的,因此我们需要查询可以直接访问的接口的基类型。让我们尝试在COM服务中创建对象。
PS> $iid = $parsed.Interfaces[0].Uuid
PS> $ti = $lib.GetTypeInfoOfGuid($iid)
PS> $href = $ti.GetRefTypeOfImplType(0)
PS> $base = $ti.GetRefTypeInfo($href)
PS> $stdole = $base.GetContainingTypeLib()
PS> $stdole.Parse()
Name Version TypeLibId
---- ------- ---------
stdole 2.0 00020430-0000-0000-c000-000000000046
PS> $ti = $stdole.GetTypeInfoOfGuid("0be35203-8f91-11ce-9de3-00aa004bb851")
PS> $font = $ti.CreateInstance()
PS> Get-ComObjRef $font | Select ProcessId, ProcessName
ProcessId ProcessName
--------- -----------
27020 svchost.exe
PS> Get-ComInterface -Object $Obj
Name IID HasProxy HasTypeLib
---- --- -------- ----------
...
IFont bef6e002-a874-101a-8bba-00aa00300cab True False
IFontDisp bef6e003-a874-101a-8bba-00aa00300cab True True
我们通过GetRefTypeOfImplType和GetRefTypeInfo的组合查询现有接口的基类型,然后使用GetContainingTypeLib获取引用的类型库接口。我们可以解析该库以确认我们获得了stdole库。接下来,我们获取StdFont类的类型信息并调用CreateInstance。我们可以检查对象的进程以确保它是在进程外创建的,结果显示它被困在服务进程中。作为最终检查,我们可以查询对象的接口以证明它是一个字体对象。
现在我们只需要找到一种方法来利用这两个类中的任何一个,第一个问题是只能访问StdFont对象。StdPicture对象会进行检查以防止在进程外使用。我在字体对象中没有找到有用的可利用行为,但我没有花太多时间寻找。当然,如果有人想在该类中寻找合适的漏洞,请自便。
因此,这项研究陷入了死胡同,至少就系统服务而言是这样。可能有一些可以从沙箱访问的COM服务器,但对可从AppContainer访问的服务器进行的初步分析没有显示任何明显的候选者。然而,在进一步思考之后,我意识到它可以作为一种注入技术,注入到以相同权限级别运行的进程中。例如,我们可以劫持StdFont的COM注册,使用TreatAs注册表键指向任何其他类。这个其他类将是可利用的,例如将JScript引擎加载到目标进程中并运行脚本。
尽管如此,注入技术通常不是我在这个博客上讨论的内容,那更像是恶意软件的领域。然而,有一种情况可能具有有趣的安全影响。如果我们能利用它注入到Windows受保护进程(Windows Protected Process)中呢?命运的一个奇怪转折是,我们刚刚检查的WaaSRemediationAgent类可能就是我们的入场券:
PS> $cls = Get-ComClass -Clsid 72566e27-1abb-4eb3-b4f0-eb431cb1cb32
PS> $cls.AppIDEntry.ServiceProtectionLevel
WindowsLight
当我们检查托管服务的保护级别时,它被配置为在PPL-Windows级别运行!让我们看看是否能从这项研究中挽救一些价值。
受保护进程注入
我之前曾撰文(并做过演讲)讨论过注入Windows受保护进程的话题。我建议重新阅读那篇博客文章,以更好地了解之前的注入攻击背景。然而,一个关键点是,微软不认为PPL是一个安全边界,因此他们通常不会在安全公告中及时修复任何漏洞,但他们可能会选择在新版本的Windows中修复它。
想法很简单,我们将重定向StdFont类注册以指向另一个类,这样当我们通过类型库创建它时,它将在受保护进程中运行。选择使用StdFont应该更通用,因为如果WaaSRemediationAgent被移除,我们可以转而使用不同的COM服务器。我们只需要一个合适的类,它能在受保护进程中运行并给我们任意代码执行能力。
不幸的是,这立即排除了任何像JScript这样的脚本引擎。如果你重新阅读了我的上一篇博客文章,代码完整性(Code Integrity)模块明确阻止了常见脚本引擎在受保护进程中加载。相反,我需要一个可以在进程外访问并且可以加载到受保护进程中的类。我意识到一个选择是加载一个已注册的.NET COM类。我曾撰文讨论过.NET DCOM如何可利用,以及不应使用它,但在这种情况下,我们恰恰需要它的漏洞。
那篇博客文章讨论了利用序列化原语,然而有一种更简单的攻击,我通过使用DCOM上的System.Type类来利用。通过访问Type对象,你可以执行任意反射并调用任何你喜欢的方法,包括从字节数组加载程序集,这将绕过签名检查并完全控制受保护进程。
微软修复了这种行为,但他们留下了一个配置值AllowDCOMReflection,允许你重新启用它。由于我们不是提升权限,并且我们需要以管理员身份运行才能更改COM类注册信息,我们只需在注册表中启用DCOM反射,即在将.NET框架加载到受保护进程之前,向HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft.NETFramework键写入值为1的DWORD类型的AllowDCOMReflection。
需要采取以下步骤来实现注入:
- 在注册表中启用DCOM反射。
- 添加TreatAs键以将StdFont重定向到System.Object COM类。
- 创建WaaSRemediationAgent对象。
- 使用类型库获取StdFont类类型信息。
- 使用CreateInstance方法创建StdFont对象,这实际上将加载.NET框架并返回System.Object类的实例。
- 使用.NET反射调用System.Reflection.Assembly::Load方法并传入字节数组。
- 在加载的程序集中创建一个对象以强制代码执行。
- 清理所有注册表更改。
你需要使用非.NET语言执行这些步骤,否则序列化机制将启动并在调用进程中重新创建反射对象。我用C++编写了我的PoC,但如果你愿意,也可以用Python等语言实现。我不会公开提供PoC,但代码与我为CVE-2014-0257编写的漏洞利用非常相似,那将为你提供一个如何在C++中使用DCOM反射的示例。另外请注意,.NET COM对象的默认设置是使用v2框架运行,该框架默认不再安装。为了避免在v4上折腾使其工作,我直接从Windows组件安装程序安装了v2。
我的PoC在Windows 10上首次运行就成功了,但不幸的是,当我在Windows 11 24H2上运行时失败了。我可以创建.NET对象,但调用对象上的任何方法都失败,错误为TYPE_E_CANTLOADLIBRARY。我本可以就此打住,证明了我的观点,但我想知道在Windows 11上是什么失败了。让我们深入探讨一下,看看我们是否能做些什么使其在最新版本的Windows上工作。
Windows 11 的问题
我能够证明问题与受保护进程有关,如果我更改服务注册以在未受保护状态下运行,那么PoC就有效。因此,一定有什么东西在专门在受保护进程中运行时阻止了库的加载。这似乎通常不影响类型库,stdole的加载工作正常,所以是.NET特有的问题。
在用Process Monitor检查PoC的行为后,很明显mscorlib.tlb库正在被加载以在服务器中实现存根类(stub class)。由于某种原因,它加载失败,这阻止了存根的创建,进而导致任何调用失败。此时,我对正在发生的事情有了一个想法。在之前的博客文章中,我讨论了通过修改NGEN COM进程用于创建接口存根的类型库来引入类型混淆(type-confusion)的攻击。这使我能够覆盖KnownDlls句柄并强制任意DLL加载到内存中。我从Clément Labro和其他人的工作中了解到,大多数围绕KnownDlls的攻击现在都被阻止了,但我怀疑对类型库类型混淆技巧也有某种修复。
深入研究oleaut32.dll,我找到了有问题的修复,VerifyTrust方法如下所示:
NTSTATUS VerifyTrust(LoadInfo *load_info) {
PS_PROTECTION protection;
BOOL is_protected;
CheckProtectedProcessForHardening(&is_protected, &protection);
if (!is_protected)
return SUCCESS;
ULONG flags;
BYTE level;
HANDLE handle = load_info->Handle;
NTSTATUS status = NtGetCachedSigningLevel(handle, &flags, &level,
NULL, NULL, NULL);
if (FAILED(status) ||
(flags & 0x182) == 0 ||
FAILED(NtCompareSigningLevels(level, 12))) {
status = NtSetCachedSigningLevel(0x804, 12, &handle, 1, handle);
}
return status;
}
此方法在加载类型库期间被调用。它使用缓存的签名级别(cached signing level),这也是我在上一篇博客文章中提到的,来验证文件是否具有12的签名级别,这对应于Windows签名级别。如果它没有适当的缓存签名级别,代码将尝试使用NtSetCachedSigningLevel来设置它。如果失败,则假定文件无法在受保护进程中加载并返回错误,这导致类型库加载失败。注意,类似的修复阻止了滥用运行对象表(Running Object Table)来引用进程外类型库,但这与本次讨论无关。
根据Get-AuthenticodeSignature的输出,mscorlib.tlb文件已签名,尽管是通过目录签名(catalog signing)。签名证书是Microsoft Windows Production PCA 2011,这与.NET Runtime DLL的证书完全相同,因此没有理由它不会获得Windows签名级别。让我们尝试使用我的NtObjectManager PowerShell模块手动设置缓存签名级别,看看是否能得到一些启示:
PS> $path = "C:\windows\Microsoft.NET\Framework64\v4.0.30319\mscorlib.tlb"
PS> Set-NtCachedSigningLevel $path -Flags 0x804 -SigningLevel 12 -Win32Path
Exception calling "SetCachedSigningLevel" with "4" argument(s): "(0xC000007B) - {Bad Image}
%hs is either not designed to run on Windows or it contains an error. Try installing the program again using the
original installation media or contact your system administrator or the software vendor for support. Error status 0x"
PS> Format-HexDump $path -Length 64 -ShowAll
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F - 0123456789ABCDEF
-----------------------------------------------------------------------------
00000000: 4D 53 46 54 02 00 01 00 00 00 00 00 09 04 00 00 - MSFT............
00000010: 00 00 00 00 43 00 00 00 02 00 04 00 00 00 00 00 - ....C...........
00000020: 25 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 - %...............
00000030: 2E 0D 00 00 33 FA 00 00 F8 08 01 00 FF FF FF FF - ....3...........
设置签名级别给我们带来了STATUS_INVALID_IMAGE_FORMAT错误。查看类型库文件的前64个字节显示它是一个原始类型库,而不是打包在PE文件中。这在Windows上相当不常见,即使文件扩展名为TLB,类型库通常也作为资源打包在PE文件中。我想我们运气不好,除非我们能在文件上设置缓存签名级别,否则它将被阻止加载到受保护进程中,而我们需要它加载以支持存根类通过DCOM调用.NET接口。
顺便说一句,奇怪的是,我有一个Windows 11虚拟机,其中包含非DLL形式的类型库,它可以成功设置缓存签名级别。我一定以某种方式更改了虚拟机的配置以支持此功能,但我不知道那是什么,我决定不再深入探讨。
我们可以尝试找到类型库文件的先前版本,该版本既有效签名,又打包在PE文件中,但是,我宁愿不这样做。当然,几乎肯定有另一个我们可以加载的COM对象,而不是.NET,它可能给我们任意代码执行能力,但我已经下定决心采用这种方法。最终,解决方案比我预期的更简单,出于某种原因,类型库文件的32位版本(即在Framework中,而不是Framework64中)打包在DLL中,我们可以在其上设置缓存签名级别。
PS> $path = "C:\windows\Microsoft.NET\Framework\v4.0.30319\mscorlib.tlb"
PS> Format-HexDump $path -Length 64 -ShowAll
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F - 0123456789ABCDEF
-----------------------------------------------------------------------------
00000000: 4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00 - MZ..............
00000010: B8 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00 - ........@.......
00000020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 - ................
00000030: 00 00 00 00 00 00 00 00 00 00 00 00 B8 00 00 00 - ................
PS> Set-NtCachedSigningLevel $path -Flags 0x804 -SigningLevel 12 -Win32Path
PS> Get-NtCachedSigningLevel $path -Win32Path
Flags : TrustedSignature
SigningLevel : Windows
Thumbprint : B9590CE5B1B3F377EAA6F455574C977919BB785F12A444BEB2...
ThumbprintBytes : {185, 89, 12, 229...}
ThumbprintAlgorithm : Sha256
因此,要在Windows 11 24H2上利用,我们可以将类型库注册路径从64位版本切换到32位版本,然后重新运行漏洞利用。VerifyTrust函数将自动为我们设置缓存签名级别,因此我们不需要做任何额外的事情使其工作。尽管从技术上讲,它是类型库的不同版本,但对于我们的用例来说没有任何区别,存根生成器代码也不关心。
结论
我在本文中讨论了Windows上一个有趣的漏洞类别,尽管它也适用于任何类似的面向对象的远程处理跨进程或远程处理协议。它展示了如何通过利用OLE自动化的一个特性,特别是IDispatch接口和类型库,使COM对象被困在更高权限的进程中。
虽然我未能演示权限提升,但我展示了如何使用WaaSRemediationAgent类公开的IDispatch接口将代码注入到PPL-Windows进程中。虽然这不是最高可能的保护级别,但它允许访问大多数受保护运行的进程,包括LSASS。我们看到微软已经做了一些工作来尝试缓解现有的攻击,例如类型库类型混淆,但在我们的情况下,这种缓解措施不应该阻止加载,因为我们不需要更改类型库本身。虽然攻击需要管理员权限,但通用技术不需要。如果你能找到合适的公开IDispatch的COM服务器,你可以修改本地用户的COM和.NET注册,以普通用户身份进行攻击,注入到PPL中。