应用安全 --- 逆向工程 之 C++类的本质
C++ 类 = C 结构体 + 专属于它的一批函数
1. 类里的数据 = C 结构体 class A { int x; int y; }; 在内存里 =struct A { int x; int y; }; 一模一样,没有任何区别。对象就是一块内存,布局完全相同。 2. 成员函数 = 专门操作这个结构体的 C 函数 你写: void A::set(int v) { x = v; } 编译器内部理解成: void A_set(struct A *this, int v) { this->x = v; } 调用: a.set(10); 变成: A_set(&a, 10); 这就是全部真相。 3. 所谓 “面向对象”,汇编里只多了一件事 C++ 比 C 多的唯一一件事: 自动把对象地址当作隐藏第一个参数 this 传进去。 仅此而已。没有魔法,没有高级结构,没有玄学。 4. 扩展一句:带虚函数的类也没变本质 加了 virtual 之后: 结构体前面多了一个指针 vptr 虚函数表是一个函数指针数组 本质依然是:结构体 + 操作它的函数。 终极一句话(可以刻在脑子里) C++ 类,就是给 C 结构体绑定了一批专用函数, 并自动把结构体地址当 this 传进去的语法糖。
我用一个具体的demo演示
#include <iostream> // 最小化的C++类示例 class SimpleClass { private: int value; // 私有成员变量 public: // 构造函数 SimpleClass(int v) : value(v) {} // 成员函数:获取值 int getValue() const { return value; } // 成员函数:设置值 void setValue(int v) { value = v; } // 成员函数:显示值 void display() const { std::cout << "Value: " << value << std::endl; } }; int main() { // 创建对象 SimpleClass obj(42); // 显示初始值 std::cout << "Initial value: " << obj.getValue() << std::endl; // 修改值 obj.setValue(100); // 显示修改后的值 std::cout << "Modified value: " << obj.getValue() << std::endl; // 使用display方法 obj.display(); return 0; }
C++ 源码与 IDA 反编译对比分析 1. 整体结构对比 text 源码结构 IDA反编译结构 ───────────────────────────────────────────────────── SimpleClass类 独立函数形式 ├── 构造函数 SimpleClass::SimpleClass() ├── getValue() SimpleClass::getValue() ├── setValue() SimpleClass::setValue() └── display() SimpleClass::display() 2. 关键差异详细分析 2.1 类的本质还原 C++ // 源码:面向对象形式 class SimpleClass { private: int value; // 高层抽象 public: SimpleClass(int v) : value(v) {} }; // IDA反编译:底层实现暴露 SimpleClass *__fastcall SimpleClass::SimpleClass(SimpleClass *this, int a2) { *(_DWORD *)this = a2; // 直接操作内存! return this; // 返回this指针 } 核心发现: 类在编译后不存在,只剩下内存布局 value 成员变量 → 变成 *(_DWORD *)this(this偏移0处的4字节) 成员函数 → 普通函数,this指针作为隐式第一参数显式传入 2.2 内存布局分析 text SimpleClass对象内存布局 (大小=4字节): ┌─────────────────────────┐ │ offset+0: value (int) │ ← *(_DWORD *)this │ [4 bytes] │ └─────────────────────────┘ // IDA中对应: v10[20] // 栈上分配,实际只用4字节 // 20字节是编译器对齐/调试信息导致 2.3 栈对象分配对比 C++ // 源码 SimpleClass obj(42); // IDA反编译 _BYTE v10[20]; // [rsp+2Ch] [rbp-4h] BYREF // ↑ ↑ // 栈上原始字节数组 标记为可引用(BYREF) SimpleClass::SimpleClass((SimpleClass *)v10, 42); // ↑ // 将栈内存强转为对象指针 源码视角 编译器视角 SimpleClass obj _BYTE v10[20] 栈内存 自动调用构造函数 显式调用 SimpleClass::SimpleClass(v10, 42) obj.method() SimpleClass::method(v10) 2.4 调用约定 __fastcall 分析 C++ __int64 __fastcall SimpleClass::getValue(SimpleClass *this) // ↑ ↑ // 返回值用寄存器 this通过RCX寄存器传递(x64) // x64寄存器传参规则: // RCX = 第1参数 (this指针) // RDX = 第2参数 // R8 = 第3参数 // R9 = 第4参数 2.5 成员函数逐一对比 构造函数 C++ // 源码 SimpleClass(int v) : value(v) {} // IDA SimpleClass *__fastcall SimpleClass::SimpleClass(SimpleClass *this, int a2) { *(_DWORD *)this = a2; // value = v return this; // 源码中隐式,编译后显式返回this } getValue() C++ // 源码 int getValue() const { return value; } // IDA __int64 __fastcall SimpleClass::getValue(SimpleClass *this) { return *(unsigned int *)this; // 读取this[0]处4字节 // ↑ 注意:用unsigned int,与源码int有符号性差异 } ⚠️ 类型信息丢失:int → unsigned int,IDA无法100%还原有符号性 setValue() C++ // 源码 void setValue(int v) { value = v; } // IDA SimpleClass *__fastcall SimpleClass::setValue(SimpleClass *this, int a2) { *(_DWORD *)this = a2; // value = v return this; // 源码返回void,但编译器仍传递this } ⚠️ 返回值差异:源码void,IDA显示返回SimpleClass* 这是因为x64调用约定中RAX寄存器始终存有this,IDA误判为返回值 display() C++ // 源码 void display() const { std::cout << "Value: " << value << std::endl; } // IDA __int64 __fastcall SimpleClass::display(SimpleClass *this) { v1 = std::operator<<<...>(refptr__ZSt4cout, "Value: "); v2 = std::ostream::operator<<(v1, *(unsigned int *)this); // ↑ 链式调用被拆解为顺序的中间变量 return std::ostream::operator<<(v2, refptr__ZSt4endl...); } 2.6 流操作符还原对比 C++ // 源码(简洁的链式调用) std::cout << "Initial value: " << obj.getValue() << std::endl; // IDA(完全展开) v3 = std::operator<<<std::char_traits<char>>( refptr__ZSt4cout, // cout对象 "Initial value: "); // 字符串 Value = SimpleClass::getValue((SimpleClass *)v10); v5 = std::ostream::operator<<(v3, Value); // 输出整数 std::ostream::operator<<( // 输出endl v5, refptr__ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_ // ↑ Name mangling后的endl符号名 ); 3. Name Mangling(名称修饰) text 源码符号名 编译后符号名(Mangled) ───────────────────────────────────────────────────── std::endl → _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_ std::cout → _ZSt4cout SimpleClass::getValue → _ZN11SimpleClass8getValueEv text 解码规则示例:_ZN11SimpleClass8getValueEv _Z = 名称修饰标记 N = 命名空间/类开始 11SimpleClass = 11个字符的"SimpleClass" 8getValue = 8个字符的"getValue" E = 命名空间/类结束 v = 参数为void 4. 总结:编译过程信息损失表 特性 源码 IDA反编译 信息损失 类定义 class SimpleClass 无 ❌ 完全丢失 访问控制 private/public 无 ❌ 完全丢失 成员变量名 value *(DWORD*)this ❌ 丢失 类型有符号性 int unsigned int ⚠️ 部分丢失 链式调用 cout<<a<<b 拆分为v1,v2,v3 ⚠️ 结构改变 函数语义 const修饰 无 ❌ 丢失 构造/析构 自动调用 显式调用 ✅ 可识别 函数名 正常名称 需demangle ⚠️ 可还原
我来定位 `main`、`SimpleClass` 的构造函数、`getValue`、`setValue`、`display` 这几个关键符号。拿到所有关键函数地址后,读取它们的反汇编:`main` (0x140001640)、`SimpleClass::SimpleClass` (0x140001730)、`getValue` (0x140001760)、`setValue` (0x140001780)、`display` (0x1400017B0)。素材齐全了。下面把这份 IDA 反汇编和源码逐行对照,把"C++ 类在机器层面的真相"讲清楚。 --- # C++ 类在底层到底长什么样——对照 IDA 反汇编逐个讲 ## 一、先看个"总地图":源码里的 5 个成员 ↔ IDA 里的 5 个符号 | C++ 源码中的写法 | IDA 里变成的符号(修饰后) | 地址 | |---|---|---| | `SimpleClass::SimpleClass(int v)` | `_ZN11SimpleClassC1Ei` | `0x140001730` | | `SimpleClass::getValue() const` | `_ZNK11SimpleClass8getValueEv` | `0x140001760` | | `SimpleClass::setValue(int v)` | `_ZN11SimpleClass8setValueEi` | `0x140001780` | | `SimpleClass::display() const` | `_ZNK11SimpleClass7displayEv` | `0x1400017B0` | | `int main()` | `main` | `0x140001640` | 解密符号命名(Itanium ABI): ``` _ZN 11SimpleClass C1 E i ← 11=类名长度, C1=完整构造器, E 结束, i=int参数 _ZNK11SimpleClass 8getValue Ev ← K=const 成员, v=无参 _ZN 11SimpleClass 8setValue Ei _ZNK11SimpleClass 7display Ev ``` 光从符号名就能看出:**成员函数跟普通函数没有本质区别**,只是 C++ 编译器把"所属类"和"参数表"一起编码进了函数名而已。 --- ## 二、底层原理一句话先说清 > **C++ 里的"对象",在机器层面就是"一块裸内存";所谓"对象.方法()",在机器层面就是"把对象的首地址当成隐藏的第 1 个参数(`this`),去调用一个普通函数"。** Windows x64 的 ABI(也就是你这个 exe 用的)规定:前 4 个参数依次放进 `RCX, RDX, R8, R9`。C++ 再加一条:**`this` 永远占用第 1 个参数的位置**。所以: ``` obj.getValue() → getValue(RCX=&obj) obj.setValue(100) → setValue(RCX=&obj, RDX=100) SimpleClass obj(42); → SimpleClass::C1(RCX=&obj, RDX=42) ``` 下面我们就一条条去 IDA 里验证。 --- ## 三、`main` 逐行拆解(0x140001640) ``` .text:140001640 push rbp .text:140001641 mov rbp, rsp .text:140001644 sub rsp, 40h ; ★ 在栈上直接腾出空间放对象 obj ; 对象 obj 就在 rbp-0x4 这个位置(见下文) ``` **关键点 ①:对象不是"new 出来的",而是"栈上的一段普通内存"**。`SimpleClass` 只有一个 `int value`(4 字节),所以它在机器层面就是 4 个字节,占位符 `[rbp-4]`。 ### 3.1 `SimpleClass obj(42);` ``` .text:140001654 lea rax, [rbp+var_4] ; rax = &obj ← 取对象地址 .text:140001658 mov edx, 2Ah ; rdx = 42 ← 构造参数 v .text:14000165D mov rcx, rax ; rcx = this = &obj .text:140001660 call _ZN11SimpleClassC1Ei ; ★ 调构造函数 ``` **翻译**:这三条汇编把 `rax` 指向栈上 `obj` 所占的那 4 个字节,然后以"第 1 参数=this、第 2 参数=42"的形式调用构造函数。源码一句 `SimpleClass obj(42);`,底层就变成了 *"开一块内存 + 调一次普通函数"*。 ### 3.2 `obj.getValue()` ``` .text:140001665 lea rax, [rbp+var_4] ; &obj .text:140001669 mov rcx, rax ; rcx = this .text:14000166C call _ZNK11SimpleClass8getValueEv ; 返回值 42 放在 EAX 里 .text:140001671 mov esi, eax ; 保存到 esi 备用 ; 然后把字符串和数字喂给 std::cout << ... << ... .text:140001673 mov edx, offset "Initial value: " .text:140001678 mov rcx, offset std::cout .text:14000167F call std::operator<<(std::ostream&, char const*) .text:140001684 mov edx, esi ; 把前面保存的返回值传给 << int .text:140001686 mov rcx, rax ; rcx = 上一次 << 的返回值(也就是 cout 自己) .text:140001689 call std::ostream::operator<<(int) ; ... 后面再 << endl ``` **两个关键观察**: 1. 成员函数的返回值跟普通函数一模一样,放在 `EAX/RAX` 里。 2. `cout << ...` 这种看起来很"面向对象"的流式写法,底层其实是**链式调用**:每次 `<<` 返回 `cout` 本身,下一次 `<<` 的 `this` 就用这个返回值——这是为什么你会看到 `mov rcx, rax` 这种"把前一个 call 的返回值当下一个 call 的 this 用"的模式。 ### 3.3 `obj.setValue(100);` ``` .text:1400016BC lea rax, [rbp+var_4] .text:1400016C0 mov edx, 64h ; 64h = 100 ← 参数 v .text:1400016C5 mov rcx, rax ; this = &obj .text:1400016C8 call _ZN11SimpleClass8setValueEi ``` 和普通函数调用**没有任何区别**。唯一"面向对象"的味道,就是第 1 参数固定是对象地址而已。 ### 3.4 `obj.display();` ``` .text:140001719 lea rax, [rbp+var_4] .text:14000171D mov rcx, rax .text:140001720 call _ZNK11SimpleClass7displayEv ``` 完全一样的套路。 --- ## 四、四个成员函数的反汇编 ### 4.1 构造函数 `SimpleClass::SimpleClass(int v)` @ `0x140001730` ``` .text:140001730 push rbp .text:140001731 mov rbp, rsp .text:140001734 mov [rbp+arg_0], rcx ; 保存 this .text:140001738 mov [rbp+arg_8], edx ; 保存参数 v .text:14000173B mov rax, [rbp+arg_0] ; rax = this .text:14000173F mov edx, [rbp+arg_8] ; edx = v .text:140001742 mov [rax], edx ; ★★ *this = v; (把 v 写到 this 指向的 4 字节) .text:140001744 nop .text:140001745 pop rbp .text:140001746 retn ``` **通俗翻译**:构造函数的本质,就是"**把初始化值写进这块内存**"。源码 `value(v)` 编译下来就是一条 `mov [rax], edx`。没有什么魔法。 **为什么是 `[rax]` 而不是 `[rax+偏移]`?** 因为 `value` 是这个类里唯一的成员、偏移就是 0;如果你加一个 `int value2;`,它就会变成 `mov [rax+4], ...`。成员变量"住在哪里",在编译期就已经固定成偏移常量了。 ### 4.2 `getValue()` @ `0x140001760` ``` .text:140001763 mov [rbp+arg_0], rcx ; 保存 this .text:140001767 mov rax, [rbp+arg_0] ; rax = this .text:14000176B mov eax, [rax] ; ★ eax = *this (读 value) .text:14000176D pop rbp .text:14000176E retn ``` **通俗翻译**:`return value;` = "从 this 指向的内存里读 4 字节,放进 EAX 返回"。 **为什么 `const` 没在汇编里留痕?** 因为 `const` 是**编译期约束**:它只阻止你在源码里修改 `*this`,到机器层面早就"信息擦除"了——`getValue()` 的汇编跟一个"只读全局函数"毫无差别。 ### 4.3 `setValue(int v)` @ `0x140001780` ``` .text:140001783 mov [rbp+arg_0], rcx ; 保存 this .text:140001787 mov [rbp+arg_8], edx ; 保存参数 v .text:14000178A mov rax, [rbp+arg_0] .text:14000178E mov edx, [rbp+arg_8] .text:140001791 mov [rax], edx ; ★ *this = v .text:140001793 nop .text:140001794 pop rbp .text:140001795 retn ``` **它和构造函数几乎一模一样**,这并非巧合: > 对 C++ 编译器来说,**"构造一个只含 int 的对象" 和 "赋值 int 字段" 在指令层面是同一件事**——都是往同一个偏移写同一个值。二者的差别完全在源码语义层(生命周期开始 vs 生命周期中赋值),编译后就都塌缩成了 `mov [rax], edx`。 ### 4.4 `display() const` @ `0x1400017B0` ``` .text:1400017B0 push rbp .text:1400017B1 push rbx ; 要用到 rbx(被调方保存寄存器),先保存 .text:1400017B2 sub rsp, 28h ; 给 Win64 的 "shadow space" 留位子 .text:1400017B6 lea rbp, [rsp+20h] .text:1400017BB mov [rbp+20h], rcx ; 保存 this ; --- std::cout << "Value: " --- .text:1400017BF mov edx, "Value: " .text:1400017C4 mov rcx, offset std::cout .text:1400017CB call std::operator<<(ostream&, char const*) ; --- << value --- .text:1400017D0 mov rbx, rax ; 上一个 << 返回的 cout 存进 rbx .text:1400017D3 mov rax, [rbp+20h] ; rax = this .text:1400017D7 mov eax, [rax] ; ★ eax = this->value .text:1400017D9 mov edx, eax ; 第 2 参数 = value .text:1400017DB mov rcx, rbx ; 第 1 参数 = 上一次的 cout .text:1400017DE call std::ostream::operator<<(int) ; --- << std::endl --- .text:1400017E3 mov rdx, offset std::endl<char, std::char_traits<char>> .text:1400017EA mov rcx, rax .text:1400017ED call std::ostream::operator<<(std::ostream& (*)(std::ostream&)) ``` **这里有一个极其重要的原理揭示**:你看 `std::endl` 不是一个"字符"而是 `offset` 一个**函数的地址**! > C++ 的 `std::endl` 本质上是一个函数 `ostream& endl(ostream&)`,`cout << endl` 被解析成"调用一个接收函数指针的 `operator<<` 重载",那个 `operator<<` 内部再调用该函数指针去真正完成"输出 `\n` 并刷新缓冲区"的工作。 这也解释了一个常见面试题:*"为什么 `"\n"` 比 `std::endl` 快?"* ——因为 `"\n"` 走的是"输出一个字符串"重载,而 `endl` 走的是"再 call 一个函数指针(里面还包含 flush)"重载。你在 IDA 里就能看到这差别。 --- ## 五、把源码一行行翻译成机器动作 | 源码 | 机器层面发生了什么 | |---|---| | `class SimpleClass { int value; };` | 在编译期告诉编译器:"这个类型大小 = 4 字节,`value` 字段偏移 = 0"。运行期无任何代码产生。 | | `SimpleClass obj(42);` | 在栈上预留 4 字节,把其地址作 `this` 传给构造函数,构造函数写入 42。 | | `obj.getValue()` | `call getValue(this=&obj)` → 返回 `*this` 放进 `EAX`。 | | `obj.setValue(100)` | `call setValue(this=&obj, v=100)` → 往 `*this` 写 100。 | | `obj.display()` | `call display(this=&obj)` → 读 `*this` 并喂给 `cout << ...` 链。 | | `private:` | **纯编译期权限检查**,0 条机器指令。 | | `const` 成员函数 | **纯编译期限制**(禁止修改 `*this`),0 条机器指令差别。 | --- ## 六、"类" 的底层本质——4 条结论 通过这次汇编对照,我们可以把 C++ 类的底层原理归纳成 **4 条铁律**: **结论 1:类 = "一块内存 + 一组跟内存首地址相关的函数"** `obj` 本身在内存里只有 4 字节,没有藏任何"函数指针"、"类型信息"(因为没有虚函数)。**成员函数不占对象的空间**,它们是独立存在于 `.text` 段里的普通函数。 **结论 2:`this` 是一个"隐藏的第 1 参数"** 这是 C++ 成员函数唯一真正"特殊"的地方。源码里"隐身"的 `this`,到汇编层永远是 `RCX`(在 Win64 ABI 下)。 **结论 3:成员变量访问 = 固定偏移寻址** `this->value` 在底层就是 `[rcx+偏移]`。偏移在**编译期**就定死了。这也解释了为什么 C++ 访问成员变量和 C 访问结构体成员速度完全一样——**它们就是同一回事**。 **结论 4:访问控制、`const` 等修饰符是"编译期魔法",运行期不存在** `private`、`public`、`const`、`override`、`final` 这些关键字,在机器码里一个字节都不占。它们只在编译器的检查阶段起作用。IDA 里你看不到任何 `private` 的痕迹——因为根本没有。 --- ## 七、最后给你一个"记忆口诀" 如果你用 C 的视角重写一遍这个例子,会立刻看穿 C++ 的把戏: ```c // 等价的 C 代码(这就是编译器内部看到的样子) typedef struct { int value; } SimpleClass; void SimpleClass_ctor (SimpleClass *this, int v) { this->value = v; } int SimpleClass_getValue(const SimpleClass *this) { return this->value; } void SimpleClass_setValue(SimpleClass *this, int v) { this->value = v; } void SimpleClass_display (const SimpleClass *this) { printf("Value: %d\n", this->value); } int main() { SimpleClass obj; SimpleClass_ctor(&obj, 42); printf("Initial value: %d\n", SimpleClass_getValue(&obj)); SimpleClass_setValue(&obj, 100); printf("Modified value: %d\n", SimpleClass_getValue(&obj)); SimpleClass_display(&obj); return 0; } ``` **这份 C 代码的汇编,和你 IDA 里看到的那份 C++ 代码的汇编,在结构上是一一对应的。** 所以,**C++ 类只是 C 的语法糖**——只不过编译器替你自动:① 把对象地址变成第 1 参数;② 把函数名加上类名前缀防止冲突;③ 编译期帮你做权限/const 检查。仅此而已。至于虚函数、多态那些"真正花哨的东西",就是在此基础上再加一个 vptr + vtable(就像我们之前分析 Dobby 的 `CPURegister` 那样),原理依然是"一块内存 + 一组函数"。
免责声明
本文档所有内容仅供安全研究、学术交流与技术学习使用,严禁用于任何未经授权的逆向破解、网络攻击、隐私窃取、恶意软件开发及其他违反《中华人民共和国网络安全法》《数据安全法》等法律法规的行为,使用者应确保已获得目标软件权利人的合法授权并自行承担因使用本文档内容所产生的一切法律责任与后果,作者不对任何直接或间接损害承担任何责任,继续阅读即视为您已知悉并同意上述全部条款。
浙公网安备 33010602011771号