为Rust设计COM库 | MSRC博客
我去年夏天在微软MSRC英国切尔滕纳姆团队担任软件工程实习生,工作在安全系统编程语言(SSPL)小组,该小组探索安全编程语言作为预防内存安全相关漏洞的主动措施。
本博客描述我在SSPL团队指导下完成的项目,希望能让大家更了解微软实习生的工作!
项目概述
我的目标是构建一个开源的Rust库,使开发者能够以符合语言习惯的方式消费和生产进程内组件对象模型(COM)组件。
对于不熟悉COM的人来说,COM是一种与语言无关的、面向对象的二进制创建标准。由于COM具有明确定义的应用程序二进制接口(ABI),支持此标准的二进制文件可以相互操作,无论它们是用何种语言编写的。
COM组件通过一组接口暴露其数据。每个接口定义一个虚拟方法表(vtable),类似于C++对象的虚函数布局。这些vtable包含指向实际函数实现的函数指针。COM组件然后存储指向这些vtable的指针(也称为vpointer)。下图说明了这一点。
通过强制vtable中函数指针的布局,COM允许任何语言将内部数据类型映射到COM对象的vtable。这为任何语言提供了所需信息,以解引用正确的函数指针,从而调用COM对象上的相应暴露函数。
为什么为COM启用Rust支持?
作为消除一类漏洞的努力的一部分,微软正在研究采用更安全的系统编程语言。正如我们在早先博客中讨论的,Rust就是这样一种语言。Rust采用的一个挑战是与C++和现有微软工具的互操作性。COM是微软广泛使用的标准,支持它是与现有组件互操作的必要步骤。
在微软之外,该库的目标还在于让COM开发者利用Rust编译器的内存安全保证来创建更安全的COM组件。我们将在后面讨论为COM交互编写安全包装时,更详细地检查这种影响及相关挑战。
实现/项目设计
在本节中,我们将解释我们的库如何在实践中工作。
该项目的主要目标之一是为Rust开发者创建一个符合语言习惯的库,以消费和生产COM组件。编写COM交互涉及大量样板代码,描述和生成不同的vtable布局。为了使其使用符合习惯,我们试图尽可能抽象掉COM细节。幸运的是,Rust具有表达性强的宏系统,允许我们实现这种抽象!
要消费COM组件,必须首先在Rust中描述接口(此处为IAnimal)。
|
|
要与暴露IAnimal接口的COM对象交互,采取两个动作。首先,实例化COM对象,返回一个接口指针(指向vpointer的指针)。通过Runtime结构完成此操作,该结构使用CoInitializeEx/CoUninitialize控制COM库的生命周期。
|
|
其次,通过接口指针调用方法。
|
|
消费侧的宏使用相对直接实现,因为COM为消费者抽象了COM组件的实现细节。
创建自己的COM对象
在生产方面,我们花了大部分时间试图使设计既可扩展又符合语言习惯。这并不容易。COM在创建组件时提供了许多实现可能性,我们必须能够用我们的库覆盖这些可能性。起初,我们试图完全抽象掉生成的vtable的存在,这些vtable将使对象COM兼容。我们通过将用户定义的对象包装在一个可以称为ComBox的结构中来实现这一点。这个ComBox持有vpointer,然后传递给消费者进行交互。
|
|
这对于基本COM工作良好。然而,这个解决方案是在假设用户永远不需要访问他们的vpointer的情况下创建的。这个假设被证明是错误的。COM在创建COM组件时允许许多功能。其中一个实现代码重用的功能是聚合,它允许你暴露另一个COM对象的接口,就像是你自己的一样。为了在任何时候启用聚合,用户需要显式地向聚合对象提供你自己的vpointer。由于这违反了我们的原始假设,影响了我们的设计决策。
我们现在知道我们需要隐藏COM细节,但也要为有经验的COM开发者提供访问这些细节的权限。假设你想创建一个名为CatDog的COM组件,它继承自COM接口ICat和IDog。要做到这一点,你必须写类似这样的东西:
|
|
开发者将在结构体中定义他们的用户字段。#[co_class]宏然后扩展结构体以包含COM字段(vpointer、ref_count等)。这里的一个注意事项是用户必须实现一个构造函数“new”,在其中必须通过宏生成的“allocate”函数初始化COM字段。这里的主要区别是方法定义在包含vpointer的包装结构体上。这授予用户访问这些字段的权限,因为它们现在在同一作用域内!
它到底有多安全?
我们已经讨论了如何使用我们的库以及我们做出的一些设计决策。使用Rust的真正影响是什么?
尽管Rust提供内存安全保证,但在使用语言的不安全超集时,所有赌注都无效。在这种特定情况下,COM促进语言之间的互操作性,这些交互将需要频繁使用Rust的不安全超集。原因如下。
Rust开发者的通常做法是在这些交互周围创建安全包装。这些安全包装验证原始指针并将其转换为安全类型。可空指针将被检查并转换为Option,因此开发者必须显式处理空情况等。不幸的是,我们无法为我们的库复制这个解决方案。这些包装不是万无一失的。我们可以强制用户显式处理空指针,但是悬空引用呢?无法验证传递给我们的指针不指向垃圾或无效内存。这些包装中的任何一个如何按照Rust标准标记为安全?
上述场景与我们库之间的一个关键区别是,它们包装的是特定的库/API。我们试图包装的是一个标准/协议。在他们的案例中,他们可以参考文档、检查代码库等,然后生成特定于该库的安全包装。这将是一个有效的案例,可以将包装标记为安全,因为他们已经检查了他们包装的不安全代码,确保返回有效的指针。由于我们正在为一个标准制作包装,而不是特定的库或API,我们不能保证每个COM组件都将正确实现。由于我们对包装的代码一无所知,我们不能自动将这些生成的包装标记为安全。我们能做的是为用户提供一个选项,一旦他们完成了尽职调查,就将交互标记为安全。
图片来源:@matthewhenry, Unsplash
如果我们不能假设包装是安全的,那么在影响方面我们处于什么位置?与C/C++系统语言相比,使用Rust将使开发者更容易编写安全的COM组件。首先,不安全代码主要围绕外部函数接口(FFI)交互编写。开发者仍然可以为独立于这些FFI交互的逻辑流编写安全的Rust代码。这在编写新的COM组件时尤其重要。例如,多线程数据结构通常用于高性能代码。这些数据结构背后的逻辑流容易受到数据竞争的影响。由于编译器优化,这些数据竞争可能反过来导致难以跟踪的内存安全漏洞。Rust的所有权模型跨线程持久存在,消除了数据竞争的可能性。
其次,Rust强制开发者采取 conscientious 和主动的方法来处理内存安全。例如,开发者必须使用Rust中的unsafe关键字将上述FFI交互标记为不安全。这向库用户发出信号,要求他们调查这些FFI交互并对他们交互的COM对象进行尽职调查。将这种信令机制直接嵌入编译器比在代码注释或文档中详细说明更难错过。作为库的维护者,我们也必须对内存安全 diligent。我们需要能够准确评估代码为安全或不安全,当暴露它们时,以符合Rust的安全标准。
最后,如果我在MSRC UK工作而不包括我与合作的安全工程师的视角,那将是一种遗憾!拥有这些显式的不安全块大大减少了安全工程师必须梳理以识别内存安全漏洞的表面区域,因为内存安全漏洞只能源自不安全代码。
如你所见,Rust开发者有许多工具可供使用,以编写安全代码,并确保其代码的安全使用。将这些工具直接放在编译器中,而不是必须拾取外部复杂工具,有助于全面提高生产力和安全性。
下一步是什么?
我们探索了这个项目背后的动机。我们已经看到了它目前如何工作,以及检查了它将产生的影响。下一步是什么?
这个项目现在已开源并在GitHub上可用!无论你是对贡献感兴趣还是只是对项目好奇,我们都欢迎你来查看。我们希望从社区获得尽可能多的反馈,因为这是一个以用户体验为设计目标的库。还有许多COM功能我们尚未能够用这个库覆盖。这些包括进程外交互等。一旦我们基于社区的反馈建立了稳定的基础,我们可以研究这些。
最后的话
这个项目从第一天到最后一天都挑战着我。必须同时学习Rust和COM,将我推离舒适区很远。然而,挣扎被更大程度的满足所抵消,因为看到了项目完成并学到了这么多。
通过在微软的这次实习,我有机会与一些我见过的最聪明的人一起工作并从他们的视角看问题。感谢MSRC英国团队的热情好客并赋予我作为团队成员的权力。感谢我的团队成员Ryan和Sebastian,在这个项目上与你合作非常愉快,并多次帮我摆脱困境。最后,感谢我的导师Sebastian,在委托我负责这个项目时采取了信任的飞跃,并在整个过程中耐心指导。
Hadrian Wei Heng Lim, 软件工程实习生, MSRC