返回介绍

8.7 C++ 逆向工程基础

发布于 2024-10-11 21:05:43 字数 12739 浏览 0 评论 0 收藏 0

C++ 类是 C 结构体面向对象的扩展,因此,在结束数据结构的讨论时,我们有必要介绍一下已编译的 C++ 代码的各种特性。C++ 代码非常复杂,详细介绍这个主题并不属于本书的讨论范围。在这里,我们仅仅涉及几个重要问题,以及微软的 Visual C++和 GNU 的 g++ 之间的一些差异。

有一点需要特别记住,牢固掌握 C++ 语言的基础知识,对于你理解已编译 C++ 代码将大有裨益。在源代码层次上完全掌握继承和多态等面向对象的概念会非常困难。尝试在汇编语言层次上深入这些概念,但却不了解它们在源代码层次上的意义,毫无疑问,你会陷入困境。

8.7.1 this 指针

所有非静态 C++ 成员函数都使用 this 指针。任何时候调用这样一个函数, this 都被初始化,指向用于调用该函数的对象。以下面的函数调用为例:

//object1, object2, and *p_obj are all the same type.  
object1.member_func();  
object2.member_func();  
p_obj->member_func();

在 3 次调用 member_func 的过程中, this 分别接受了 &object1&object2p_obj 这 3 个值。我们最好是把 this 看成是传递到所有非静态成员函数的第一个隐藏参数。如第 6 章所述,Microsoft Visual C++利用 thiscall 调用约定,并将 this 传递到 ECX 寄存器中。GNU g++ 编译器则把 this 看做是非静态成员函数的第一个(最左边)参数,并在调用该函数之前将用于调用函数的对象的地址作为最后一项压入栈中。

从逆向工程的角度看,在调用函数之前,将一个地址转移到 ECX 寄存器中可能意味着两件事情。首先,该文件使用 Visual C++编译;其次,该函数是一个成员函数。如果同一个地址被传递给两个或更多函数,我们可以得到结论,这些函数全都属于同一个类层次结构。

在一个函数中,在初始化之前使用 ECX 意味着调用方必定已经初始化了 ECX ,并且该函数可能是一个成员函数(虽然该函数可能只是使用了 fastcall 调用约定)。另外,如果发现一个函数向其他函数传递 this 指针,则这些函数可能和传递 this 的函数属于同一个类。

使用 g++ 编译的代码较少调用成员函数。但是,如果一个函数没有把指针作为它的第一个参数,则它肯定不属于成员函数。

8.7.2 虚函数和虚表

虚函数用于在 C++ 程序中实现多态行为。编译器会为每一个包含虚函数的类(或通过继承得到的子类)生成一个表,其中包含指向类中每一个虚函数的指针。这样的表就叫做 虚表 (vtable)。此外,每个包含虚函数的类都获得另外一个数据成员,用于在运行时指向适当的虚表。这个成员通常叫做 虚表指针 (vtable pointer),并且是类中的第一个数据成员。在运行时创建对象时,对象的虚表指针将设置为指向合适的虚表。如果该对象调用一个虚函数,则通过在该对象的虚表中进行查询来选择正确的函数。因此,虚表是在运行时解析虚函数调用的基本机制。

下面我们举例说明虚表的作用。以下面的 C++ 类定义为例:

class BaseClass {  
public:  
   BaseClass();  
   virtual void vfunc1() = 0;  
   virtual void vfunc2();  
   virtual void vfunc3();  
   virtual void vfunc4();  
private:  
   int x;  
   int y;  
};  
class SubClass : public BaseClass {  
public:  
   SubClass();  
   virtual void vfunc1();  
   virtual void vfunc3();  
   virtual void vfunc5();  
private:  
   int z;  
};

在这个例子中, SubClassBaseClass 的一个子类。 BaseClass 由 4 个虚函数组成,而 SubClass 则包含 5 个虚函数( BaseClass 中的 4 个函数加上一个新函数 vfunc5 )。在 BaseClass 中,其声明使用了 =0 ,说明 vfunc1 是一个 纯虚函数 。纯虚函数在它们的声明类中没有实现,并且必须在一个子类被视为具体类之前,在这个子类中被重写。换言之,没有名为 BaseClass::vfunc1 的函数,直到一个子类提供一次实现,也没有对象能够被实例化。 SubClass 提供了这样一个实现,因此可以创建 SubClass 的对象。

