COM 学习

偏实用,简要介绍 COM,并给出使用 COM 的例子

概述

概述部分,介绍 COM 的一些基础知识和相关的 C/C++ 实现,实用部分可从Windows 中的 COM 看起。

COM(Component Object Model,组件对象模型),是一种软件组件之间交互的规范,不依赖于特定的语言,但受 C++ 影响较大;也不依赖于特定平台,但目前仅在 Windows 中应用比较广泛。COM 是二进制级别的规范。

COM 中引入了面向对象的思想,COM 对象通过COM 接口提供服务,COM 组件为 COM 对象提供活动空间(以下简称为组件、对象、接口)。三者关系为:一个组件可以包含多个对象,一个对象包含多个接口。多数情况下,一个组件只包含一个对象。

COM 使用 C/S 模型,客户端调用组件功能的顺序如下:

  1. 创建或获取一个对象
  2. 通过该对象实现的接口调用服务
  3. 不再需要该对象时释放对象

组件、对象与接口

组件在 Windows 中是一个 EXE 或者 DLL 文件。COM 组件对于用户来说是透明的。

类似于 C++,COM 对象实际上是某个 class 的实例,class 则是一组相关的数据和功能的定义。每个对象由一个 128 位的 GUID(Globally Unique Identifier)标识,称为 CLSID。客户端通过 CLSID 创建对象。

C++ 中没有接口的概念,接口是一组逻辑上相关的函数,这些函数通常被称为接口成员函数。每个接口也由 GUID 标识,称为 IID。

COM 库

COM 库是 COM 规范的实现,在 Windows 下以 dll 文件形式存在,包括:

  • 部分 API 函数,实现了客户和服务器端 COM 应用的创建过程。包括客户端的创建函数和服务器端对对象的访问支持。
  • COM 通过注册表查找本地服务器(EXE 程序),以及程序名与 CLSID 之间的转换等。
  • 提供标准的内存控制方法,使应用控制进程中的内存分配。

接口定义

客户端使用指向接口数据结构的指针来调用接口的成员函数,而这个接口指针实际指向另一个指针(pVtable),第二个指针(pVtable)指向一组函数,被称为接口函数表(也成为虚函数表 vtable)。对于一个接口来说,它的虚函数表 vtable 是确定的,因此成员函数的个数和先后顺序是不变的;成员函数的参数和返回值也是不变的。以上所有信息都需要在二进制级别确定,即使使用其他编程语言,只要能够支持这样的内存结构描述,就可以定义接口。

例如一个 IDictionary 字典接口,它的 C 语言描述如下:

1
2
3
4
5
6
7
8
9
struct IDictionaryVtbl;
struct IDictionary {
IDictionaryVtbl* pVtbl;
}
struct IDictionaryVtbl {
BOOL (*Initialize) (IDictionary* this);
BOOL (*InsertWord) (IDictionary* this, String, String);
// ...
}

几点说明:

  • 接口成员函数第一个参数必须为指向 IDictonary 的指针,因为接口必定存在于某个 COM 对象上,该指针提供了对象实例的信息,从而得知是对哪个 COM 对象操作。在此例子中,就是可能存在多本字典,this 指针指明了对哪个字典对象进行操作。
  • 接口成员函数中,字符串变量必须使用 Unicode 字符指针,这是 COM 规范的要求。
  • 接口成员函数应该使用相同的调用规范。Windows 平台常用的调用规范为_cdecl_stdcall(也称为pascal)。由于大多数语言和系统 API 都使用_stdcall,所以 COM 规范和 COM API 都采用了 _stdcall
  • 以上定义中没有给出具体实现,对于客户端来说只需要使用这样的定义即可,对于组件程序来说,则必须提供具体实现。

接口需要具有不变性,如果需要修改现有功能或提供新的功能,则应创建新的接口。

如果客户端需要使用一个 COM 对象的某个接口,则必须知道接口的 IID 和接口能够提供的方法(接口成员函数)。

前面提到 COM 受 C++ 影响较大,使用 C++ 描述接口十分容易:

1
2
3
4
5
class IDictionary {
virtual BOOL Initialize() = 0;
virtual BOOL InsertWord(String, String) = 0;
// ...
}

这与 C 语言描述效果一致。这里使用了纯虚函数,因为接口知识一种描述,并不提供具体实现。

