查看原文
其他

逆向 | 指针、数组、结构体与对象

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

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

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

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



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

ID:Computer-network

一、指针与数组的渊源


逆向的角度去理解数组与指针,并学习怎样识别它们,从而能以多个角度去理解C语言中较为复杂的指针问题。


我们先看一段分别以数组下标和指针方式访问数据的代码,如代码清单1所示。

代码清单1  指针与数组

由以上代码可知,前两组printf()函数是以指针方式访问数组nArray中的数据的,而后两组printf()函数则是使用数组下标的式访问数组nArray中的数据的。


从执行结果我们可以发现,用指针访问数组与用下标访问数组的结果是一样的,那么其反汇编代码是否也一样呢?请看代码清单2。

代码清单2  指针与数组的Debug版反汇编代码

相对来说,数组与指针的反汇编指令还是很容易理解的,相信通过注释您已经能理解以上反汇编代码的意思了。下面我们再看看代码清单3。

代码清单3  指针与数组的Release版反汇编代码

由代码清单3可知,使用指针取数据与数组取数据的反汇编代码是完全相同的。由此我们完全有理由以数组来解释复杂的指针问题,从而令指针问题变得清晰明了。


二、数组的不同表达方式


数组在反汇编中的表达方式一般分为一维数组、多维数组与循环遍历数组这3种,其中一维数组与多维数组较难区分。以nArray[2][3]这种多维数组的访问方式为例,根据编译器的不同,有可能生成以下反汇编代码:

其实对于以上反汇编指令,我们既可以认为这是一个类似于nArray[11]的访问数组方式,又可以认为这是一个类似于nArray[2][3]的访问数组方式。因为无论采用的是哪种方式,其指向的数据成员都是同样的。因此在实际的逆向工作中,我们很难区分这两种情况。当然,在绝大多数情况下也不需要我们对此进行区分。如果我们出于工作需要必须要对其作出区分,那么唯一能入手的地方就是查看访问这个数组其他数据成员的代码,从代码结构与逻辑着手,进而分析出这是一个几维的数组。


除此之外,当数组遇到需要循环遍历的时候,那么它就会以一种比较有特点的形式出现。

以上语句根据编译器的不同,可能会生成以下反汇编代码:

我们可以发现,在数组面对循环访问的情况下,负责控制访问数组成员偏移的功能由一个寄存器作为选择子来代替,这样做更有利于高效地访问数组中的各个数据。


总体而言,数组的访问总是遵循以下公式:

因此每当我们发现能套用以上公式的访问或地址计算时,就要先考虑这是否是一个数组。


我们简单描述了指针与数组的关系,以及数组的寻址方式。我们发现当从逆向的角度上来研究它们时,数组、多维数组与指针大同小异,这些特性只不过是在C语言层面才被细分成不同的概念。

我们将要接触的结构体、对象的概念与数组也是同根同源的,在逆向时同样很难分辨清楚它们之间的异同。作为逆向来讲,最为关键的就是能还原出目标程序的算法与逻辑,而这些细微的差别并不足以影响我们的逆向结果,但是为了不时之需,我们仍然有必要掌握区分它们的方法与技巧。


三、数组与结构体


数组与结构体存放数据的原则是一样的,它们都是在一段连续的空间中存放若干数据,唯一的不同之处在于数组存放的数据都是同一类型的,而结构体则有可能存放多种类型的数据。我们要怎样才能区分它们呢?请先看代码清单4。

代码清单4  结构体示例

上面的代码十分简单,就是用一个函数打印自定义结构体_TEST里的元素内容。以下是截取的Release版部分反汇编代码:

在分析以上的反汇编代码时应该注意以下问题:


在逆向以上代码时大脑里一定要想象一个堆栈,并随之变化,以免被其迷惑。


这虽然是一段IDA的反汇编代码,但看起来反而没有OllyDbg的反汇编代码简单明了,主要是因为IDA为了表明某些变量是属于一个结构内的,对其作了拆分命名处理,但是却并未能对其进行准确命名,因此将IDA生成的变量名替换成了偏移。由此便导致了一个问题,即IDA本想做好一件事,反而帮了倒忙,例如其中的fstp[esp+4Ch-4Ch]其实就是fstp[esp],很明显IDA画蛇添足了。


