14. C++逆向基础:this指针、虚函数表、构造与析构、继承与多态的识别
各位好,我是老周。今天咱们聊聊C++逆向里最核心的几个概念。说实话,很多新手一看到C++逆向就头大,觉得比C语言复杂太多。其实不然,你只要抓住了几个关键点——this指针怎么传、虚函数表怎么布局、构造析构的顺序、继承多态在内存里长什么样——那C++逆向就跟看自家后院一样清楚。
我个人习惯把C++逆向比作「拆积木」。你看到的每个对象,在内存里都是一块块积木拼起来的。咱们今天就把这些积木一块块拆开看。
14.1 this指针:对象自己的「身份证」
先说说this指针。这东西说白了就是每个对象自己的地址。在C++里,每个成员函数调用时,编译器都会偷偷把对象的地址塞进去。怎么塞的?不同平台不一样。
x86平台(32位):this指针通常通过ecx寄存器传递。你反汇编看到函数开头有mov ecx, [esp+4]或者lea ecx, [ebp-8],那八成就是this指针。
x64平台(64位):this指针通过rcx寄存器传递。因为x64调用约定前四个参数都用寄存器传,this指针就占了第一个位置。
ARM平台:this指针通过r0寄存器传递。ARM的调用约定里,第一个参数就是r0。
关键识别点:
- 函数内部频繁使用
ecx/rcx/r0做基址偏移访问 - 函数开头没有明显的参数压栈(因为this是寄存器传的)
- 看到
mov eax, [ecx+offset]这种模式,基本就是访问成员变量
我在项目中遇到过这么个坑:有一次逆向一个游戏外挂,发现某个函数明明有5个参数,但反汇编里只看到4个压栈。我琢磨了半天,后来才意识到第一个参数是通过ecx传的this指针。嗯,这就是典型的C++成员函数特征。
14.2 虚函数表(vtable):多态的「调度中心」
虚函数表,简称vtable,是C++实现多态的核心机制。你想想看,如果基类指针调用一个虚函数,编译器怎么知道该调用哪个版本的函数?答案就是vtable。
vtable的内存布局:
- 每个包含虚函数的类,编译器会生成一个vtable
- vtable是一个函数指针数组,按声明顺序排列
- 每个对象的前4/8字节(32/64位)是一个指向vtable的指针,叫vptr
- vtable的索引0通常不是第一个虚函数,而是
typeinfo指针(用于RTTI)
看个例子,假设有这样一个类:
class Base {
public:
virtual void func1() { ... }
virtual void func2() { ... }
virtual void func3() { ... }
};
在内存里,vtable长这样:
vtable_Base:
[0] typeinfo指针(指向Base的typeinfo)
[1] &Base::func1
[2] &Base::func2
[3] &Base::func3
对象的内存布局:
对象地址: [vptr] -> vtable_Base
[成员变量1]
[成员变量2]
...
逆向识别技巧:
- 看到函数开头有
mov eax, [ecx]然后call [eax+offset],这就是典型的虚函数调用 - vtable通常位于只读数据段(.rdata),里面全是函数地址
- 构造函数里一定有
mov [ecx], offset vtable_XXX这样的代码,这是设置vptr
我曾经逆向过一个大型MMO客户端,里面有个类有30多个虚函数。一开始我手动一个个数,后来发现IDA Pro的「Class Browser」插件可以直接解析vtable。嗯,工具要用好,能省不少力气。
14.3 构造函数与析构函数:对象的「生与死」
构造函数和析构函数,说白了就是对象的初始化和清理工作。逆向时识别它们有几个关键特征。
构造函数特征:
- 函数名通常被编译器修饰为
??0ClassName@@QAE@XZ(MSVC)或_ZN8ClassNameC1Ev(GCC) - 函数内部一定会设置vptr:
mov [ecx], offset vtable_XXX - 会初始化所有成员变量(至少是那些有构造函数的成员)
- 如果有基类,会先调用基类构造函数
析构函数特征:
- 函数名修饰为
??1ClassName@@QAE@XZ(MSVC)或_ZN8ClassNameD1Ev(GCC) - 函数内部会重置vptr(指向当前类的vtable,防止析构时调用到派生类的虚函数)
- 会释放资源(delete、free等)
- 如果有基类,析构函数执行完后会调用基类析构函数
注意:MSVC编译器会生成多个版本的构造函数和析构函数:
??0ClassName@@QAE@XZ:普通构造函数??0ClassName@@QAE@ABV0@@Z:拷贝构造函数??1ClassName@@QAE@XZ:普通析构函数??_GClassName@@UAEPAXI@Z:删除析构函数(用于delete操作)
我记得有一次分析一个恶意软件,它用C++写的,但把所有符号都strip掉了。我通过查找vptr的设置点,硬是定位到了它的构造函数,然后顺着构造函数找到了所有成员变量的初始化逻辑。这招在恶意软件分析里特别管用。
14.4 继承与多态的识别:看vtable怎么「串」起来的
继承和多态,是C++逆向里最精彩的部分。你想想看,一个派生类对象,它的vtable里既有自己的虚函数,也有从基类继承来的虚函数。怎么区分?看vtable的布局。
单继承:
- 派生类对象只有一个vptr
- vtable里先放基类的虚函数,再放派生类新增的虚函数
- 如果派生类重写了基类的虚函数,vtable里对应位置会被替换成派生类的函数地址
多继承:
- 派生类对象有多个vptr(每个基类一个)
- 每个vptr指向不同的vtable
- 派生类自己的虚函数通常放在第一个基类的vtable末尾
- 调用第二个基类的虚函数时,需要调整this指针(thunk函数)
看个多继承的例子:
class Base1 {
public:
virtual void f1() { ... }
virtual void f2() { ... }
};
class Base2 {
public:
virtual void g1() { ... }
virtual void g2() { ... }
};
class Derived : public Base1, public Base2 {
public:
virtual void f1() override { ... } // 重写Base1的f1
virtual void h1() { ... } // 新增虚函数
};
内存布局:
Derived对象:
[vptr1] -> vtable_Derived_Base1
[Base1成员变量]
[vptr2] -> vtable_Derived_Base2
[Base2成员变量]
[Derived新增成员变量]
vtable_Derived_Base1:
[0] typeinfo指针
[1] &Derived::f1 // 重写了Base1的f1
[2] &Base1::f2 // 继承自Base1
[3] &Derived::h1 // 新增虚函数
vtable_Derived_Base2:
[0] typeinfo指针
[1] &thunk: Derived::g1 // 需要调整this指针
[2] &thunk: Derived::g2 // 需要调整this指针
多继承的逆向识别要点:
- 看到对象里有多个vptr(多个
mov [ecx+offset], offset vtable) - 看到thunk函数(一小段代码,作用是调整ecx/rcx的值,然后跳转到真正的函数)
- thunk函数通常很短,就几条指令:
sub ecx, offset; jmp real_function
我曾经在逆向一个商业软件时,遇到了一个极其复杂的多继承体系。那个类继承了6个基类,每个基类都有虚函数。我花了整整两天才理清楚它的vtable布局。后来我写了个IDA脚本,自动解析vtable并标注每个函数的作用。嗯,从那以后我就养成了「能自动化就自动化」的习惯。
14.5 实战技巧:快速识别C++对象的步骤
好了,理论讲完了,咱们来点实战的。如果你拿到一个二进制文件,怎么快速识别C++对象?我一般按这个步骤来:
- 找构造函数:搜索
mov [ecx], offset vtable这种模式,找到所有vptr的设置点 - 分析vtable:跳转到vtable所在地址,把里面的函数指针都提取出来
- 确定继承关系:看vtable里有没有重复的函数地址,或者有没有thunk函数
- 识别成员变量:通过构造函数里对
[ecx+offset]的访问,推断成员变量的偏移和类型 - 验证虚函数调用:找到调用虚函数的地方,看
call [eax+offset]的偏移是否匹配vtable里的索引
工具推荐:
- IDA Pro + Hex-Rays:自动识别vtable和虚函数调用
- Ghidra:开源,支持C++类分析
- WinDbg:调试时查看vtable内容
- 我自己写了个IDA脚本叫「vtable_analyzer.py」,可以自动标注vtable里的每个函数
最后说一句,C++逆向其实没那么可怕。你只要理解了this指针怎么传、vtable怎么布局、构造析构的顺序、继承多态在内存里的表现,那剩下的就是熟练度的问题了。多练,多看,慢慢就上手了。
公众号:蓝海资料掘金营,微信deep3321