初看起来, BaseClass 似乎包含 2 个数据成员,而 SubClass 则包含 3 个成员。但是,我们前面提到,任何包含虚函数(无论是本身包含还是继承得来)的类也包含一个虚表指针。因此, BaseClass 类型的实例化对象实际上有 3 个数据成员,而 SubClass 类型的实例化对象则有 4 个数据成员,且它们的第一个数据成员都是虚表指针。在类 SubClass 中,虚表指针实际上由类 BaseClass 继承得来,而不是专门为类 SubClass 引入。图 8-14 是一个简化后的内存布局,它动态分配了一个 SubClass 类型的对象。在创建对象的过程中,编译器确保新对象的虚表指针指向正确的虚表(本例中为类 SubClass 的虚表)。

图 8-14 一个简单的虚表布局

值得注意的是,SubClass 中包含两个指向属于 BaseClass 的函数( BaseClass::vfunc2BaseClass::vfunc4 )的指针。这是因为 SubClass 并没有重写任何一个函数,而是由 BaseClass 继承得到这些函数。图中还显示了纯虚函数的典型处理方法。由于没有针对纯虚函数 BaseClass::vfunc1 的实现,因此,在 BaseClass 的虚表中并没有存储 vfunc1 的地址。这时,编译器会插入一个错误处理函数的地址,通常,该函数名为 purecall 。理论上,这个函数绝不会被调用,但万一被调用,它会令程序终止。

使用虚表指针导致的一个后果是,在操纵 IDA 中的类时,你必须考虑到虚表指针。前面我们讲过,C++ 类是 C 结构体的一种扩展。因此,我可以利用 IDA 的结构体定义来定义 C++ 类的布局。对于包含虚函数的类,你必须将一个虚表指针作为类中的第一个字段。在计算对象的总大小时,也必须考虑到虚表指针。这种情况在使用 new 操作符1 动态分配对象时最为明显,这时,传递给 new 的大小值不仅包括类(以及任何超类)中的所有显式声明的字段占用的空间,而且包括虚表指针所需的任何空间。

1. new 操作符在 C++ 中用于动态内存分配,与 C 中的 malloc 非常相似(尽管 new 是 C++ 语言中的内置运算符,而 malloc 仅仅是一个标准库函数)。

下面的例子动态创建了 SubClass 的一个对象,它的地址保存在 BaseClass 的一个指针中。然后,这个指针被传递给一个函数( call_vfunc ),它使用该指针来调用 vfunc3

void call_vfunc(BaseClass *b) {  
   b->vfunc3();  
}  

int main() {  
   BaseClass *bc = new SubClass();  
   call_vfunc(bc);  
}

由于 vfunc3 是一个虚函数,因此,在这个例子中,编译器必须确保调用 SubClass::vfunc3 ,因为指针指向一个 SubClass 对象。下面 call_vfunc 的反汇编版本说明了如何解析虚函数调用:

.text:004010A0 call_vfunc     proc near  
.text:004010A0  
.text:004010A0 b              = dword ptr  8  
.text:004010A0  
.text:004010A0                push    ebp  
.text:004010A1                mov     ebp, esp  
.text:004010A3                mov     eax, [ebp+b]
.text:004010A6              ➊ mov     edx, [eax]  
.text:004010A8                mov     ecx, [ebp+b]  
.text:004010AB              ➋ mov     eax, [edx+8]  
.text:004010AE              ➌ call    eax  
.text:004010B0                pop     ebp  
.text:004010B1                retn  
.text:004010B1 call_vfunc     endp

在➊处,虚表指针从结构体中读取出来,保存在 EDX 寄存器中。由于参数 b 指向一个 SubClass 对象,这里也将是 SubClass 的虚表的地址。在➋处,虚表被编入索引,将第三个指针(在本例中为 SubClass::vfunc3 的地址)读入 EAX 寄存器。最后,在➌处调用虚函数。

