查看原文
其他

逆向工程 | 运算符的识别与优化

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

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

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

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


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

ID:Computer-network

在大多数逆向工作中,我们所需要分析的最关键内容几乎都是算法,因为算法就是一个软件的灵魂。通常意义上来讲,算法是由各种复杂的数学公式组成的,因此如果我们要真正看懂一个加密、解密或压缩算法的话,那么学习运算符的逆向技巧还是非常必要的。下面我们就由简入繁,逐一为您剖析逆向工程中数学运算的精要。


一、加法的识别与优化


加法的优化相对来说比较简单,只有3种优化方案。下面我们就以一个简单的例子来说明这3个方案,先看代码清单1。

代码清单1  加法运算

Debug版反汇编代码如下所示:

通过上面的反汇编代码我们可以看到3个再普通不过的加法计算,没有什么值得特别探究的地方。下面我们再看看Release版的反汇编代码。

由上面两段反汇编代码,我们可以总结出加法计算的以下优化方案:

由此可见,识别算术运算中的加法还是非常简单的。


二、减法的识别与优化


减法的优化与加法大同小异,基本相同,因此与加法相同的地方不再多说。我们先看代码清单2。

代码清单2  减法运算

这次我们直接看Release版反汇编代码。

通过以上代码我们不难发现,减法的优化方案与加法基本相同,只有形式2上体现出了不同之处,编译器将其优化成了一个加法。这究竟是为什么呢?其原理又是怎样的呢?


仅从指令周期上来讲,老版本的CPU加法指令周期要比减法短一些,因此这种优化也就这样一直沿袭了下来。而仅从本条指令上的优化来说,编译器将原本的减法转换成了加法,并将常量转成了其原本的补码。我们都知道,减一个数与加这个数的补码所得到的结果是一样的,因此编译器就利用了这个原理。


以下是总结的减法优化方案:

三、乘法的位移优化


如果让我们来做乘法的优化,那么我们该怎么做呢?很显然位移是必须要被利用的。但是除此之外微软的编译器还利用了lea指令。


我们先看看简单的位移优化。经过总结,当乘数为2的整数次方、且大于8时,编译器才会使用此优化。让我们看看优化前与优化后的效果,先看代码清单3。

代码清单3  简单的乘法运算

下面是这两行源码的Debug版反汇编代码:

Debug版所呈现出来的是一个非常标准的乘法运算方式。我们再看看Release版的反汇编代码。

按照我们平时对编译器的理解,如果我们乘的是17的话,那么编译器肯定会将其分解为一个左位移再加一个加法。事实真的如此吗?我们看看乘以17后的Release版的汇编代码。

如果比2的次方多1会采用加法,那么比2的次方少1是否会用左位移后再加一个减法操作的方式呢?我们看看这个Release版汇编代码。

看来我们想的没错。除此之外,我们就要思考一下lea指令在乘法中的优化及应用了。


四、乘法的lea指令优化


众所周知,lea是Intel工程师比较得意的一条指令,它的作用是传递操作数地址,它具有指令周期短、与逻辑指令流水线无关等特点。下面我们就一起分析一下微软的编译器是怎样使用它来优化乘法的。

为了节约版面,这里我直接给出一个较全面的例子,这可以帮助您快速了解乘法lea的优化方案。先看代码清单4。

代码清单4  复杂的乘法运算

我们先看以上代码Debug版的反汇编代码,如代码清单5所示。

代码清单5  复杂的乘法运算的Debug版反汇编代码

通过上面的例子我们可以看到,即便是Debug版,编译器仍然应用了一些优化方案。那么Release版编译器究竟会将上面的代码变成什么样子呢?请您仔细看代码清单6。

代码清单6  复杂的乘法运算的Release版反汇编代码

看到这里,您肯定已经被编译器的强大所折服了吧?


我们在前面提到过“lea具有指令周期短、与逻辑指令流水线无关等特点”,但是为什么编译器在优化时会交替使用ecx与edx两个寄存器呢?难道一个寄存器不能解决问题吗?答案是肯定的,原理很简单,我们的push指令是与逻辑指令流水线有关的。


