做逆向分析时,碰上一个带纯虚函数的类,很多新手会直接懵:析构函数里明明调了那个函数,可跳过去一看,函数体居然是__cxa_pure_virtual或者直接就是ud2异常指令,代码根本不存在。这就是抽象类反汇编的经典场景。抽象类在源码层面很好认,virtual void func() = 0一写就完事,但到了二进制层面,抽象类的“抽象”并不是靠编译器加个标记来体现的,而是通过一系列微妙的结构特征传递出来的——虚表指针的安装位置、虚表里特定槽位的内容、构造函数的执行顺序、RTTI 类型信息里的修饰标记,这些组合起来才是它在机器码里的真实形态。
这篇文章我从三个方向展开:先讲透抽象类在编译器和内存模型里的本质,再给出一套在反汇编里定位抽象类的实操方法,最后结合具体案例拆解抽象类与普通类在二进制层面的关键差异。适合正在啃逆向、做恶意样本分析或者搞程序理解的读者,直接拿 IDA 或 Ghidra 对着样本就能用上的那种。
1. 抽象类的二进制本质:编译器到底做了什么
理解抽象类反汇编,第一步要跳出源码思维。源码里的= 0是一个语法层面的契约,它告诉编译器“这个类不能被实例化,子类必须实现这个函数”。但编译器翻译成机器码时,并不会在某个字节里写一行“这是个抽象类”的注释,一切都得靠结构推断。
1.1 虚函数表:抽象类栖身的核心结构
C++ 的动态多态依赖虚表(vtable)实现。只要类里存在虚函数,编译器就会为这个类生成一张虚表,表中每个条目是一个函数指针,指向该类的虚函数实现。抽象类同样有虚表,但它的虚表里存在“空槽”或“特殊槽”。
纯虚函数在虚表里对应的槽位,通常不会指向一个正常的函数实现,而是指向编译器内置的特殊处理函数。以 GCC/Clang 编译 Linux ELF 为例,纯虚函数的槽位通常指向__cxa_pure_virtual;而在 MSVC 编译的 Windows PE 里,则是指向_purecall。这两个函数的行为一致:调用它们会触发运行时错误,提示“纯虚函数被调用”。从反汇编角度看,你只要在虚表数据里看到一个函数指针指向这两个符号之一,就能确定这是一个抽象类的虚表。
这是一个非常关键的识别信号。普通类的虚表里每个槽位都是实实在在的函数地址,不会有这种“故意指向崩溃函数”的设计。
1.2 构造函数中的虚表指针安装
C++ 对象的内存布局中,虚表指针(vptr)通常位于对象起始地址。构造函数的第一步就是把这个 vptr 指向当前类的虚表,这个操作在汇编里表现为对对象首地址写入一个偏移量(虚表地址)。
抽象类的构造函数同样会安装 vptr,而且安装的是抽象类自己的虚表——哪怕这个类抽象到根本不可能被实例化,它的构造函数依然存在,因为派生类的构造流程里会先调用基类构造函数,再调用派生类构造函数,每个阶段都会把 vptr 切换成当前阶段对应类的虚表。
反汇编代码里你会看到一个很有意思的模式:构造函数开头lea reg, [vtable_addr],然后mov [obj], reg。只要 vtable_addr 往虚表数据里一追,发现指向__cxa_pure_virtual的槽位,构造函数就暴露了抽象类的身份。
1.3 RTTI 类型信息:编译器留下的指纹
启用 RTTI 的二进制里,虚表偏移 -2(GCC 布局中通常在虚表指针之前)的位置有一个指向 typeinfo 结构的指针。这个 typeinfo 结构里保存了类名等类型信息,而抽象类在 typeinfo 的基类偏移部分会有特殊标记。
MSVC 的 RTTI 结构更直接:_s__RTTICompleteObjectLocator后面跟着的_s__RTTIClassHierarchyDescriptor里有标志位,其中0x04(CLASSHIEARCHY_DESCRIPTOR_ABSTRACT)标记该类为抽象类。这个标志位在反汇编里是铁证级别的特征——看到它,不用猜,这就是抽象类。GCC 的 itanium ABI 虽然没有直接的抽象标志位,但通过虚表槽位内容同样可以推断。
2. 抽象类与普通类的反汇编差异:一张对照表
很多人在网上搜“抽象类和普通类的区别”,源码层面的答案一大堆,但到了二进制层面,区别会变得更加具体和工程化。我把常见的情况整理成表,对照着看会非常直观。
| 特征维度 | 抽象类 | 普通类 |
|---|---|---|
| 虚表槽位内容 | 存在指向__cxa_pure_virtual/_purecall的槽位 | 所有槽位均为实际函数地址 |
| 实例化能力 | 无法直接 new,只能作为基类被继承 | 可直接实例化 |
| 构造函数调用性 | 会被派生类构造函数调用(链式调用) | 同样会被派生类链式调用,但也可单独调用 |
| 析构函数 | 常见情况是虚析构,虚表里占用槽位 | 可以是普通析构,也可以是虚析构 |
| RTTI 特征 | MSVC 中有 ABSTRACT 标志位;无直接标志时靠虚表推断 | 无 ABSTRACT 标志 |
| 代码量 | 纯虚函数无函数体,没有对应机器码 | 每个虚函数都有对应机器码实现 |
| new 操作对应反汇编 | 直接调用构造会出现致命错误,实际场景中不会出现 | 可正常看到operator new+ 构造函数调用序列 |
| 异常处理 | 纯虚函数调用会触发异常或终止 | 正常调用栈回溯 |
2.1 关键差异:虚表槽位的语义变化
普通类的虚表槽位永远是对应函数的真实入口地址。在 IDA 里,你光标移到虚表数据区,按 x 交叉引用,每个槽位都能跳到一个函数。抽象类则不同——至少一个槽位要么指向异常处理函数,要么是一个明显不正常的地址(比如被编译器折叠成abort附近的代码)。
这也带来一个实用的反汇编技巧:在 IDA 或 Ghidra 里定位虚表时,不要只看数据区,直接把所有交叉引用到__cxa_pure_virtual的地址收集起来,这些地址所在的数据区往上追,就能反推出一批抽象类虚表。换个说法:先找纯虚函数引用,再反查虚表,最后反查构造函数。这是一个高效的反向追踪链路。
2.2 关键差异:构造流程的强制链路
普通类可以独立构造,在反汇编里你经常能看到:
call operator_new lea rdi, [rax] ; obj 地址 call Class_constructor ; 直接构造抽象类则永远不会以这种形式出现——因为它根本无法实例化。实际你在反汇编里看到的抽象类构造函数,都是被派生类构造函数调用的:
; 派生类构造函数开头 lea rdi, [rbp-0x10] ; this 指针 call AbstractClass_constructor ; 先调基类(抽象类)构造函数 ... ; 基类构造函数内部安装了基类 vptr lea rdi, [rbp-0x10] call DerivedClass_constructor ; 再调派生类构造函数 ... ; 派生类构造函数把 vptr 切换成派生类虚表这套链式调用在动态调试器里非常清楚:构造函数内层先设置抽象类 vptr,外层再把 vptr 覆盖为派生类 vptr。如果断点打在基类构造函数返回后,你会发现当前对象的 vptr 已经被替换了。这个“先写后覆盖”的模式是抽象类作为基类存在时的经典特征。
2.3 关键差异:析构函数的结构暴露
抽象类通常还会声明虚析构函数,而且纯虚析构在语义上有点特殊——析构函数本身不能是纯虚的,否则派生类析构时无法链式调用,但析构函数体可以是空的。所以你会看到很多抽象类的析构函数实现是一个空函数,只有一个ret,或者只包含清理 vptr 的指令。
更常见的情况是:抽象类有一个非纯虚的虚析构函数(因为析构函数要参与继承链),但析构函数体里不调用任何虚函数。反汇编时,这类析构函数极短,通常在几条指令内就结束。压缩得很短、没有子调用、只是重置 vptr 然后返回——看到这种析构函数,再配合虚表里有纯虚槽位,基本就能锁定抽象类。
3. 实战拆解:从二进制到抽象类识别
下面用一个具体例子完整走一遍识别流程。我们假定手上是一份 C++ 编写的 Linux ELF 二进制,用 IDA Pro 做静态分析。
3.1 第一步:定位虚表与构造函数
先在 IDA 的 Functions 窗口里按名字排序,通常能找到这样的构造函数:
_ZN10BaseClassC2Ev ; BaseClass::BaseClass() _ZN10DerivedClassC2Ev ; DerivedClass::DerivedClass()双击进入BaseClass构造函数,看到的反汇编类似:
push rbp mov rbp, rsp mov [rbp-0x8], rdi ; this lea rax, [vtable_BaseClass+0x10] ; 虚表地址+偏移 mov rcx, [rbp-0x8] mov [rcx], rax ; 安装 vptr pop rbp ret这里[vtable_BaseClass+0x10]就是真正的虚表首地址,加偏移是因为 vtable 头部还有 RTTI 指针等信息。
3.2 第二步:追踪虚表槽位内容
跳到虚表地址(假设是.data.rel.ro段),数据区大概长这样:
.rodata: 08D2A0 DC 26 A0 08 ... ; typeinfo for BaseClass .rodata: 08D2A4 A4 21 84 08 ... ; func1() 实现地址 .rodata: 08D2A8 10 90 84 08 ... ; func2() 实现地址 .rodata: 08D2AC 00 00 00 00 ... ; ??? .rodata: 08D2B0 ?? ?? ?? ?? ... ; 纯虚函数槽位如果某个槽位的值指向的地址,跳过去是一段类似这样的代码:
; __cxa_pure_virtual push rbp mov rbp, rsp ... call __cxa_pure_virtual那这个槽位就是纯虚函数。在 IDA 中直接看交叉引用,你会看到虚表项指向的是__cxa_pure_virtual符号,这就够了。
另外有个重要细节:一个抽象类可能重写了一部分纯虚函数,只保留少数几个纯虚。这种情况下虚表里既有正常函数指针,也有纯虚槽位。判断标准是至少存在一个纯虚槽位。
3.3 第三步:利用 RTTI 确认抽象属性
如果二进制保留 RTTI 信息,直接查看虚表首地址之前的 typeinfo:
在 IDA 中,虚表地址减 8(即虚表头部指针位置)通常存放的是 typeinfo 指针,结构类似:
TYPEINFO: name_ptr -> "10BaseClass"对于 MSVC 编译的 PE 文件,RTTI 结构更立体:
RTTICompleteObjectLocator: signature: 0 offset: 0 cdOffset: 0 pTypeDescriptor: offset type_info pClassDescriptor: offset class_hierarchy_descriptor class_hierarchy_descriptor: attributes: 0x04 ; ABSTRACT! numBaseClasses: 1 pBaseClassArray: ...看到attributes = 4,立刻可以标记为抽象类。很多自动化逆向脚本就是通过解析这一段结构来批量标记抽象类的。
3.4 第四步:结合交叉引用圈定派生类
确认了抽象类之后,把它的构造函数地址做交叉引用收集,凡是调用这个构造函数的其他构造函数,基本就是它的派生类。这个思路在分析大型 C++ 程序时极其高效,因为虚继承、多继承关系都会在构造函数调用链中体现。
我经常用一条 IDAPython 脚本自动完成前三步:
import idautils import ida_funcs import ida_bytes # 找到 __cxa_pure_virtual 地址 pv_addr = idautils.Names()[-1] # 简化示意 # 收集引用它的所有虚表槽地址 refs = set() for ref in idautils.CodeRefsTo(pv_addr, 0): # 回溯到数据引用 for dref in idautils.DataRefsTo(ref): refs.add(dref) # 输出候选虚表位置及周边数据 for addr in sorted(refs): print(f"纯虚槽位: 0x{addr:X}") print("虚表区间: 0x{X:X} - 0x{X:X}".format(addr - 0x10, addr + 0x10))这段代码不是完整工程,但思路够用:找到纯虚函数,反查虚表,再用虚表反查构造函数。实际上 line 2 的写法需要调整(idautils.Names()结构是 (ea, name) 元组),完整脚本读者可以根据自己的 IDA 版本改一改。Ghidra 里也可以用grep指令引用反向查找。
4. 不同编译器与语言下的形态差异
“抽象类反汇编”并不是 C++ 专属。Java/Kotlin、Objective-C、甚至 Rust 里都有类似的概念,只是形态完全不同。逐一说容易散,我挑最常见的两类展开。
4.1 C++ 的完整形态:纯虚槽位 + 构造函数链
上面已详细展开。补充一点:C++ 的抽象类还可能表现为“接口风格”的类——所有方法都是纯虚的,没有成员变量。对应的二进制就是一个虚表 + 构造函数 + 虚析构函数,几乎没有其他代码。在逆向里这种类非常容易被识别,因为它们太“干净”了,没有任何非虚成员函数实现。MFC、COM 接口、插件架构里大量这类类,反汇编时它们是一个个精简的虚表入口。
4.2 Java/DEX 的形态:抽象类是一种类型描述符
Java 字节码层面,抽象类的识别相对直观:Class.access_flags里有ACC_ABSTRACT(0x0400)标记。但放到 DEX 字节码(Android)里,类定义结构体class_def_item的access_flags字段同样保留抽象标记。
在反汇编 smali 代码时,你会看到:
.class public abstract Lcom/example/BaseClass; .super Ljava/lang/Object;abstract修饰符直接写在类声明里,识别难度为零。真正有分析价值的是抽象方法:
.method public abstract foo()V .end method抽象方法在 smali 里没有.locals、没有指令序列,直接空方法体。这种形态在 native 层(比如 ART 编译后的 oat 文件)里会转化成 stub 或抛异常指令,但回溯到 DEX 层面依然有完整标记。
Java 抽象类的反汇编难点不在“识别抽象类”,而在“找到所有继承它的具体类”——通过class_def_item里的superclass_idx字段做反向索引,就能构建继承树。我在做 Android 样本分析时,经常用 jadx 打开一个抽象类,直接看它的 Known Direct Subclasses 面板,比手工翻字节码高效得多。
4.3 Rust trait 的形态:一种异构的抽象
顺带说一句,Rust 的 trait 在概念上接近抽象类,但编译产物完全不同。Rust 通过 trait object 实现动态分派时,会生成一份包含析构函数、大小、对齐和各个 trait 方法的 vtable 结构。它没有“纯虚槽位”的概念,因为 Rust 要求 trait object 背后必须是一个具体类型。所以反汇编里 Rust 的 trait object vtable 是全部有效函数指针,没有一个指向异常处理函数。识别方式变成:找&dyn Trait对应的 fat pointer(数据指针 + vtable 指针),而不是找纯虚槽。
5. 常见误判与排查技巧实录
抽象类反汇编过程中,有几个坑我反复踩过,这里按出现频率排一排,都是实战里很容易翻车的地方。
5.1 误把普通虚函数当成纯虚函数
有些编译优化会把空函数体折叠成ret,甚至把多个空虚函数合并到同一个地址。这时候虚表里出现了一个指向ret的槽位,新手容易误判为纯虚函数。区分方法是看交叉引用和符号:真正的纯虚槽位指向的是__cxa_pure_virtual或_purecall,是唯一的全局符号;而空函数是一个真实存在、有函数帧的代码地址,即便被优化到只剩ret,它也有自己的函数边界。在 IDA 里按 x 看谁引用了它,如果只有虚表引用,且函数体内只有一行ret,那它确实可能是空虚函数,而不是纯虚。判断关键还是目标地址是否等于纯虚全局符号。
5.2 构造函数被内联导致虚表链条断裂
Release 编译加上 LTO(链接时优化)之后,抽象类的构造函数往往被内联到派生类构造函数里,你在反汇编里可能找不到独立的抽象类构造函数符号。这时候不能按“构造函数交叉引用”来找抽象类了。改成直接解析虚表数据结构:
- 在
.data.rel.ro段里搜索指向__cxa_pure_virtual的 8 字节指针 - 命中后往前 8 字节读 RTTI 指针,确认类名
- 收集所有引用了该虚表的代码地址,这些就是构造、析构的现场
LTO 对内联的唯一残留就是虚表依然存在(只要类有虚函数且没被完全优化掉,虚表就必须保留)。所以这个思路在 LTO 下依然成立,只是从“找函数”变成“找数据”。
5.3 MSVC + RTTI 关闭时的降级识别
MSVC 编译时如果关了 RTTI(/GR-),typeinfo 结构不会生成,_RTTICompleteObjectLocator也就不存在了。但抽象类依然会生成含_purecall槽位的虚表,纯虚函数引用还是暴露身份。另一个降级特征是 MSVC 的虚表布局中,第 0 个槽位是“析构函数或未知特殊函数”,最后一个槽位可能在多继承时用于调整 this 指针。这些都不影响纯虚槽位识别。
5.4 误把接口调度代码当成抽象类
COM 和 Qt 的信号槽机制会用大量接口指针做动态调度。真实二进制里,调用一个virtual void func() = 0的纯虚函数时,反汇编是:
mov rax, [obj] mov rax, [rax] ; vptr call [rax + offset] ; 虚函数调用这本身不说明目标类是抽象的——它只是通过 vptr 做间接调用,具体指向的可能是派生类的实现。你追踪进去看到的是一段真实代码。所以,判断抽象类必须回到虚表定义处,而不是调用点。调用点的间接调用到底是调用纯虚(会崩溃)还是调用实现(正常运行),只有动态调试才知道。
5.5 多继承下的虚表混淆
多继承中,派生类会有多张虚表,每张对应一个基类。如果其中一个基类是抽象类,那么派生类在构造过程中会先安装多张 vptr,再逐一切换。反汇编里你会看到构造函数多次执行mov [obj+offset], reg。这个时候的虚表主次关系,要靠每个 vptr 指向的虚表内容反推。技巧:从每个 vptr 指针所在的虚表里,找出指向__cxa_pure_virtual的最远偏移,那个偏移对应着继承链中最左侧基类的虚表。
5.6 ARM/Thumb 架构下的识别差异
ARM 架构下,虚表槽位的内容可能是函数地址 +1(Thumb 模式),纯虚函数符号的地址同样带 Thumb 标记。遇到0x...01结尾的地址,比较时要把最低位屏蔽后再跟__cxa_pure_virtual对比。在 IDA 里按住Alt+G查看地址的 Thumb 属性,脚本里用addr & ~1做归一化。这个小细节在逆向 Android ARM 库时经常出现,忘了处理就会漏报。
6. 自动化标记抽象类的完整方案
手动分析单个类还行,样本一多、类层次一深,就需要脚本化。分享一套我在 Ghidra 上用的思路,完全可迁移到 IDA。
6.1 核心逻辑
在所有已加载模块里定位纯虚全局函数的地址:
- Linux ELF:
__cxa_pure_virtual - Windows PE:
_purecall - macOS:
___cxa_pure_virtual
- Linux ELF:
遍历所有数据引用指向该地址的内存位置,这些位置是虚表槽位。
对每个槽位,向前 8 字节(64 位下)读取虚表头部,如果存在 RTTI 指针,解析类名。
收集所有代码引用指向虚表头部(或指向虚表+偏移量)的位置,这些是构造函数/析构函数的 start 位置。
标记出构造函数后,在函数里定位对虚表的写入指令,确认 vptr 安装位置。
输出结果:抽象类虚表地址、类名(如果 RTTI 可用)、构造函数地址、纯虚槽位偏移。
6.2 Ghidra 脚本示例(伪代码)
// Ghidra 中基于 Java 的脚本片段,示意核心逻辑 // key:定位纯虚函数地址 Address pureAddr = null; for (Symbol sym : getSymbolTable().getAllSymbols(true)) { if (sym.getName().contains("pure_virtual") || sym.getName().contains("_purecall")) { pureAddr = sym.getAddress(); break; } } // 找到所有指向该地址的数据引用 ReferenceIterator refs = getReferenceManager().getReferencesTo(pureAddr); while (refs.hasNext()) { Reference ref = refs.next(); if (ref.getReferenceType().isData()) { Address slotAddr = ref.getFromAddress(); System.out.println("纯虚槽位: " + slotAddr); // 向前读 vtable 头部,解析 RTTI / typeinfo Address vtblStart = slotAddr.subtract(8); // 进一步解析... } }这套脚本跑下来,一个大型二进制里的抽象类清单基本出来了。配合 RTTI 类名,可以快速在反编译输出里定位所有派生类构造函数。
7. 实战心得
抽象类反汇编,说到底是在二进制里找回源码时代的抽象边界。纯虚函数的槽位像一个拉响警报的哨兵,一旦锁定,虚表、构造函数、继承关系就全部串起来了。我个人在分析大型 C++ 服务端程序时,用这个思路批量标记接口类和实现类,效率提升非常明显;在逆向插件架构的客户端程序时,靠虚表纯虚槽位反查派生类,往往比追字符串更快定位到核心业务逻辑。
有一点想特别提醒:不要把抽象类识别做成一个机械的“找__cxa_pure_virtual”的动作。虚表分析的意义不在找到那张表,而在通过表的结构理解这个类在整个继承体系里的位置。抽象类往往是最上层的设计骨架,顺着它往下追,派生类的实现逻辑就像树的枝叶一样自然展开。分析的时候多花点时间把构造函数链捋清楚,比单纯标记十个八个抽象类有价值得多。
再分享一个小技巧:如果二进制是带符号或带 RTTI 的,先全局搜索“C2”和“C1”结尾的构造函数符号(_ZN*C2Ev/_ZN*C1Ev),C2 是完整对象构造,C1 是基类构造,两个版本并存时,优先看 C1,它才是继承链里的关键环节。这个细节能让你的继承树还原工作省一半力气。