值得注意的是,➋处的虚表索引操作非常类似于结构体引用操作。实际上,它们之间并无区别。因此,我们可以定义一个结构体来表示一个类的虚表的布局,然后利用这个已定义的结构体来提高反汇编代码清单的可读性,如下所示:

00000000 SubClass_vtable struc ; (sizeof=0x14)  
00000000 vfunc1          dd ?  
00000004 vfunc2          dd ?  
00000008 vfunc3          dd ?  
0000000C vfunc4          dd ?  
00000010 vfunc5          dd ?  
00000014 SubClass_vtable ends

这个结构体允许将虚表引用操作重新格式化成以下形式:

.text:004010AB                 mov     eax, [edx+SubClass_vtable.vfunc3]

8.7.3 对象生命周期

了解对象的构建和撤销机制,有助于明确对象的层次结构和嵌套对象关系,并有助于迅速确定类构造函数和析构函数2

2. 类构造函数是一个初始化函数,它在创建对象时被自动调用。对应的析构函数为可选函数,它在对象脱离作用域或类似情况下被调用。

对全局和静态分配的对象来说,构造函数在程序启动并进入 main 函数之前被调用。栈分配的对象的构造函数在对象进入声明对象的函数作用域中时被调用。许多时候,对象一进入声明它的函数,它的构造函数就被调用。但是,如果对象在一个块语句中声明,那么,它的构造函数直到这个块被输入时才被调用(如果它确实被输入的话)。如果对象在程序堆中动态分配,则创建对象分为两个步骤。第一步,调用 new 操作符分配对象的内存。第二步,调用构造函数来初始化对象。微软的 Visual C++与 GNU 的 g++ 的主要区别在于,Visual C++可确保在调用构造函数之前, new 的结果不为空值( null )。

执行一个构造函数时,将会发生以下操作。

  1. 如果类拥有一个超类,则调用超类的构造函数。

  2. 如果类包含任何虚函数,则初始化虚表指针,使其指向类的虚表。注意,这样做可能会覆盖一个在超类中初始化的虚表指针,这实际上是希望的结果。

  3. 如果类拥有本身就是对象的数据成员,则调用这些数据成员的构造函数。

  4. 最后,执行特定于代码的构造函数。这些是程序员指定的、表示构造函数 C++ 行为的代码。

构造函数并未指定返回类型,但由 Microsoft Visual C++生成的构造函数实际上返回到 EAX 寄存器中的 this 指针。无论如何,这是一个 Visual C++ 实现细节,并不允许 C++ 程序员访问返回值。

析构函数基本上按相反的顺序调用。对于全局和静态对象,析构函数由在 main 函数结束后执行的清理代码调用。栈分配的对象的析构函数在对象脱离作用域时被调用。堆分配的对象的析构函数在分配给对象的内存释放之前通过 delete 操作符调用。

析构函数执行的操作与构造函数执行的操作大致相同,唯一不同的是,它以大概相反的顺序执行这些操作。

  1. 如果类拥有任何虚函数,则还原对象的虚表指针,使其指向相关类的虚表。如果一个子类在创建过程中覆盖了虚表指针,就需要这样做。

  2. 执行程序员为析构函数指定的代码。

  3. 如果类拥有本身就是对象的数据成员,则执行这些成员的析构函数。

  4. 最后,如果对象拥有一个超类,则调用超类的析构函数。

通过了解超类的构造函数和析构函数何时被调用,我们可以通过相关超类函数的调用链,跟踪一个对象的继承体系。有关虚表的最后一个问题涉及它们在程序中如何被引用。一个类的虚表被直接引用,只存在两种情况:在该类的构造函数中引用和在析构函数中引用。定位一个虚表后,你可以利用 IDA 的数据交叉引用功能(参见第 9 章)迅速定位相关类的所有构造函数和析构函数。

8.7.4 名称改编

名称改编 也叫做 名称修饰 (name decoration ),是 C++ 编译器用于区分重载函数3 的机制。为了给重载函数生成唯一的名称,编译器用其他字符来修饰函数名称,用来编码关于函数的各种信息。编码后的信息通常描述函数的返回类型、函数所属的类、调用该函数所需的参数序列(类型和顺序)。