客户端调用时,当获得了某个接口指针后,就可以调用接口成员函数,如:

1
2
3
4
5
// C++
pIDictionary->InsertWord("a","one");

// or C
pIDictionary->pVtbl->InsertWord(pIDictionary, "a", "one");

接口描述语言(IDL)是一种不依赖于具体语言的接口描述方法,此处略过。

由此可知,

对象实现

对象往往有自己的属性数据,这些数据表示了对象的状态,能够区分对象。比如两本字典之间的不同可以通过字典名和字典数据不同,使用 C++ 的类定义字典对象:

1
2
3
4
5
6
7
8
9
10
11
12
class CDictionary: public IDictionary {
public:
CDictionary();
~CDictionary();
public:
virtual BOOL Initialize();
virtual BOOL InsertWord(String, String);
// ...
private:
struct DictWord * m_pData;
char* m_DictFileName[128];
}

如果一个客户端使用了两个字典对象,则两个字典对象公用了成员函数,但是数据是没有公用的,此时内存结构如下:

如果第二个字典组件对象没有使用 CDictionary 类实现字典功能,但也实现了 IDictionary 接口,则内存结构如下:

以上模型中,每个接口成员函数都包含一个 this 指针参数,通过该参数可以访问到对应对象的数据。

IUnkown 接口

所有接口必须继承 IUnkown 接口,它提供了两个重要特性:生存期管理和接口查询。C++ 定义如下:

1
2
3
4
5
6
class IUnknown {
public:
virtual HRESULT _stdcall QueryInterface(const IID& iid, void ** ppv) = 0;
virtual ULONG _stdcall AddRef() = 0;
virtual ULONG _stdcall Release() = 0;
}

引用计数

AddRef 和 Release 用于对引用计数操作,实现引用计数可以在三个级别上:

  • 组件级别,使用全局变量
  • 对象级别,使用类的成员变量
  • 接口级别,为每一个成员函数使用一个成员变量

下面是引入了 IUnknown 和对象界别的引用计数的例子(C++):

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
34
35
36
37
class IDictionary: public IUnkown {
public:
virtual BOOL Initialize() = 0;
// ...
}
class CDictionary: public IDictionary {
public:
CDictionary();
~CDictionary();
public:
virtual HRESULT _stdcall QueryInterface(const IID& iid, void ** ppv);
virtual ULONG _stdcall AddRef();
virtual ULONG _stdcall Release();
virtual BOOL Initialize();
//...
private:
struct DictWord *m_pData;
char* m_DictFileName[128];
int m_Ref;
}
// ...
CDictonary::CDictionary() {
m_Ref = 0;
// ...
}
CDictonary::AddRef() {
m_Ref++;
return ULONG(m_Ref);
}
CDictonary::Release() {
m_Ref--;
if(m_Ref == 0) {
delete this;
return 0;
}
return ULONG(m_Ref);
}

接口查询

上面提到,一个 COM 对象可以实现多个接口,客户程序可以对 COM 对象的接口进行询问,是否可以提供某个接口对应的一系列服务,这就是 QueryInterface 实现的功能。

1
HRESULT QueryInterface([in] REFIID iid, [out] void ** ppv);

iid 为希望得到的接口 IID,ppv 为查询得到的结果接口指针,失败则为 NULL。

当客户创建 COM 对象后,创建函数总会为我们返回一个接口指针。由于接口都继承自 IUnkown ,所以所有接口都有 QueryInterface 函数,所以可以通过这个最初的接口指针获取到该对象支持的任意接口指针。

Windows 中的 COM

之前提到,COM 在 Windows 中有广泛应该用,讨论 Windows 上的 COM 其实是在讨论 Windows 对于 COM 规范的实现,其中使用了许多 Windows 平台的特性,比如注册表和 DLL 等。

组件可以以 DLL 或者 EXE 的形式实现,当以 DLL 形式实现时,由于客户程序调用组件服务时,会把组件程序加载到自己的内存空间中,所以称为进程内组件;以 EXE 形式实现时,则成为进程外组件。实现方式对于用户程序来说是透明的,此处不展开。

Windows 系统上,COM 库所需的注册信息都存放在注册表中,路径为HKEY_CLASSES_ROOT\CLSID\[CLSID],进程内组件内部包含InprocServe32 子健,包含组件程序的全路径;进程外则是 LocalServer32 子健。除此之外,还可能包含其他信息。除了使用 CLSID 可以表示 COM 对象以外,还可以使用字符串为对象命名,这样的名字信息称为 ProgID,直接存放在HKEY_CLASSES_ROOT键下,可以查找CLSID子键获取 CLSID。