另外在“标注1”的地方我们发现,编译器在这里直接使用了乘法指令而并没有将其优化成"lea ecx,[eax+eax*5]",这是因为lea后面的比例因子必须为2的倍数,因此我们想象的这条指令是无法被执行的。也有个别版本的编译器会将其拆分为两条lea指令,但这种情况并不常见,因此无需深究。


而“标注2”所示的这个优化恐怕会难倒一大批人。为什么同样的乘法带加法运算,一个用的是lea,而另一个却是用add呢?其实这个问题可简可繁,往简单说,上面那条指令之所以没有直接用add是因为那样会改变寄存器eax的值,对后面的计算造成不便;而往繁杂说,那就要讨论为什么add要比lea的优先级高了。这还是出于指令周期上的考虑,虽然lea在逻辑处理器中只占用1个指令周期,并且可以在mmx协处理器中成对执行,但是其产生的相对负荷还是要远高于像add这种“原生态”指令的。


五、除法与倒数相乘


何为倒数相乘?编译器世界中倒数相乘的中心思想其实就是用乘法来代替除法运算。它的原理很简单,就是将被除数乘以除数的倒数,其公式为x/y=x*(1/y),我们拿10/2作为例子。


由x/y=x*(1/y)(9-1)

可得10/2=10*(1/2)=10*0.5


编译器也正是依据式(9-1)将除法转换为除法的。但是编译器为什么要这样做呢?原因同样很简单,因为乘法的运算速度比除法的快4倍左右,在现有的Intel指令集中,就属除法指令最慢,因此将其优化为乘法的理由显得很充分。但是这么做似乎有不妥之处,让我们看看这是为什么,如代码清单7所示。

代码清单7  简单的除法运算

很明显,我们没能充分考虑浮点类型,一般情况下,在C语言中,1除以任何数其结果皆为0。这个问题显得严重,我们要怎样才能解决它呢?答案在下面。


六、倒数相乘与定点运算的配合


为了使除法的倒数相乘优化成为可能,编译器使用了定点运算方案来表示小数。


何为定点运算,定点运算有什么特点呢?


我们都知道,一般情况下,小数都是用浮点类型来表示的,有一位记录当前的小数点位于哪里。当然还有其他的一些转换规则,我们只需知道浮点类型的小数点可以位于任意一位,也就是说“小数点是浮动的”。


而定点运算根据字面意思来理解就是“小数点是固定的”,这种小数的定点表示法有很多优点,其中最重要的就是效率高。当然,作为代价,同样也必须承担随之而来的精度上的丢失。


那么,这个定点表示法又是怎样运作的呢?它怎么就能保存小数信息呢?这部分内容的相关资料很难寻找,经过大量近似资料的启示与试验,最终才证实其原理其实很简单,这还要从定点表示法的小数点位置与精度说起。


首先,编译器一般都将小数点定位在第一位,而要表示一个数的倒数,那么用小数表示的话必然是一个小于1的数。例如8的倒数与12345678的倒数分别为0.125与0.000000081,其整数部分恒为0。


其次,就是精度问题。例如我们用一个4bit大小的数来表述小数的话,那么它的精度就是0.0625,其表示的整数每加1或减1,其表示的小数就随之增加或减少0.0625,如下表所示。

如果还不太明白,请看下面这个精度为0.0625的例子。

而对于32bit的编译器来说,只不过是将上面例子的位数扩充了一下,因此精度也就随之提高了。32bit的精度如下:

下表中所示的就是一些精度为32bit的定点运算方式。

有了上面的基础,我们就可正式进入除法优化的神秘领地了。


七、除法运算的识别与优化


大多数情况下除法是最好识别的,因为其特征很明显。在前面详细地讲解了编译器利用位移指令(sar、shr)的优化情况,显然,除法必然也会存在这种优化,但是鉴于其基础知识已经讲解过,且原理比较简单,因此除法的位移优化将一带而过,我们将重点放在与倒数相乘相关的优化上。