3. 在 C++ 中, 函数重载 情况下,程序员可以对几个函数使用相同的名称。这样做的唯一要求是,重载函数的每个版本在函数接受的参数类型的顺序和数量上各不相同。换言之,每个函数的原型必须是唯一的。

名称改编是 C++ 程序的一个编译器实现细节,其本身并不属于 C++ 语言规范。因此,毫不奇怪,编译器供应商已经开发出他们自己的、通常并不互相兼容的名称改编约定。幸好,IDA 理解 Microsoft Visual C++、GNU g++ 以及其他一些编译器使用的名称改编约定。默认情况下,在程序中遇到一个改编名称时,IDA 会在反汇编代码清单中该名称出现的位置以注释的形式显示该名称的原始名称。使用 Options ▶Demangled Names 打开如图 8-15 所示的对话框,可以选择 IDA 的名称取消改编选项。

enter image description here

图 8-15 取消改编名称的显示选项

对话框中有 3 个主要选项,用于控制是否以注释的形式显示取消改编的名称(demangled name),是否对名称本身进行取消改编,或者根本不执行取消改编。以注释的形式显示改编名称可得到以下代码:

  .text:00401050 ; protected: __thiscall SubClass::SubClass(void)  
➊ text:00401050 ??0SubClass@@IAE@XZ  proc near  
   ...  
  .text:004010DC              ➋ call  ??0SubClass@@IAE@XZ  ; SubClass::SubClass(void)

同样,以名称显示改编名称得到以下代码:

➊ .text:00401050 protected: __thiscall SubClass::SubClass(void) proc near  
   ...  
   .text:004010DC  ➋ call    SubClass::SubClass(void)  

其中,➊表示一个函数的反汇编代码清单的第一行,➋表示调用该函数。

Assume GCC v3.x(采用 GCC v3.x 名称)复选框用于区分 g++ 2.9.x 版本与 g++ 3.x 及更高版本使用的改编方案。在正常情况下,IDA 应自动检测 g++ 编译代码中使用的命名约定。Setup short names (设置短名称)和 Setup long names (设置长名称)按钮为取消改编名称的格式化提供了细化控制,其中包括大量选项,相关信息请查询 IDA 的帮助系统。

因为改编名称能提供大量与函数签名有关的信息,它们减少了 IDA 为理解传递给函数的参数的数量和类型所需的时间。如果一个二进制文件使用了改编名称,IDA 的取消改编功能会立即揭示所有名称被改编的函数的参数类型和返回类型。相反,如果函数并未使用改编名称,你必须花费大量时间,对进出函数的数据流进行分析,从而确定函数的签名。

8.7.5 运行时类型识别

C++ 提供各种操作符,可进行运行时检测,以确定( typeid )和检查( dynamic_cast )一个对象的数据类型。为实现这些操作,C++ 编译器必须将类型信息嵌入到一个程序的二进制文件中,并执行能够百分之百确定一个多态对象的类型的过程,而不管为访问该对象被取消引用的指针类型。然而,与名称改编一样,RTTI(Runtime Type Identification ,运行时类型识别)也是一个编译器实现细节,而不是一个语言问题,因此,编译器没有标准的方法来实现 RTTI 功能。

我们将简要介绍 Microsoft Visual C++与 GNU g++ 的 RTTI 实现之间的异同。具体来说,我们介绍的内容仅仅涉及如何定位 RTTI 信息,并以此为基础,了解与这些信息有关的类名称。有关微软的 RTTI 实现的详细讨论,请参阅 8.7.7 节,其中详细说明了如何遍历一个类的继承体系,包括如何在存在多继承的情况下跟踪继承体系。

以下面这个利用多态的简单程序为例:

class abstract_class{  
public:  
   virtual int vfunc() = 0;  
};  

class concrete_class : public abstract_class {  
public:  
   concrete_class();  
   int vfunc();  
};   
void print_type(abstract_class *p) {  
   cout << typeid(*p).name() << endl;  
}  

