C++支持开发者进行面向对象编程,并免去了处理许多面向对象编程(OOP)范式问题的责任。但这些问题并不会神奇地消失。相反,编译器旨在解决由C++对象、虚方法、继承等引起的许多复杂性。在最佳情况下,解决方案对开发者几乎是透明的。但要小心假设或依赖“底层”行为。这就是我想在这篇文章中分享的内容——编译器如何处理C++对象、虚方法、继承等方面的一些细节。最后,我想描述一个我最近分析的真实问题,我称之为“指针转换漏洞”。
指针:C vs C++
C++引入了由C++语言标准支持的类,这是一个重大变化。编译器需要处理许多问题,例如构造函数、析构函数、字段分离、方法调用等。
在C中,我们能够创建函数指针,那么为什么在C++中不能创建指向成员函数的指针呢?这意味着什么?如果我们有一个实现了任何方法的类,从C开发者的角度来看,这只是一个在对象内部声明的函数。C++应该允许我们创建指向这个确切方法的指针。这被称为指向成员函数的指针。
如何创建它们?这比C中的函数指针更复杂。让我们看一个例子:
|
|
函数指针的定义与C相比没有太大不同。但调用函数的方式显然不同,因为隐含了this指针。此时发生了一些魔法。为什么我们需要类的实例,并以其为基础调用指针?答案需要我们分析这些“指针”在内存中的样子。
普通指针(从C语言中已知)的大小是CPU字长。如果CPU操作在32位寄存器上,类似C的指针将是32位长,仅包含一个内存地址。但上面提到的指向成员函数的C++指针有时也被称为“胖指针”。这个名字暗示它们保存了更多信息,而不仅仅是一个内存地址。胖指针的实现依赖于编译器,但它们的大小通常总是比函数指针大。在Microsoft Visual C++中,指向成员函数的指针(胖指针)可以是4、8、12甚至16字节大小!为什么有这么多选项,为什么需要这么多内存?这都取决于它关联的类的性质。
类和继承
“指向成员函数的指针”的性质由我们想要指向的成员函数的类布局驱动。
关于C++对象布局的细节有一些优秀的参考资料——例如参见[1,2]。我们只给出一个示例类和关联布局:考虑两个不相关的类,它们从同一个基类派生:
|
|
这两个类可能由完全不同的开发者甚至公司编写。它们不需要知道彼此的存在。现在我们想象一个情况,第三家公司想为这两个协议编写一个包装器,并导出独立于任一规范的API。新类可能看起来像这样:
|
|
注意,将Protocol_1声明为具有虚拟继承意味着在内存布局中有一个单一版本的ip_id(和chksum()),并且语句
|
|
是明确的。Proto_wrap对象的布局是:
(布局图省略)
(如果没有虚拟继承,Protocol_1和Protocol_2将各自拥有自己的ip_id成员副本,如果我们尝试类似int a = pProto_wrap->ip_id
的操作,会导致歧义。)
指向成员函数的指针(胖指针)
现在我们已经回顾了一些相关背景,可以回到原始问题。为什么指向成员函数的指针比C风格的函数指针大?Microsoft VC++编译器可以生成4、8、12甚至16字节长的指向成员函数的指针(胖指针)[3,4]。为什么有这么多选项,为什么它们需要这么多内存?希望思考上面的对象布局示例能提供一些线索……
如果我们创建一个指向静态函数的指向成员函数的指针,它将被转换为普通(类似C的)指针,并且将是4字节长(在32位架构上,其他情况下是CPU字长)。为什么?因为静态函数有一个固定的地址,与任何特定对象实例无关。
在单继承情况下,任何成员函数都可以作为从单个‘this’指针的偏移来引用。
然而,在多继承情况下,给定一个派生对象(例如Proto_wrap),其‘this’指针并不对每个基类都有效。相反,‘this’需要根据所引用的基类进行调整。在这种情况下,“胖指针”将是2个CPU字长:
| 偏移量 | “this” |
参见[5]以获取更详细的演练。
此外,如果我们的对象使用虚拟继承(上一节给出的布局示例),那么我们不仅需要知道哪个vtable相关(Protocol_1的或Protocol_2的),还需要知道对应于我们想要指向的成员函数的偏移量。在这种情况下,指向成员函数的指针大小将是12字节(3个CPU字大小)。
这还不是结束……您还可以前向声明一个对象,在这种情况下,编译器不知道其内存布局,并将为指向成员函数的指针分配一个16字节的结构,除非您通过特殊的编译器开关/pragmas指定与对象关联的继承类型。[3,4]。
所以现在我将尝试解释一些我在工作中遇到的有趣的安全相关行为……
C++指针转换漏洞
让我们分析以下骨架示例。我们有一个基类,它被另外两个类虚拟继承:RealData保存一些数据;Manage可以处理特定类型的数据;‘BYTE *_ip’用于指示应调用Manage的哪个处理方法。
|
|
这个漏洞的关键是Manage::frame()中相当复杂的转换。注意涉及的类型:
- _ip是BYTE *类型
- ProcessHelper::DummyStruct是一个带有指向成员函数的指针类型ManageFunc的结构体
因此,‘BYTE *’数据实际上被转换(通过结构体间接地)为ManageFunc指向成员函数的指针类型,即该指令实际上等同于:
|
|
然而,编译器在此语句上出错,标记‘BYTE *’和‘ManageFunc’是不兼容的类型(特别是大小不同!)以进行转换。这里似乎开发者通过引入‘struct DummyStruct’ subterfuge绕过了编译器错误:他们假设在底层ManageFunc真的只是一个标准指针,并能够间接实现不正确的转换……C/C++总是允许固执的开发者最终做错事。
让我们运行一下这在实践中是如何崩溃的。我们创建以下实例:
|
|
为了说明问题,我们可以如下设置‘flags’成员:
|
|
假设代码做了类似以下的事情:
|
|
这导致崩溃——在转换为带有指向成员函数指针的DummyStruct结构后,基础转换期望具有与虚拟继承关联的胖指针内存布局,特别期望在第3个CPU字找到vbtable偏移信息:该值被取出并添加到整个指针。在我们的例子中,_ip指向_lcurr,因此我们有以下相邻数据:
|
|
因此,这里任意的_flags数据将被添加到内存地址_lcurr,试图形成成员函数的地址。
注意,RTTI(运行时类型信息)在这里没有帮助;不正确的转换直接计算了一个不正确的内存地址来调用。
安全后果可能很严重——完整的远程代码执行(RCE)。在这种不正确的‘标准指针’到‘指向成员函数的指针’转换场景中,标准指针相邻的数据将被用于计算成员函数的地址。如果攻击者控制了这个,那么通过在这里选择合适的值,他可以使地址计算导致他选择的值,从而获得执行控制。
_在真实漏洞中,我们没有直接控制将写入“_flags”字段的内容。但我们能够执行一些代码路径,将“_flags”值设置为非零——数字2(二)。因此,我们能够将“flags”设置为值2,然后执行易受攻击的代码。因为指针计算错误(因为2被添加到指针),转换为结构的内存具有错误的值。结构内部是函数指针,因为它们被移动了2字节,它们总是指向堆范围内的某个内存位置。攻击者可以喷洒堆,从而控制这个。[6]
总结
语言级别越高,相关编译器必须解决的问题就越多。但开发者最终负责编写正确的代码。通常,C/C++编译器最终将允许您在无关类型之间进行转换(例如,C指针到指向成员函数的指针)并再次返回。开发者应避免此类非法活动,注意在违反规则时发生的编译器警告,并意识到如果他们坚持,他们只能靠自己……Microsoft Visual Compiler检测到描述的情况,并通过打印适当的信息通知开发者:
|
|
顺便说一句。我想感谢以下人员对我工作的帮助:
- Tim Burrell (MSEC)
- Greg Wroblewski (MSEC PENTEST)
- Suha Can
此致, Adam Zabrocki
参考文献
[1] Reversing Microsoft Visual C++ Part II: Classes, Methods and RTTI [2] C++: under the hood [3] MSDN: Inheritance keywords [4] MSDN: pointers-to-members pragma [5] Pointers to member functions are very strange animals [6] http://technet.microsoft.com/en-us/security/bulletin/ms13-002