按照惯例,请阅读代码清单8,并分析编译器会将其进行怎样的优化。

代码清单8  复杂的除法运算

下面我们再看代码清单9。

代码清单9  复杂的除法运算的Debug版反汇编代码

通过上面例子可知,虽然Debug模式下未开启任何优化,不过在除数为2整数次方的情况下,编译器仍对其做了不小的优化。那么Release版就更是可想而知了,如代码清单10所示。

代码清单10  复杂的除法运算的Release版反汇编代码

后面除数为2整数次方的优化没什么可说的,与Debug版里一模一样,但是上面的除法优化可能使有些朋友很困惑,这究竟是为什么呢?我们先看看下表。

但是我们发现反汇编代码中的值并非与上表中的完全相同,这其实是编译器优化的结果,我们可以对照上表来观察下表。

由上表可知,不管我们反汇编中体现出来的值最终比实际计算出来的值大几倍,但都是2整数次方,例如11的倒数对应的是大2倍,而59对应的是大8倍。


而且反汇编中体现出来的值不管比原值大几倍或大致没变,都要比原值大1。


这看起来似乎有些怪异,我们先想想后面这种情况,想想为什么非要大1呢?它为什么不大2或100呢?这其实不难理解,很明显与精度有关,我们拿除数为59的定点运算结果72796055.68241664000来说,在将其转为十六进制后,它的小数会直接被舍去,而按照四舍五入的原则其实是应该加1的,但是实际并没有这样做。


编译器在进行优化时考虑到了这点,为了弥补后面丢失的小数精度,所以在最终生成代码时就统一都加1。


第二种加1的问题解决了,思维活跃的朋友这时应该可以猜到第一种情况也是精度问题了。事实也正像您所想象的那样,将原定点表示法计算出来的值乘以一个2的倍数,就是出于精度的考虑。


让我们共同回忆一下定点表示法的特点:


计算更加简便快速。

有精度问题。


当我们试图用定点表示法表示更小的小数时,其精度问题就越明显。拿一个4位的定点小数来讲,其最小精度为0.0625,也就是说,如果我们想表示0.05的话是不行的,那么除了增加位数以外,还有没有什么其他方法呢?


做法很简单,我们只需用0.05乘以5,那么我们就可以用0x4来表示它了,而且精度一点也没丢失,再次使用的时候只需再将其除以5即可。


不过令人沮丧的是,上面的思路虽然原理是对的,但是实际情况要复杂一些。因为编译器最终是要将除法优化掉的,所以我们必须保证在数据还原时不能出现除法。这很明显要用到位移了,而位移的特点是只能转换除数为2的整数次方的除法,因此这就需要我们保证在变换小数时将其增加的倍数也必须是2的整数次方。


我们拿1876523938/876523938这个例子来讲,这次要将其乘以4以期减少误差,其运算逻辑如下。


(1)参考数据:


此算式的浮点结果为2.140870。

此算式的整除结果为2。

1876523938=0x6FD97BA2。

1/876523938定点表示为0x00000004。

4/876523938定点表示为0x00000013。


(2)以普通方式计算:

(3)以编译器优化后方式计算:

由此可见,这种计算前增加倒数值(称为‘倒数向上圆整’)后,再将结果减去对应倍数的值(称为‘商向下圆整’)的方法可以比较完美地解决精度问题。


八、取模运算的识别与优化


事实上当我们明白了除法运算的优化原理后,再理解取模运算就非常简单了,取模运算只不过是在除法运算的基础上再加一步而已。


那么即便是如此,我们还是有必要说一说,先看代码清单11。

代码清单11  取模运算

简单至极的源码呀!想必编译出来的机器码也是如此,Debug版的肯定更加友好,具体如下:

既然Debug版这么简单,或许Release版也不会太难。

非常顺利地完成了取模运算的学习,最后再总结一下特点。


普通除法。

应用计算结果(如果倒数被向上圆整了,那么根据sar指令后的数值向下圆整即可)如下:

除数为2的次方。

应用计算结果如下:

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

ID:Computer-network

【推荐书籍】

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

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