查看原文
其他

逆向 | 虚函数与纯虚函数

计算机与网络安全 计算机与网络安全 2022-06-01

一次性进群,长期免费索取教程,没有付费教程。

教程列表见微信公众号底部菜单

进微信群回复公众号:微信群;QQ群:460500587


微信公众号:计算机与网络安全

ID:Computer-network

虚函数的存在就是为了克服类型域解决方案的缺陷,以使程序员可以在基类里声明一些能够在各个派生类里重新定义的函数。下面我们就一起研究一下有关虚函数的逆向技巧。


一、识别简单的虚函数


我们先来看一个简单的例子,如代码清单1所示。

代码清单1  简单的虚函数示例

这是一个比较简单的例子,很好地演示了虚函数的应用。在学习虚函数的逆向之前,我们有必要简单了解一下虚函数的实现机制。首先,针对所有虚函数的引用大多数时候都是放在一个名为Vtbl(或vftable)的数组里,并且每个使用了虚函数的对象内都有一个指针Vptr(或vfptr)指向虚函数数组。这个指针在非派生的对象中只有一个,在多重继承对象里可能有多个。为了便于您理解后面的反汇编代码,这里给出代码清单1这个类的内存结构图,如图1所示。

图1  类的数据结构图

这些内存结构可以用VC编译器提供的一个功能进行绘制,在编译命令后加上-d1reportAllClassLayout就会生成此程序的内存结构图,这个结构图对于学习逆向是很有用的(生成的信息视编译器的版本不同其存放的地方就不同)。


通过图1我们知道,这个例子中的每个类都有一个自己的虚函数表以及虚函数指针。通过观察我们还可以发现,Vptr指针位于类内存结构中的0偏移处。代码清单2所示是程序的反汇编代码。

代码清单2  简单的虚函数示例的Debug版反汇编代码

由上面的汇编代码可以看出,编译器针对我们的代码做了非常多的事情,比如它会对代码执行security_cookie检查,以查看是否有栈溢出的情况发生,并且还增加了一个用于异常处理的局部变量。相信有的朋友看完上面的注释后感觉很难,但很有成就感。不过我们忽视了一个最重要的信息,即如果我们没有Debug版的符号文件,甚至这段代码根本就不是我们写的,我们要怎么办呢?肯定首先要判定它是否是一个类的应用了。


现在我们别无选择,只能老老实实地跟进函数内部一探究竟,先从第一个call开始。

我们通过阅读上面的代码可以得出以下结论:


代码清单9-55中的例子操作了虚表(也就是0x004115D6处看似毫无意义的??_7CObj@@6B@,我们只需要跟进去看看就知道这是一个保存函数地址的指针,再通过汇编上下文的猜测,即可大致确定这就是一个虚表),且将值传到了寄存器参数ecx记录地址的第一项。


以寄存器参数ecx为首地址,分别给其4偏移与8偏移处赋了值。


寄存器参数ecx又作为返回值传了回去。


通过调用函数的分析,我们怀疑ecx里保存的就是this指针。并且根据类的内存结构可知,this里的第一项是一个Vptr,而代码清单2中的操作很好地验证了这一点。我们到这里就可以基本判定这是一个类成员函数的调用了。


但是由于此成员函数是this(即类对象)下第一个被调用的,那么我们还要判定它是否为构造函数。通过代码清单2中的例子做的第二件事,即初始化类数据成员,我们有理由怀疑这就是构造函数,但是证据并不充分。此成员函数做的最后一件事情,是将this指针当做返回值传回,这为我们的猜测提供了更准确的依据。


到此,我们基本上已经可以判定这就是一个构造函数。此时你可以在IDA中将这个类重命名为obj或ClassA或其他任何你喜欢的名字了。

很好,既然我们已经顺利地识别出了第一个call,那么我们利用同样的方法也可以确定第二个call是另外一个类的构造函数(当然子类的构造里与父类比稍有不同,即子类的构造中包含父类的构造)。而第三个call与第四个call通过主函数就可以确定它们通过虚表调用的分别是第一个类(CObj)与第二个类(CPeople)的一个成员函数(Show)。


现在我们还剩两个call没有分析了,纵然我们有理由猜测它们很有可能就是这两个类的析构函数,但是我们一定要坚持基于分析的猜测,而非胡乱臆测。现在让我们一起看看第五个call。

我们通过阅读上面的代码可以得出以下结论:


此函数操作了虚表,且将值传到了寄存器参数ecx记录地址的第一项。

此函数内又调用0x00411640,进入该函数即可很容易确定这是其父类的析构函数。


通过以上简单的逆向我们可以看出一些特点,只有构造或析构才会有添虚表的操作,而构造又有初始化与返回this等特征。因此对于有虚函数的类,分析其成员函数与构造、析构的结果还是比较准确的。


综上所述,我们得出以下经验:


只有构造函数与析构函数会对Vptr操作。

在Visual Studio默认设置下,构造与析构前都会有相应的异常处理标记置位操作。

虚函数的调用一般采用eax。


二、识别较复杂的虚函数


下面通过一个较复杂的例子来深入讲解虚函数的各种情况,包括new出来的对象以及纯虚函数、抽象类以及Release版的优化等。我们先看代码清单3。

代码清单3  较复杂的虚函数

这段代码相对于前面的显得稍有些复杂,主要是CPeople变成了一个抽象类,并且其下又多了两个子类,且成员函数的调用也采取了两种完全不同的方式。图2是对这个类内存结构的描述。

图2  类的内存结构图

下面我们开始分析汇编代码。先看代码清单4。

代码清单4  较复杂的虚函数的Debug版反汇编代码

如果上面的代码你能跟着看下来,肯定会感觉没有想象的难,但是如果让你马上就写出对应的C++代码,还是有些难度的。当然,目前从逆向知识的掌握上我们还没到直接能逆出源代码的程度。单从这个层次上来讲,如果让我们识别出纯虚函数与new出来的对象还是不过分的。因此,下面先从main函数里的调用开始看起。


(1)程序首先调用了3个类的构造,因此生成了3个类的对象(this指针),再分别对其取vptr后各自调用了自己的Show函数。这点想必各位应该都看出来了,不会遇到什么障碍。


(2)程序new了一块空间出来,将new出来的首地址当做this直接传到了CBoy的构造函数里,由此创建了一个对象,而后程序又用同样的方法为CGirl创建了一个对象。当程序创建完这两个对象后,便开始对这两个对象的成员函数做了调用。


(3)程序将释放new出来的空间,并析构相应类的对象。


通过上面的描述,相信各位对于程序的整体流程已经有所了解。下面我们就着重分析其重点。


第一个问题是,new出来的对象与直接构造出来的对象有何差异?我们都知道在定义一个类对象的时候,它的this指针在main()函数建立之初就已经确定好了,因此它的相关内容(有可能包括vptr或数据成员)都是在栈里,而new出来的对象则是在堆里。


第二个问题是,new出来的对象调用虚成员函数,与构造出来的对象调用虚成员函数有何不同?通过上面的讲解相信大家首先想到的是寻址方式有所不同,因为它们一个在堆里、一个在栈里。没错,就是这些,仅此而已!


第三个问题是,与delete相关了,我们通过汇编指令可知那似乎并不是典型的delete,因为在调用这个函数前压入了一个参数1。我们再回顾一下那段代码。

为了确定其并非为delete,我们跟进去一探究竟:

这里竟然调用了CPeople的析构后就直接delete了。难道new出来的CBoy与CGirl不是应该先被析构吗?请各位不要惊奇。遇到这种情况后我们就有必要怀疑CPeople是不是一个抽象类了。判断抽象类的一个最重要的依据就是由其派生出来的子类如果是new出来的,则在delete前只析构自身,并不需要析构派生出来的子类。其次,检查其虚表是否有多项相同的地址也是一个很重要的特征,如果是的话那基本就可以肯定这就是一个抽象类了。因此我们现在要到CPeople的析构中检查其虚表。

通过上述代码我们知道,有两项跳向一个名为j__purecall的虚表。这个__purecall是一个库函数,它的作用是当编译期间内程序试图调用一个仍未被获取的虚函数时,它将接管控制,并终止程序执行。


接着分析上面的程序,我们看到在调用完CPeople的析构后,程序取得参数以后与1比对,如果为1则执行后面的delete,否则直接跳走并将this返回。这样做的目的主要是考虑到CPeople的析构有可能会对this指针产生影响,不过不同版本的编译器对此的处理不尽相同,因此各位知道有这么回事就可以了。


综上所述,我们得出以下经验:


new出来的对象会以其在堆中申请空间的指针作为this指针传入参与构造。

new出来的对象其虚函数调用的寻址方式与普通构造出来的不同。

delete对象时会先析构自己,再析构父类,最后再执行delete。

new出来的对象如果其成员函数派生于纯虚函数,在delete时只调用父类的析构。

如果此类为抽象类(包含纯虚函数),那么其虚表的对应项会填充指向库函数__purecall的函数指针。


有了以上知识垫底,我们现在趁热打铁就可以与Release版过过招了。请看代码清单5(为了便于大家理解,没删除IDA识别出来的一些信息,因此请大家在学习期间自觉地忽略这些信息)。

代码清单5  较复杂的虚函数的Release版反汇编代码

综上所述,我们得出以下经验:记住虚函数调用的固定模式,紧盯对各个虚表的操作,从而根据上下文即可大致确定虚函数的调用与类的析构和构造。

微信公众号:计算机与网络安全

ID:Computer-network

【推荐书籍】

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存