Windows 工具更新:OleView.NET
作者:James Forshaw,Google Project Zero
这是一篇关于我对 OleView.NET 工具所做的一些近期改进的简短博文,这些改进已作为 1.16 版本的一部分发布。该工具旨在发现 Windows COM 的攻击面,并查找权限提升和远程代码执行等安全漏洞。这些更新最近在雷德蒙德的 Microsoft Bluehat 会议上以 "DCOM Research for Everyone!" 为名进行了展示。这篇博文扩展了讨论的主题,提供了更多在 45 分钟时间限制内无法涵盖的背景和细节。本文假设读者具备 COM 知识,因为我只会描述有限数量的术语。
使用 OleView.NET 工具
在我们开始讨论之前,了解如何获取 OleView.NET 工具及其基本用法非常重要。获取该工具最简单的方法是通过 PowerShell 库使用 Install-Module OleViewDotNet 命令进行安装。这将同时安装 PowerShell 模块和 GUI。
接下来,您需要将 COM 注册工件解析到内部数据库中。您可以通过运行 Get-ComDatabase 命令来完成此操作。完成后,您就可以开始使用了。您会注意到,完成此操作可能需要很长时间,因此每次想要开始研究时都必须这样做会很烦人。为此,您可以使用命令 Set-ComDatabase -Default 将数据库写入默认存储位置。现在,下次启动 PowerShell 时,您只需运行检查命令,例如 Get-ComClass,默认数据库将自动加载。
此默认数据库也与 GUI 共享,您可以通过运行 Show-ComDatabase 命令来启动 GUI。对于一般研究,我发现 GUI 更易于使用,您可以点击查看 COM 注册信息。对于分析而言,通过 PowerShell 进行脚本编写的能力更为重要。
研究 COM 服务
在 COM 中进行安全研究通常涉及以下步骤:
- 枚举感兴趣的潜在 COM 类。这些可能是沙箱外部可访问的类、以高权限运行的类,或者是设计为远程公开的类。
- 验证 COM 类是否确实可以从攻击位置访问。COM 具有各种安全控制措施,用于确定哪些用户可以启动、激活和访问对象。了解这些安全控制措施可以将感兴趣的 COM 类列表限制为那些实际构成攻击面一部分的类。
- 枚举暴露的接口,确定它们的功能,并调用其上的方法来测试安全漏洞。
最后一步是工具更新的重点,使其更容易确定暴露接口的功能并调用方法来测试其行为。目标是尽量减少所需的逆向工程工作量(尽管通常仍需要一些),并避免在工具之外编写代码来与被测 COM 服务交互。
为了实现这一目标,OleView.NET 将汇集其拥有的任何接口信息来源,然后提供一种通过 UI 或 PowerShell 检查和调用接口上方法的机制。它目前汇集的信息来源包括:
- 已知接口,定义在基础 .NET 框架类库中或 OleView.NET 内部。
- 全局程序集缓存中存在的 COM 接口定义。
- 已注册的类型库。
- Windows 运行时接口。
- 提取的代理类封送处理信息。
收集这些信息的一个有用好处是,该工具将接口格式化为"源代码",以便您可以手动检查它。
格式化接口定义
OleView.NET 工具使用一个数据库对象来表示它在您的系统上分析的所有工件。最新发布的版本定义了其中一些对象可以转换为"源代码"。例如,如果工具可以确定一些元数据来表示工件,则以下内容可以转换:
- COM 接口
- COM 代理
- COM Windows 运行时类。
- 类型库、接口和类。
如何进行此转换取决于您使用的是 PowerShell 还是 UI。最简单的方法是使用 PowerShell,使用 ConvertTo-ComSourceCode 命令。例如,以下命令将接口对象转换为源代码:
PS> Get-ComInterface -Name IMyInterface | ConvertTo-ComSourceCode -Parse
请注意,我们还需要向命令传递一个 -Parse 选项。某些元数据(如类型库和代理)的解析成本可能很高,因此不会自动进行解析。但是,一旦它们在当前会话中被解析,元数据就会被缓存以供进一步使用,因此,例如,如果您格式化了一个类型库中的单个接口,那么所有其他接口现在也都被解析并可以格式化。
此命令的输出是转换后的"源代码"文本。格式取决于元数据源。例如,以下是 Windows 运行时类型的输出:
[Guid("155eb23b-242a-45e0-a2e9-3171fc6a7fdd")]
interface IUserStatics
{
/* Methods */
UserWatcher CreateWatcher();
IAsyncOperation<IReadOnlyList<User>> FindAllAsync();
IAsyncOperation<IReadOnlyList<User>> FindAllAsync(UserType type);
IAsyncOperation<IReadOnlyList<User>> FindAllAsync(UserType type,
UserAuthenticationStatus status);
User GetFromId(string nonRoamableId);
}
由于 Windows 运行时类型是使用类似于 .NET 的元数据定义的,因此输出是伪 C# 格式。相比之下,对于类型库或代理,它看起来更像以下内容:
[
odl,
uuid(00000512-0000-0010-8000-00AA006D2EA4),
dual,
oleautomation,
nonextensible
]
interface _Collection : IDispatch {
[id(1), propget]
HRESULT Count([out, retval] int* c);
[id(0xFFFFFFFC), restricted]
HRESULT _NewEnum([out, retval] IUnknown** ppvObject);
[id(2)]
HRESULT Refresh();
};
这是 Microsoft 接口定义语言 (MIDL) 格式,类型库版本应该非常准确,甚至可以被 MIDL 编译器重新编译。对于代理,一些信息会丢失,因此生成的 MIDL 并不完全准确,但正如我们稍后将看到的,将输出重新编译的理由有限。
另一件需要注意的事情是,当代理从 MIDL 编译为其 C 封送表示形式时,会丢失名称信息。因此,该工具只生成占位符名称,例如,方法名称的形式为"ProcN"。如果代理是针对具有已知定义的类型(例如来自 Windows 运行时类型或类型库),则该工具将尝试自动应用名称。如果没有,如果您希望它们不是默认名称,则需要手动更改它们。
您可以通过直接修改代理对象从 PowerShell 更改名称。例如,"IBitsTest1" 接口在未做任何处理之前如下所示:
[
object,
uuid(51A183DB-67E0-4472-8602-3DBC730B7EF5),
]
interface IBitsTest1 : IUnknown {
HRESULT Proc3([out, string] wchar_t** p0);
}
您可以使用以下脚本修改"Proc3":
PS> $proxy = Get-ComProxy -Iid 51A183DB-67E0-4472-8602-3DBC730B7EF5
PS> $proxy.Procedures[0].Name = "GetBitsDllPath"
PS> $proxy.Procedures[0].Parameters[0].Name = "DllPath"
现在格式化后的输出如下所示:
[
object,
uuid(51A183DB-67E0-4472-8602-3DBC730B7EF5),
]
interface IBitsTest1 : IUnknown {
HRESULT GetBitsDllPath([out, string] wchar_t** DllPath);
}
当我们回过头来调用代理方法时,这种重命名也很重要。显然,每次运行这个脚本会很烦人,因此您可以使用以下命令缓存名称:
PS> Export-ComProxyName -Proxy $p -ToCache
这将把一个描述名称的文件写入本地缓存文件。当代理在另一个会话中再次加载时,此缓存文件将自动应用。Export-ComProxyName 和相应的 Import-ComProxyName 命令允许您读写表示代理名称的 XML 或 JSON 文件,如果更方便,您可以在文本编辑器中修改它们。
最快见效的方法之一是枚举 COM 对象的接口,然后将该输出传递给 ConvertTo-ComSourceCode 命令。例如:
PS> $obj = New-ComObject -Clsid 4575438f-a6c8-4976-b0fe-2f26b80d959e
PS> Get-ComInterface -Object $obj | ConvertTo-ComSourceCode -Parse
这将基于其 CLSID 创建一个新的 COM 对象,枚举它支持的接口,然后将它们传递给转换过程,以获得接口的"源代码"表示。
要在 GUI 中查看源代码,您首先需要从 Registry 菜单中打开一个数据库视图。在生成的窗口中,将有一个工件的树状视图。您需要通过右键单击树并在上下文菜单中选择 Show Source Code 选项来打开源代码查看器窗口。这将产生类似于以下视图:

您也可以从 View→Registry View Options 菜单自动启用源代码视图。在该菜单中,您还可以启用自动解析接口信息,默认情况下是关闭的。
您可能会在屏幕截图中注意到一些带下划线的文本。这表示可以更改的名称,并且仅用于代理。您可以右键单击名称并从上下文菜单中选择 Edit Name 以调出文本输入对话框。然后,您可以更改名称以适应需要。如果您希望在会话之间持久保存名称,请在注册表视图选项中设置 Save Proxy Names on Exit 选项。然后,当您退出时,任何修改过的代理都将被写入缓存。
如果您想在一个类似的 GUI 中从 PowerShell 编辑代理,可以使用以下命令:
PS> Edit-ComSourceCode $proxy
这将显示一个类似于以下的对话框,您可以在其中编辑代理名称信息:

从代理定义生成接口
现在进入这些更新更重要的方面,即能够在您想要研究的对象暴露的接口上调用方法。只要对象具有可通过反射调用的 .NET 接口,该工具就一直为您提供调用方法的能力。这可以通过已知的接口类型(例如内置接口或 Windows 运行时接口)或通过按需将类型库转换为 .NET 程序集来实现。
新功能是基于代理定义生成接口,然后使用该接口来调用方法。最初,我尝试通过动态生成 .NET 接口来实现这一点,然后使用现有的 .NET 互操作来调用代理方法。这对于简单的代理来说效果很好,但在处理更复杂的情况时很快就遇到了问题:
- 某些类型很难用易于使用的 .NET 类型表示,例如指向结构的指针。类型库转换器通过将它们导出为
IntPtr参数来"处理"这个问题,这意味着调用者必须手动封送数据。如果出错,工具就会崩溃。 - 任何结构都需要精确布局,以便本机封送拆收器可以读取和写入正确的字段位置。如果出错,工具就会崩溃。
- 我是否提到过,如果出错,工具就会崩溃?
幸运的是,我已经有了一个解决方案,我的沙箱库已经具备从解析的 NDR 数据动态生成.NET 类的能力,事实上,我已经在使用该库来解析代理的 NDR 数据,因此我意识到可以重新利用现有的客户端构建器来构建 COM 代理客户端。我需要对代码进行一些简单的重构,使其从 COM 代理实例而不是 RPC 服务器构建,但我很快就有了一个 RPC 客户端。这个 RPC 客户端不直接与任何本机封送代码交互,因此不太可能崩溃。此外,任何复杂的结构都以一种易于从 .NET 修改的方式构建,从而消除了指针相关的问题。使用 RPC 客户端方法的一个问题是,同一个接口可以同时用于进程内和进程外对象。由于 COM 的设计方式,客户端通常不需要关心对象的位置,但在这种情况下,它必须可以通过代理访问。这不是什么大问题,进程内 COM 对象之间没有安全边界,因此能够调用它们的方法并不是那么有趣。
下一个问题是 RPC 传输。COM 调用有一个额外的输入和输出参数,即 ORPTHIS 和 ORPCTHAT 结构,需要添加到调用中。这些参数本可以添加到 RPC 客户端中,但最好让客户端与传输无关。相反,由于我的 RPC 代码具有可插拔的 RPC 传输,我能够在现有的 ALPC 和 TCP 传输之上重新实现一个自定义版本,该版本向任何调用添加了额外的参数。但这还不是全部,ALPC 需要额外的一对参数,LocalThis 和 LocalThat,它们可能因 Windows 版本而异。此外,您还需要添加对额外服务的支持,例如 OXID 解析器和与本地 DCOM 激活器的通信。虽然我实现了所有这些,但它不如我希望的那么可靠,不过,如果您想尝试,它仍然存在于源代码中。
顺便说一下,我应该指出,Clement Rouault(ALPC RPC 协议的原始研究人员之一,我自己的部分实现也受其启发)最近为他们的 Python 工具发布了一个非常类似的项目,实现了 ALPC DCOM 协议。
我决定需要一种不同的方法,在 COM 运行时中,代理实例使用的 RPC 通道由 IRpcChannelBuffer 接口表示。实现此接口的对象在初始化期间连接到代理,然后用于在客户端和服务器之间发送和接收 NDR 格式的数据。该实现处理所有特性,例如额外的参数、处理 OXID 解析和引用计数。如果我们能够获取代理对象的 IRpcChannelBuffer 实例,我们就可以使用它,而不是实现我们自己的协议,挑战在于如何获取它。
经过一些研究,我发现可以使用文档化的 NdrProxyInitialize 函数,通过将接口指针传递给代理,从其 MIDL_STUB_MESSAGE 结构中获取该接口。虽然它不如完全自定义的实现灵活,但这给了我一种处理传输的简单方法,而无需担心平台或协议差异。它也可以从现有的 COM 对象工作,只需查询适当的接口,提取缓冲区并调用远程服务器。
当然,事情没那么简单,我发现虽然 IRpcChannelBuffer 对象是一个 COM 对象,但它有一个损坏的 IMarshal 实现。由于 .NET 的 COM 互操作在生成运行时可调用包装器时会尝试查询 IMarshal,这将立即导致进程崩溃。我不得不手动通过本机委托分派调用到该方法,但至少它能工作。
调用接口方法
好的,那么如何使用该工具调用任意方法呢?对于 GUI,它的工作方式一直如此,当您创建 COM 对象的实例时(通常通过右键单击视图中的条目并选择 Create Instance),您将获得一个新的对象信息窗口,类似于以下内容:

窗口底部是支持的接口列表。右列是一个指示器,显示该接口是否有查看器。如果设置为 Yes,则可以双击它以调出调用窗口,如下所示:

在此窗口中,您可以双击一个方法以调出一个新对话框,您可以在其中指定参数并调用方法,如下所示。

调用后,它将显示结果输出参数,如果返回值是整数,则假定它是 HRESULT 错误代码。这些窗口对于"反射"接口(如类型库和 Windows 运行时接口)以及代理客户端都是相同的。如果在接口窗口打开时更改代理方法的名称,它们不会自动更新。您需要返回到对象信息窗口并再次双击该接口以使其重新创建客户端。
对于 PowerShell,您可以在使用 New-ComObject 命令时指定 Iid 参数,或使用 Get-ComObjectInterface 命令查询现有 COM 对象以获取新接口。该工具将从其可用选项中选择调用接口的最佳选项,包括动态生成 RPC 客户端。
PS> $obj = New-ComObject -Clsid 4991D34B-80A1-4291-83B6-3328366B9097
PS> $test = Get-ComObjectInterface $o -Iid 51A183DB-67E0-4472-8602-3DBC730B7EF5
PS> $test.GetBitsDllPath()
DllPath retval
------- ------
c:\windows\system32\qmgr.dll 0
为了更轻松地从 PowerShell 调用接口方法,对象上暴露的方法将被修改,以将输出参数包装在单个返回值中。您可以在上面的列表中看到这一点,DllPath 参数最初是一个仅输出参数。与其在脚本中处理这个问题,不如自动创建一个包含 DllPath 以及 HRESULT 返回值的返回结构。如果参数是输入和输出,则方法签名接受输入值,返回值包含输出值。
如果您的接口定义尚不存在,您可以将它们导入到工具中,供自动接口选择使用。为此,您需要将接口定义为 .NET 类型并将其编译到程序集中。然后在 GUI 中使用 File→Import Interop Assembly 菜单选项,或者在 PowerShell 中使用 Add-ComObjectInterface 命令。这两个选项都允许您指定程序集,下次启动工具时将自动加载。这会将 DLL 的副本复制到中心位置,以便即使您稍后删除该库,也可以访问它。
如果您只有一组 COM 接口的 IDL 文件,您可以在 Windows SDK 的帮助下将它们间接导入到工具中。首先使用 MIDL 编译器编译 IDL 文件以生成类型库,然后使用 TLBIMP 命令从类型库生成程序集文件。最后,您可以使用上一段的方法导入它。
OleView.NET 中还有很多值得探索的内容,我在这里没有涵盖。我鼓励您尝试一下,或者在 github 上查看源代码。