除去IDA此次生成反汇编代码本身的混乱不谈,我们注意一下其对于结构体stcTEST的操作,似乎根本看不出它是在操作一个结构体,这段代码看似在操作3个临时变量。而且从程序对结构体的初始化赋值上也完全找不到原本的赋值顺序。当我们试图在堆栈中重组这个结构体时,发现其在堆栈中的存储同样与我们结构体声明的顺序不一样,仿佛一切都乱了。


如果我们事前不知道这是一个结构体的话,那么如何才能通过逆向工程来分辨它呢?答案是几乎不可能,因为编译器在遇到结构体时首先会对其进行拆分,并按照其喜欢的顺序来保存或初始化它,除非我们能得到这个结构体的指针,否则我们基本不可能确定哪些是结构体,哪些是变量。


到此我们已经找到数组与结构体的不同了,即数组是一组同类型的被连续保存的数据,而结构体并不总是如此。换句话说,结构体也有可能与数组一样,例如你声明了一个由多个同类型元素组成的结构体,那么它的反汇编代码特征将与数组一致。有人也许会问,那我们在这种情况下怎样去分辨它们呢?答案是没必要这样做,因为此时无论是从数据结构或从代码逻辑上讲,数组与结构体已经不分彼此了,我们为什么还要去区分它们呢?


四、结构体与类


C++中,结构体与类是非常相似的,它们都有默认的构造函数与析构函数,也都可以提供自己的接口函数。但是除此之外它们还是有很多不同的。


比如类既可以由结构体继承,也可以由其他的类继承而来,而且类还可以派生出新的子类,但是结构体做不到这些。而且结构体内的所有数据默认都是可以被外界所访问的,因此结构体仅是一个数据的集合,它并不能为维护面向对象的基本思想提供必要的支撑。


逆向工程中,我们怎样才能分辨出结构体与类的区别呢?又怎样确定结构体或类的大小呢?答案是很难,或者说基本不可能。


一般情况下,我们要逆向的东西肯定不是我们自己写的,而且会遇到由内存对齐问题带来的困扰,并且还有类似于静态数据成员、虚函数与派生类等问题,这让我们确定它们的大小变得基本不太可能。而且,实现相同功能的结构体与类,其生成的二进制代码应该是完全相同的,因此我们也很难分辨这是一个结构体,还是一个类。


五、变量作用域的识别


变量的作用域一般根据其类型而定,比如全局变量与局部变量,我们仅根据其名字就可以大致推断出它们的作用域的差异。


但是除了全局变量以外,其他变量的作用域并不能通过反汇编特征来分辨。以局部变量为例,我们只能通过初始化该变量与最后一次引用此变量的区间为界限,来判断此局部变量的具体作用域,并不能通过其他方式确定。


一般情况下,我们只需要分辨此变量的类型,并粗略地确定其作用域即可。即便是我们要将目标程序还原为源代码,也不需要对变量的具体作用域太过较真,因为我们在逆向出目标程序的基本逻辑之后,后面的工作在还原为源代码时可以根据编程的常识做细微调整。


具体来说,变量总共分为以下几种类型。


全局变量:全局变量的数据总是存储在PE文件中的数据段或代码段中,在游戏修改社群内也被称为“基址”。全局变量的特点是其生存周期与其所在的模块相同,其初始化的内容大多在程序执行之前就已经被保存在程序的数据段中,访问方式为对某一地址空间的直接访问。


局部变量:局部变量的数据保存在栈中,其生命周期与其所在的函数作用域一致,访问方式是使用ebp或esp间接访问。


静态局部变量:静态局部变量的数据保存方式及访问方式与全局变量基本一致,因此很容易被误认为是全局变量。但是在保存此变量的临近位置往往会有一个标志位控制其具体的作用域,当此标志位为1时证明此变量已经被初始化。


堆变量:堆变量的数据保存在new出来的堆空间中,其作用域由与new对应的delete控制,访问方式是使用new出来的指针访问其中的数据。


为了使您对变量类型建立一个比较系统的认识,我们以代码清单5为例演示在实战中可能遇到的情况。

代码清单5  变量类型示例

由于Release版的优化不利于我们演示变量类型的特点,因此我们只采用Debug版的反汇编代码讲解,如代码清单6所示。

代码清单6  变量类型示例的Debug版反汇编代码

为了便于理解,对以上的反汇编代码做了一些处理,使其显得更加清晰明了,在真实情况下IDA生成的反汇编代码可能与以上代码有所不同。


但是通过以上代码,我们已经可以比较直观地分辨出各种变量类型的异同了。

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

ID:Computer-network

【推荐书籍】

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

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