注册表中存在的 COM 对象可以直接通过 CLSID 来查找和使用,

实际应用

枚举

通过以下 Powershell 遍历所有 COM 组件和导出的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
New-PSDrive -PSProvider registry -Root HKEY_CLASSES_ROOT -Name HKCR
Get-ChildItem -Path HKCR:\CLSID -Name | Select -Skip 1 > clsids.txt

$Position = 1
$Filename = "win10-clsid-members.txt"
$inputFilename = "clsids.txt"
ForEach($CLSID in Get-Content $inputFilename) {
Write-Output "$($Position) - $($CLSID)"
Write-Output "------------------------" | Out-File $Filename -Append
Write-Output $($CLSID) | Out-File $Filename -Append
$handle = [activator]::CreateInstance([type]::GetTypeFromCLSID($CLSID))
$handle | Get-Member | Out-File $Filename -Append
$Position += 1
}

利用

例如,Windows 中存在一个 CLSID 为 E430E93D-09A9-4DC5-80E3-CBB2FB9AF28E的类,其注册表路径为HKEY_CLASSES_ROOT\CLSID\{E430E93D-09A9-4DC5-80E3-CBB2FB9AF28E},它的组件位置为C:\Program Files (x86)\Windows Kits\10\App Certification Kit\prchauto.dll,其中包含名为ProcessChain的类,包含一个名为CommandLine的属性和一个Start方法。

使用 Powershell 调用比较简单:

1
2
3
4
$handle = [activator]::CreateInstance([type]::GetTypeFromCLSID("E430E93D-09A9-4DC5-80E3-CBB2FB9AF28E"))
$handle | gm
$handle.CommandLine="cmd /c whoami"
$handle.Start([ref]$true)

其中最后一行使用[ref$false]也可以成功执行。

接下来使用 C++ 实现,首先使用 OleViewer 打开 prchauto.dll,选择 Save as,保存prchauto.IDL。打开 Visual Studio Developer Command Prompt,执行:

1
midl /h processchain.h ./prchauto.IDL

会在执行目录生成prchauto.tlbprchauto_i.c,和processchain.h文件,把processchain.h文件加入工程,此时可以使用 IProcessChain 的 put_CommandLine设置 CommandLine,使用Start启动,以下是启动计算器的例子(来自COM组件相关的武器化开发技术 - idiotc4t’s blog

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
34
35
36
37
38
39
40
41
42
#include <Windows.h>
#include "processchain.h"
#include <objbase.h>
#include <stdio.h>
#include <strsafe.h>

//定义com组件使用的bool值,其实质是一个二short类型。
typedef short VARIANT_BOOL;
#define VARIANT_TRUE ((VARIANT_BOOL)-1)
#define VARIANT_FALSE ((VARIANT_BOOL)0)


#define CLSID_ProcessChain L"{E430E93D-09A9-4DC5-80E3-CBB2FB9AF28E}"
#define IID_IProcessChain L"{79ED9CB4-3A01-4ABA-AD3C-A985EE298B20}"


int main(int argc, TCHAR* argv[])
{
HRESULT hr = 0;
CLSID clsidIProcessChain = { 0 };
IID iidIProcessChain = { 0 };
IProcessChain* ProcessChain = NULL;
BOOL bRet = FALSE;

CoInitialize(NULL);//初始化com环境

CLSIDFromString(CLSID_ProcessChain, &clsidIProcessChain);
IIDFromString(IID_IProcessChain, &iidIProcessChain);
//创建接口
hr = CoCreateInstance(clsidIProcessChain, NULL, CLSCTX_INPROC_SERVER, iidIProcessChain, (LPVOID*)&ProcessChain);

TCHAR cmd[] = L"C:\\WINDOWS\\system32\\calc.exe";
VARIANT_BOOL b= VARIANT_TRUE;
//设置参数
ProcessChain->put_CommandLine((BSTR)cmd);
//调用方法
hr = ProcessChain->Start(&b);

//释放
CoUninitialize();
return 0;
}

成功弹出计算器。

calc

作者

lll

发布于

2022-04-22

更新于

2023-03-23

许可协议