int main() {  
   abstract_class *sc = new concrete_class();  
   print_type(sc);  
}

print_type 函数必须正确打印指针 p 所指向的对象的类型。在这个例子中,基于 main 函数创建了一个 concrete_class 对象这个事实,我们立即意识到, concrete_class 必须被打印。这里我们需要回答的问题是: print_type ,更具体的说是 typeid ,如何知道 p 指向的对象的类型?

问题的答案非常简单。因为每个多态对象都包含一个指向虚表的指针,编译器将类的类型信息与类虚表存储在一起。具体来说,编译器在类虚表之前放置一个指针,这个指针指向一个结构体,其中包含用于确定拥有虚表的类的名称所需的信息。在 g++ 代码中,这个指针指向一个 type_info 结构体,其中包含一个指向类名称的指针。在 Visual C++ 代码中,指针指向一个微软 RTTICompleteObjectLocator 结构体,其中又包含一个指向 TypeDescriptor 结构体的指针。 TypeDescriptor 结构体中则包含一个指定多态类名称的字符数组。

需要注意的是,只有使用 typeiddynamic_cast 操作符的 C++ 程序才需要 RTTI 信息。多数编译器都提供一些选项,禁止不需要 RTTI 的二进制文件生成 RTTI。因此,如果 RTTI 信息碰巧丢失,你不应感到奇怪。

8.7.6 继承关系

如果深入 RTTI 实现,你会发现,你可以弄清继承关系。但是,要了解继承关系,你必须理解编译器的特殊 RTTI 实现。而且,如果一个程序不使用 typeiddynamic_cast 运算符,RTTI 信息可能并不存在。缺少 RTTI 信息,又该使用什么技巧来确定 C++ 类中的继承关系呢?

确定某个继承体系的最简单方法是,观察在创建对象时被调用的超类构造函数的调用链。内联4 构造函数是这种方法成功与否的唯一最大障碍。如果使用了内联构造函数,我们就不可能知道一个超类构造函数实际上已经被调用。

4. 在 C/C++ 程序中,一个声明为 inline 的函数将被编译器作为宏处理,并且该函数的代码将被扩展,以替代一个显式函数调用。由于存在汇编语言调用语句是一个函数被调用的确凿证据,使用内联函数则倾向于隐藏了函数被调用这一事实。

分析和比较虚表是另一种用于确定继承关系的方法。例如,如图 8-14 所示,在比较虚表的过程中,我们注意到,SubClass 的虚表中包含两个相同的指针,它们也出现在类 BaseClass 的虚表中。为此,我们可以轻易得出结论,BaseClass 与 SubClass 之间必定存在着某种关系,但到底 SubClass 是 BaseClass 的子类,还是 BaseClass 是 SubClass 的子类呢?遇到这类情况,我们可以应用下面的一条或多条指导原则,设法了解它们之间的关系。

  • 如果两个虚表包含相同数量的条目,则与这两个虚表对应的类之间可能存在着某种继承关系。

  • 如果类 X 的虚表包含的条目比类 Y 多,则 X 可能是 Y 的子类。

  • 如果 X 包含的条目也可以在 Y 的虚表中找到,则必定存在下面一种关系:X 是 Y 的子类,Y 是 X 的子类,或者 X 和 Y 全都是同一个超类 Z 的子类。

  • 如果 X 包含的条目也可以在类 Y 的虚表中找到,并且 X 的虚表中至少包含一个纯调用条目,而 Y 的虚表中并没有这个条目,那么 Y 是 X 的子类。

虽然上面罗列的并不全面,但是我们可以使用这些指导原则来推断图 8-14 中 BaseClass 与 SubClass 之间的关系。上面的后 3 条原则全都适用于这个例子,但仅仅根据对虚表的分析,由最后一条原则可得出结论:SubClass 是 BaseClass 的子类。

8.7.7 C++ 逆向工程参考文献

有关逆向工程已编译 C++ 代码的补充阅读内容,请参阅下面这些详尽的参考文献。

虽然这些文章中的许多细节主要适用于使用 Microsoft Visual C++编译的程序,但许多概念也同样适用于使用其他 C++ 编译器编译的程序。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文