MS13-002漏洞揭秘:错误转换胖指针如何导致代码崩溃

本文深入分析了MS13-002漏洞的技术细节,探讨了C++中胖指针的实现机制、错误类型转换的安全风险,以及如何通过堆喷射实现远程代码执行,为开发者提供了重要的安全编程启示。

C++支持开发者进行面向对象编程,并免去了处理许多面向对象编程(OOP)范式问题的责任。但这些问题并不会神奇地消失。相反,编译器旨在解决由C++对象、虚方法、继承等引起的许多复杂性。在最佳情况下,解决方案对开发者几乎是透明的。但要小心假设或依赖“底层”行为。这就是我想在这篇文章中分享的内容——编译器如何处理C++对象、虚方法、继承等方面的一些细节。最后,我想描述一个我最近分析的真实问题,我称之为“指针转换漏洞”。

指针:C vs C++

C++引入了由C++语言标准支持的类,这是一个重大变化。编译器需要处理许多问题,例如构造函数、析构函数、字段分离、方法调用等。

在C中,我们能够创建函数指针,那么为什么在C++中不能创建指向成员函数的指针呢?这意味着什么?如果我们有一个实现了任何方法的类,从C开发者的角度来看,这只是一个在对象内部声明的函数。C++应该允许我们创建指向这个确切方法的指针。这被称为指向成员函数的指针。

如何创建它们?这比C中的函数指针更复杂。让我们看一个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Whatever {
public:
 int func(int p_test) {
  printf("I'm method \"func()\" !\n");
 }
};

typedef int   (Whatever::*func_ptr)(int);
func_ptr p_func = &Whatever::func;
Whatever *base = new Whatever();
int ret = (base->*p_func)(0x29a);

函数指针的定义与C相比没有太大不同。但调用函数的方式显然不同,因为隐含了this指针。此时发生了一些魔法。为什么我们需要类的实例,并以其为基础调用指针?答案需要我们分析这些“指针”在内存中的样子。

普通指针(从C语言中已知)的大小是CPU字长。如果CPU操作在32位寄存器上,类似C的指针将是32位长,仅包含一个内存地址。但上面提到的指向成员函数的C++指针有时也被称为“胖指针”。这个名字暗示它们保存了更多信息,而不仅仅是一个内存地址。胖指针的实现依赖于编译器,但它们的大小通常总是比函数指针大。在Microsoft Visual C++中,指向成员函数的指针(胖指针)可以是4、8、12甚至16字节大小!为什么有这么多选项,为什么需要这么多内存?这都取决于它关联的类的性质。

类和继承

“指向成员函数的指针”的性质由我们想要指向的成员函数的类布局驱动。

关于C++对象布局的细节有一些优秀的参考资料——例如参见[1,2]。我们只给出一个示例类和关联布局:考虑两个不相关的类,它们从同一个基类派生:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Tcpip {
public:
    short ip_id;
    virtual short chksum();
};

class Protocol_1 : virtual public Tcpip {
public:
 int value_1;
 virtual int proto_1_init();
};

class Protocol_2 : virtual public Tcpip {
public:
 int value_2;
 virtual int proto_2_init();
};

这两个类可能由完全不同的开发者甚至公司编写。它们不需要知道彼此的存在。现在我们想象一个情况,第三家公司想为这两个协议编写一个包装器,并导出独立于任一规范的API。新类可能看起来像这样:

1
2
3
4
5
6
class Proto_wrap
 : public Protocol_1, public Protocol_2 {
public:
 int value_3;
 int parse_something();
};

注意,将Protocol_1声明为具有虚拟继承意味着在内存布局中有一个单一版本的ip_id(和chksum()),并且语句

1
pProtoWrap->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的哪个处理方法。

 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
class UnknownBase;

class RealData {
   friend Manage;
   public:
...
      ULONG_PTR  _lcurr; // 一些真实数据...
      int   _flags;
      int   _flags2;
      int   _flags3;

};

class Manage : public virtual UnknownBase {
      friend class ProcessHelper;
public:
      BYTE *   _ip;
      RealData *    _curr;
...
};

