ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

抽象类反汇编识别:虚表纯虚槽位与构造函数链实战

抽象类反汇编识别:虚表纯虚槽位与构造函数链实战 做逆向分析时碰上一个带纯虚函数的类很多新手会直接懵析构函数里明明调了那个函数可跳过去一看函数体居然是__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 的二进制里虚表偏移 -2GCC 布局中通常在虚表指针之前的位置有一个指向 typeinfo 结构的指针。这个 typeinfo 结构里保存了类名等类型信息而抽象类在 typeinfo 的基类偏移部分会有特殊标记。MSVC 的 RTTI 结构更直接_s__RTTICompleteObjectLocator后面跟着的_s__RTTIClassHierarchyDescriptor里有标志位其中0x04CLASSHIEARCHY_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_BaseClass0x10] ; 虚表地址偏移 mov rcx, [rbp-0x8] mov [rcx], rax ; 安装 vptr pop rbp ret这里[vtable_BaseClass0x10]就是真正的虚表首地址加偏移是因为 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_ABSTRACT0x0400标记。但放到 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 [objoffset], reg。这个时候的虚表主次关系要靠每个 vptr 指向的虚表内容反推。技巧从每个 vptr 指针所在的虚表里找出指向__cxa_pure_virtual的最远偏移那个偏移对应着继承链中最左侧基类的虚表。5.6 ARM/Thumb 架构下的识别差异ARM 架构下虚表槽位的内容可能是函数地址 1Thumb 模式纯虚函数符号的地址同样带 Thumb 标记。遇到0x...01结尾的地址比较时要把最低位屏蔽后再跟__cxa_pure_virtual对比。在 IDA 里按住AltG查看地址的 Thumb 属性脚本里用addr ~1做归一化。这个小细节在逆向 Android ARM 库时经常出现忘了处理就会漏报。6. 自动化标记抽象类的完整方案手动分析单个类还行样本一多、类层次一深就需要脚本化。分享一套我在 Ghidra 上用的思路完全可迁移到 IDA。6.1 核心逻辑在所有已加载模块里定位纯虚全局函数的地址Linux ELF__cxa_pure_virtualWindows PE_purecallmacOS___cxa_pure_virtual遍历所有数据引用指向该地址的内存位置这些位置是虚表槽位。对每个槽位向前 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*C1EvC2 是完整对象构造C1 是基类构造两个版本并存时优先看 C1它才是继承链里的关键环节。这个细节能让你的继承树还原工作省一半力气。
返回列表