ARTICLE DETAIL

资讯详情

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

C++抽象类反汇编:从虚表与纯虚桩还原继承体系

C++抽象类反汇编:从虚表与纯虚桩还原继承体系 如果你逆向过C程序一定见过这种场面构造函数里连续几条mov指令把一个固定地址塞进对象偏移0的位置随便一查这个地址落在.rdata段后面跟着一串函数指针。这就是虚表也是所有面向对象反汇编的起点。而“抽象类反汇编”这个题目看着玄乎实际要回答的问题就一个在没有任何源码、很多时候连符号都被剥离的情况下怎么把那个曾经是抽象类的结构从二进制里认出来并且还原出它的继承关系。先说结论CPU根本不认识抽象类也不认识普通类。类和对象只是编译器在机器码背后维持的一套“结构性事实”。反汇编抽象类的过程就是顺着虚表、构造/析构函数、RTTI这些残留的结构性踪迹把消失的类型信息重新拼出来。这篇文章我会把抽象类在汇编层面的典型特征讲清楚再给出一套可以照着做的实操流程和几个常见的排查方向。不管是做安全研究、分析闭源插件还是单纯想搞懂C对象模型的人都能用得上。1. 抽象类反汇编到底在找什么1.1 抽象类在C层的定义对应到二进制是什么抽象类是指至少含有一个纯虚函数的类。纯虚函数的声明形式是virtual void func() 0它告诉编译器“这个类不能被实例化子类必须自己实现这个函数”。这句话听起来很吓人但落到二进制里其实很简单纯虚函数本质上就是一个“没有实现”的虚函数槽位。编译器为每个类维护一张虚表虚表里存放该类的虚函数地址。普通类的虚表槽位指向真实的函数实现比如一段执行具体逻辑的代码抽象类的纯虚函数槽位则会指向一个特殊的“桩函数”。这个桩函数不是业务逻辑它存在的唯一意义是如果有人胆敢通过基类指针直接调用这个纯虚函数程序就得当场崩溃或终止。于是“抽象类反汇编”的任务就清晰了找到虚表检查虚表槽位凡是出现桩函数地址的槽位就是纯虚函数所属类就是抽象类。再顺着引用关系摸清构造函数、析构函数、继承体系整个抽象类架构就浮出水面了。1.2 抽象类和普通类在汇编里的分水岭最新的热点话题都在问抽象类和普通类的区别从C语法层面讲区别就是有没有纯虚函数、能不能实例化。但从反汇编视角看区别要更具体、更可操作。特征普通类可实例化抽象类含纯虚函数虚表槽位所有虚函数槽位都指向真实函数实现至少一个槽位指向纯虚函数桩构造函数会被new表达式直接引用是对象创建的入口通常只被派生类构造函数隐式调用甚至没有显式构造函数栈上对象可以出现在栈上并调用成员函数永远不可能以栈对象方式存在只能作为指针/引用存在RTTI存在类型描述符必要时可读出类名同样存在类型描述符通常类名依然保留析构函数正常析构设置自己类的虚表指针析构函数内同样会设置自己类的虚表指针这是重要入口表格里最后一行是很多人忽略的抽象类没有构造函数被外部调用但它的析构函数依然存在而且析构函数里一定有一条写入虚表指针的指令。这条指令会成为我们定位抽象类虚表的最可靠线索。我在多个逆向项目里都靠这条线索反推基类后面实操部分会专门演示。1.3 搞清楚这种分析能用在哪些场景抽象类反汇编不是纯粹的学术练习它有很明确的实际用途。第一个场景是还原插件架构。商业软件、游戏引擎、浏览器内核都喜欢把扩展点设计成抽象接口比如形如IPlugin、IModule、IService的类。你拿到一个剥离符号的二进制先发现__cxa_pure_virtual的多个引用然后顺藤摸瓜找到一串虚表这就等于拿到了整个插件系统的骨架图。第二个场景是判断多态调用点。反汇编里常见的call qword ptr [rax16]就是多态调用但rax16到底调用的是哪个函数、这个函数的实现是哪个类的只有还原虚表才能确定。如果调用点最终调到了纯虚桩那说明这里运行时一定出问题要么是对象类型错误要么是接口被错误使用。第三个场景是辅助漏洞分析与代码审计。分析一个崩溃样本时如果发现栈回溯停在纯虚函数桩里说明某个地方调用了一个未完成的接口方法这往往是生命周期管理错误或者反序列化时类型不匹配的征兆。识别出抽象类后这类问题的成因会清楚得多。2. 抽象类在二进制中的三大特征信号2.1 虚表识别抽象类的第一入口对于有虚函数的C类编译器会为它生成一张虚表。虚表里是一串指针每个指针指向一个虚函数的实现。虚表本身只是一块数据但它有两个特点让它可以被检索第一它一定位于只读数据段且在32位程序里每条目是4字节、64位程序里每条目是8字节第二虚表条目会引用代码段里的函数这种“数据指向代码”的关系在IDA、Ghidra里会形成非常明显的交叉引用。识别虚表有一个坑编译器可能对虚表布局做微调。MSVC的虚表里除了用户声明的虚函数还可能塞入额外的清理函数、调整thunk等GCC/Clang这类使用Itanium ABI的编译器虚表起始位置前有两个前导字段分别是offset-to-top和typeinfo指针真正函数指针从第3个条目开始。所以在反汇编里看到构造函数写入的虚表地址往往不是单纯的数据段基址而会带一个偏移比如lea rax, vtable_IPlugin16。这是正常现象不用觉得奇怪。另一个实用经验是虚表条目数量等于虚函数数量加若干辅助条目。如果你看到一个虚表里有4个函数指针而这个类的接口预期是3个方法加1个析构那就对上了。条目数量和布局可以帮助你校验识别结果是否可信。2.2 纯虚函数桩的三种典型形态不同编译器生成的纯虚函数桩长得不太一样但它们的共同特点是函数体很短没有业务语义最终进入错误处理或终止流程。实际逆向中我见过三种主要形态。第一种是最典型的库函数调用形态。GCC/Clang环境下纯虚函数槽位直接指向__cxa_pure_virtualMSVC环境下指向_purecall。这两个都是运行时库提供的函数内部会触发abort()或类似的致命错误。在反汇编器里只要搜索这两个符号所有引用点基本都是抽象类的虚表槽位非常好用。第二种是内联生成的短桩。某些编译器会把纯虚函数实现成一小段本地代码比如一个立即返回指令序列或者直接跳转到错误处理函数。这种情况在开启优化后容易出现桩函数没名字看起来像个普通函数。判断方法是看它是否只由几条指令组成且执行路径最终都导向abort或抛出异常。第三种是异常抛出形态。个别运行时环境会把纯虚函数调用转换为抛出异常或触发未定义行为处理。这类桩函数的反编译看起来像throw std::bad_function_call或者一个诊断函数但本质上仍然是与业务无关的终止路径。建议大家在建立任何“纯虚桩名单”时把__cxa_pure_virtual、_purecall以及它们的弱符号别名都加进去并且用脚本去扫描虚表槽位。整个过程我在第5节会展开讲。2.3 构造和析构函数里的虚表写入指令C对象模型里有一个关键规则对象在构造和析构的不同阶段虚表指针会被反复设置。基类构造函数执行时对象的虚表指针指向基类虚表派生类构造函数紧接着把虚表指针改成派生类虚表。析构过程相反派生类析构函数先运行虚表指针保持派生类虚表进入基类析构函数后虚表指针又改回基类虚表。这条规则在汇编层面产生了一个可以稳定观测的指令模式。以64位GCC环境为例一个普通类的构造函数开头往往会有类似下面的代码mov qword ptr [rdi], offset vtable_PluginA 16 mov rbx, rdi call PluginA::init() ...这里的[rdi]代表对象的第一个成员也就是虚表指针的位置。offset vtable_PluginA 16就是该类虚表的实际地址加16是因为Itanium ABI虚表的前两个qword是前导字段。看到一个函数里出现“往对象偏移位置写一个数据段地址而这个地址后续被引用为一串函数指针”基本就能断定这个函数是构造函数或析构函数。进一步看写入地址对应虚表的内容就能判断该类是不是抽象类如果虚表里有槽位指向纯虚函数桩那么你找到的就是抽象类自身的析构函数。抽象类没有外部可见的“new入口”但它一定会有基类析构函数因为派生类析构到最后必须调用基类析构。这条虚表指针写入指令是抽象类在二进制里留给我们的最稳定地址。3. 反汇编实操从二进制里揪出抽象类3.1 工具选型IDA、Ghidra与radare2怎么选抽象类反汇编这件事工具选对了能省一半时间。我平时用Ghidra做主力IDA Pro看复杂继承图radare2做脚本批量扫描。Ghidra免费而且对C虚表的处理做得相当不错。它能自动识别虚表、标注vtable类型还能通过交叉引用把构造函数、析构函数和虚表之间的关联关系可视化。尤其适合做前面说的“找桩函数、找虚表、找引用”三连操作。IDA Pro的Type Libraries和FLIRT签名能帮你更快恢复标准库函数比如__cxa_pure_virtual直接就能识别出来。如果手头有IDAPython写脚本扫虚表也很方便。缺点是贵而且对新手不太友好。radare2更适合自动化场景。一条命令列出所有引用纯虚桩的位置一条命令读完虚表所有条目整个流程可以脚本化。缺点是交互式分析不如前两者直观。我的建议是分析单个目标用Ghidra或IDA批量扫描多个二进制用radare2配合脚本两个方向并不冲突。3.2 用Ghidra定位抽象类虚表的完整流程下面是一套我在Ghidra里反复使用的流程可以直接照着操作。假设目标是一个64位的Linux ELF动态库符号已经被剥离但静态分析仍能看到函数边界。第一步打开自动分析后的程序在符号表或导入表里搜索纯虚函数桩。Linux下搜__cxa_pure_virtualWindows下搜_purecall。找到后双击进入该函数。第二步右键点击函数名选择 “References - Find references to __cxa_pure_virtual”。这时会列出一堆引用点。排除掉异常处理表里的引用剩下的基本就是虚表槽位。虚表槽位的特点是引用来自数据段不是来自代码段。第三步点击某一个引用条目跳到数据段。你会看到连续排列的指针每个指针都指向某个函数。这就是一个虚表的候选。用Ghidra的 “Create Array” 快速把这一段定义成指针数组。第四步检查虚表内每个槽位目标函数。如果一个虚表里有多个槽位都指向__cxa_pure_virtual那么这一张虚表极可能属于某个抽象类。槽位数量也告诉你有多少个纯虚函数。第五步对这个虚表做反向引用查找。找到引用这个虚表的代码位置通常是构造函数或析构函数里的mov指令。仔细看这段代码就能确认该类的构造/析构结构。第六步把虚表里的非桩目标函数一个个点开反编译它们通常能看到具体的业务逻辑。如果业务逻辑与接口语义一致比如插件示例里的load、unload那就可以给函数手动重命名整个类的形状就出来了。Ghidra这套流程最舒服的地方在于数据段和代码段的交叉引用是自动维护的你不需要手动记地址点击即可跳转来回几次就能把继承关系拼起来。3.3 通过RTTI把抽象类的名字“挖”出来如果目标二进制没有关闭RTTI那类名是白送的。GCC/Clang环境下每个具有虚函数的类虚表之前会有一个typeinfo指针指向类型描述符类型描述符内部就是类型名称字符串。MSVC环境的RTTI结构更复杂一些要先从虚表附近找到RTTICompleteObjectLocator再通过它找到TypeDescriptor里面同样有类名字符串。在Ghidra里看虚表起点时你会注意到虚表基址往前两步64位下往前16字节有一个指针指向一段字符串常量。以我经验直接把这个字符串地址转成字符就能看到类似9IPlugin、.N12PluginA这样的名字。前面的数字表示类名的长度这是Itanium ABI的编码方式。N12PluginA的N表示嵌套名称12表示类名长度。这种通过RTTI得到的类名对抽象类特别有价值。因为抽象类本身不会出现在new表达式里普通反汇编思路很难给它一个名字但RTTI直接告诉你“这里确实存在一个叫IPlugin的类型”。有了名字之后整个虚表的归属判断就容易多了。需要提醒的是很多发布版二进制为了减小体积会加/GR-MSVC或-fno-rttiGCC/Clang关闭RTTI。这种情况下typeinfo不可用类名只能通过虚表槽位数量、函数行为、引用关系来间接推断。我在第5节会专门讲这种情况下的处理策略。3.4 用虚表引用关系还原继承体系识别出单个抽象类的虚表只完成了第一步真正的工作是还原继承结构。继承关系在二进制里的表现是派生类对象里有多个虚表指针每个虚表指针指向不同基类对应的虚表。一个派生类的构造函数会依次设置多个虚表指针。以一个单继承类PluginA继承自IPlugin为例它的构造函数里会有两次关键写入。第一次是调用基类构造后临时写入IPlugin虚表第二次是构造体末尾写入PluginA虚表。反汇编观察到的模式通常是; 进入 PluginA::PluginA() mov qword ptr [rdi], offset vtable_IPlugin 16 call IPlugin::IPlugin() mov qword ptr [rdi], offset vtable_PluginA 16 ...如果你在某段代码里看到针对同一个对象连续两次写入虚表地址且两个地址分别属于不同虚表那么这两个虚表所代表的类之间就有继承关系后者是前者的派生类。多继承时对象里会有多个虚表指针分别位于对象的不同偏移构造函数里会依次设置。虚表指针的写入顺序和偏移能直接反映基类的声明顺序。这一步是整个抽象类反汇编里信息量最大的环节也是我建议用图形化工具的原因。IDA和Ghidra都能画出“构造函数引用哪个虚表、虚表引用哪些函数”的结构图顺着图核对自己的判断出错的概率会低很多。4. 实战案例还原一个插件系统的抽象接口4.1 一个典型的插件架构设计为了让上面的方法不悬空我带大家看一个具体案例。假设有这么一套插件系统核心接口定义如下class IPlugin { public: virtual int load() 0; virtual int unload() 0; virtual void info(char* buffer) 0; virtual ~IPlugin() {} }; class PluginA : public IPlugin { public: int load() override { return 1; } int unload() override { return 0; } void info(char* buffer) override { strcpy(buffer, PluginA); } };目标二进制是一个动态库编译时加了-fvisibilityhidden和-s符号全部剥离。现在我们要做的是只凭二进制把IPlugin识别为一个抽象类并找出PluginA对它的实现。这个案例的难度适中因为继承还算简单虚函数数量也少。但它囊括了所有关键动作找桩、找虚表、找构造函数、验证多态调用。4.2 从桩函数到抽象接口的还原过程我在Ghidra里打开这个动态库后第一件事是搜索__cxa_pure_virtual。由于符号被剥离但动态库运行时依赖的libstdc里还是有这个符号所以导入表里能看见它。搜索后得到引用列表我注意到.data.rel.ro段有两个地方引用了它。点进第一个引用处看到连续4个指针槽位DAT_xxxx0000 - __cxa_pure_virtual ; 可能是 load() DAT_xxxx0008 - __cxa_pure_virtual ; 可能是 unload() DAT_xxxx0010 - __cxa_pure_virtual ; 可能是 info() DAT_xxxx0018 - FUN_xxxx0400 ; 可能是 ~IPlugin()这几乎就是教科书式的抽象类虚表三个纯虚函数槽位全部指向同一个桩第四个槽位指向一个实际函数。第四个函数的反编译结果很简单只是一段把虚表指针重新写回对象、然后返回的代码符合一个空的虚析构函数特征。为了确认这是IPlugin而非某个被错误合并的虚表我对这张虚表做反向引用查找发现它只被两处代码引用一处是某个构造函数的起始阶段一处是某个析构函数的结尾阶段。构造函数的头几条指令把vtable_IPlugin 16写入[rdi]随后又写入另一个地址vtable_PluginA 16。后者指向的虚表四个槽位分别对应三个可执行函数和一个析构函数。三个可执行函数里能清楚看到strcpy调用和返回固定值的逻辑与PluginA的实现完全对应。到这里我不仅确定了IPlugin是抽象类还确定了PluginA是它的派生类。整个过程没有依赖任何符号名纯粹靠虚表槽位、桩函数和引用关系。4.3 通过多态调用点验证还原结果还原结果对不对最终要用调用点来验证。插件注册函数通常接受一个IPlugin*抽象指针并调用它的虚函数。反汇编里会看到类似这样的代码mov rax, qword ptr [rdi] ; 取出虚表指针 call qword ptr [rax 24] ; 调用第三个虚槽位即 info难点在于IPlugin的虚表指针前有两个前导qword所以真正函数从偏移16开始。info是第三个虚函数对应偏移24。如果用构造函数写入地址时加16、调用时用24算出来的槽位完全一致说明整条链路是自洽的。我在实际分析中还发现这个多态调用点出现在导出表函数里意味着插件管理器就是通过这层抽象接口来驱动的。结合虚表识别结果整条调用链变成插件管理器 -IPlugin虚表 -PluginA实现抽象接口和派生实现各归其位。5. 常见误判与避坑实录5.1 派生类重写后别傻等纯虚桩最常见的一个坑是在派生类的虚表里找不到纯虚函数桩就以为它不是抽象类。这是对机制理解不透彻。纯虚函数桩只出现在抽象基类自身的虚表槽位里一旦派生类重写了这个函数它的虚表槽位就指向真实实现桩函数消失。举个例子前面案例里PluginA::load()是真实函数所以PluginA虚表里没有桩找桩只能回到IPlugin虚表。如果整个程序里恰好没有抽象基类的构造函数被呼叫也没有基类析构函数被触发那你甚至可能看不到那张基类虚表。这时候要找的是派生类构造函数里的第一次虚表写入以及它指向的地址前后是否有桩槽位。先找到派生类再通过继承顺序推回基类才是正确思路。5.2 编译器优化构造函数被内联或虚表复制被消除开优化后构造函数可能被完全内联进new调用点不再存在一个独立的函数体。你会在operator new之后直接看到一系列初始化代码包括写入虚表指针。这种情况下不能用“找一个构造函数函数”的思路要改用“在new调用点后找虚表地址写入”的思路。更隐蔽的是返回优化和对象切片场景。某些情况下编译器能证明某个对象只有一种实际类型于是把虚表指针写入和内联构造函数一起消除对象甚至可能根本没有虚表指针。这种优化主要影响普通类的识别对抽象类影响不大因为抽象类无法直接实例化派生类里仍然需要虚表指针。但你必须意识到有些看似“没有构造逻辑”的对象可能是被优化成了纯静态分发。5.3 没有RTTI、没有符号时怎么办关闭RTTI后类名消失typeinfo结构不存在。这时虚表前导区的第二个qword可能是0或者执行一个未知函数。不要慌依靠结构线索依然能识别抽象类。没有RTTI时识别重点放到三个地方第一虚表中槽位数量的语义第二非虚成员函数是否出现第三构造函数里的虚表指针写入顺序。我遇到过一个没有RTTI的闭源组件通过分析发现一个虚表有5个槽位其中3个指向同一个终止函数2个指向普通函数。结合调用点中发现其中一个普通函数被反复通过虚表索引调用最终确认那个终止函数就是纯虚桩那张虚表属于含两个实际虚函数的抽象接口。这时的分析会慢一些但信息并没有彻底消失。我建议把所有候选虚表导出到一个脚本里批量比较槽位重复率。指向同一个桩函数次数最多的虚表通常就是层数最高的抽象基类。5.4 多继承与虚继承叠加的复杂局面抽象类一旦出现在多继承体系里情况会明显复杂。多继承下派生类对象包含多个虚表指针每个指针对应一条基类路径。如果你只盯住第一个虚表指针可能漏掉另外几个抽象基类。虚继承更麻烦它会引入虚基类表vbtable对象布局里多了一层间接。抽象基类如果是虚基类派生类构造函数里除了写虚表指针还会维护虚基类偏移表。这时虚表槽位里可能会出现两个特殊条目一个是虚基类delta调整一个是虚继承thunk。我的经验是遇到这类复杂布局优先在Ghidra里手动把对象结构体建出来把每个偏移位置对应的虚表指针、vbtable指针都标好再逐个核对。没有这个结构体模型讨论继承关系纯属瞎猜。5.5 批量扫描抽象类虚表的思考框架最后一个实用建议面对大规模二进制时别一个个虚表手工看写个简单的扫描逻辑能节省大量时间。以下是我自己项目中用过的一种思路stubs {__cxa_pure_virtual, _purecall} def scan_vtables(vtable_list): abstract_candidates [] for vt in vtable_list: slot_targets vt.slot_targets pure_slots [idx for idx, fn in enumerate(slot_targets) if fn.name in stubs] if pure_slots: abstract_candidates.append((vt.address, len(slot_targets), pure_slots)) return abstract_candidates把这个逻辑接上Ghidra的API或者radare2的r2pipe就能自动枚举所有虚表、检查槽位、输出候选抽象类列表。之后再对候选列表做人工核实速度会快非常多。我自己在实际操作中的体会是抽象类反汇编真正的难点从来不是某个单一特征找不到而是特征之间的关联——虚表找到了要找构造函数构造函数找到了要反推继承继承关系确认了还要回到多态调用点验证。只要把这条链走通哪怕符号全丢、RTTI关闭抽象类也藏不住。最后送大家一个小技巧拿到一份陌生二进制先搜纯虚函数桩的引用优先处理引用次数最多的那张虚表那往往就是整个架构的根接口。
返回列表