class ProcessHelper  : virtual UnknownBase {
   public:
      typedef LONG_PTR (Manage::*ManageFunc)();

      struct DummyStruct {
         ManageFunc _executeMe;  // 指向成员函数的指针!
      };
...
};

LONG_PTR Manage::frame() {
   LONG_PTR offset = (this->*(((ProcessHelper::DummyStruct *) _ip)->_executeMe))();
   return offset;
}

这个漏洞的关键是Manage::frame()中相当复杂的转换。注意涉及的类型:

  • _ip是BYTE *类型
  • ProcessHelper::DummyStruct是一个带有指向成员函数的指针类型ManageFunc的结构体

因此,‘BYTE *’数据实际上被转换(通过结构体间接地)为ManageFunc指向成员函数的指针类型,即该指令实际上等同于:

1
LONG_PTR offset = ((ManageFunc)_ip)();

然而,编译器在此语句上出错,标记‘BYTE *’和‘ManageFunc’是不兼容的类型(特别是大小不同!)以进行转换。这里似乎开发者通过引入‘struct DummyStruct’ subterfuge绕过了编译器错误:他们假设在底层ManageFunc真的只是一个标准指针,并能够间接实现不正确的转换……C/C++总是允许固执的开发者最终做错事。

让我们运行一下这在实践中是如何崩溃的。我们创建以下实例:

1
2
  Manage *temp_manage = new Manage;
  Real_block *temp_real_block = new RealData;

为了说明问题,我们可以如下设置‘flags’成员:

1
2
3
  temp_real_block->_flags = 0x41414141;
  temp_real_block->_flags2 = 0x41414141;
  temp_real_block->_flags3 = 0x41414141;

假设代码做了类似以下的事情:

1
2
  temp_manage->_ip = (BYTE *)&temp_real_block->_lcurr;
  temp_manage->frame(); // 执行(ManageFunc)_ip)转换

这导致崩溃——在转换为带有指向成员函数指针的DummyStruct结构后,基础转换期望具有与虚拟继承关联的胖指针内存布局,特别期望在第3个CPU字找到vbtable偏移信息:该值被取出并添加到整个指针。在我们的例子中,_ip指向_lcurr,因此我们有以下相邻数据:

1
2
3
4
      ULONG_PTR  _lcurr;
      int   _flags;
      int   _flags2;
      int   _flags3;

因此,这里任意的_flags数据将被添加到内存地址_lcurr,试图形成成员函数的地址。

注意,RTTI(运行时类型信息)在这里没有帮助;不正确的转换直接计算了一个不正确的内存地址来调用。

安全后果可能很严重——完整的远程代码执行(RCE)。在这种不正确的‘标准指针’到‘指向成员函数的指针’转换场景中,标准指针相邻的数据将被用于计算成员函数的地址。如果攻击者控制了这个,那么通过在这里选择合适的值,他可以使地址计算导致他选择的值,从而获得执行控制。

_在真实漏洞中,我们没有直接控制将写入“_flags”字段的内容。但我们能够执行一些代码路径,将“_flags”值设置为非零——数字2(二)。因此,我们能够将“flags”设置为值2,然后执行易受攻击的代码。因为指针计算错误(因为2被添加到指针),转换为结构的内存具有错误的值。结构内部是函数指针,因为它们被移动了2字节,它们总是指向堆范围内的某个内存位置。攻击者可以喷洒堆,从而控制这个。[6]

总结

语言级别越高,相关编译器必须解决的问题就越多。但开发者最终负责编写正确的代码。通常,C/C++编译器最终将允许您在无关类型之间进行转换(例如,C指针到指向成员函数的指针)并再次返回。开发者应避免此类非法活动,注意在违反规则时发生的编译器警告,并意识到如果他们坚持,他们只能靠自己……Microsoft Visual Compiler检测到描述的情况,并通过打印适当的信息通知开发者:

1
2
error C2440: 'type cast' : cannot convert from 'BYTE *' to 'XXXyyyZZZ'.
  There is no context in which this conversion is possible.

顺便说一句。我想感谢以下人员对我工作的帮助:

  • 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

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计