逆向分析总结

逆向分析总结

十二月 08, 2019

基本数据类型

整数类型

  • C++提供的整数数据类型有三种:intlongshort。在Visual C++ 6.0(Visual Studio 2013同样)中,int类型和long类型在内存中都是占四个字节,short类型在内存中占两个字节。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    #include <stdio.h>
    #include <stdlib.h>
    int main()
    {
    printf("int size of is:%d\n",sizeof(int));
    printf("long size of is:%d\n",sizeof(long));
    printf("short size of is:%d\n",sizeof(short));
    return 0;
    }

    image-20191208195745647

    ​ 由于二进制数字不方便显示和阅读,因此内存中的数据都是采用十六进制的方式进行显示。一个字节由两个十六进制的数组成。在进制转换的过程中,一个十六进制数字可以用4个二进制数字表示,每个二进制数表示1位,因此一个字节在内存中占的是8位。

    ​ 在C++中,证书类型又可以分为有符号型和无符号型两种。有符号的整数可以用来表示负数与正数,而无符号的整数则只能表示正数。

  • 无符号整数

​ 在内存当中,无符号整数的所有位都用来表示数值。以无符号整数数据unsigned int为例,此类型的变量在内存中占用4个字节,由8个十六进制数组成,取值范围是0x00000000~0xFFFFFFFF,如果转换为十进制的数字,则表示范围为0~4294967295

​ 当无符号整数不足32位的时候,用0来填充剩余高位,直到占满4个字节内存空间为止。例如,数字5对应的二进制数字为101,只占了3位,按照4字节大小保存,剩余29个高位将用0填充,结果就是00000000000000000000000000000101;转换成十六进制数0x00000005之后,在内存中以小端排序的方式存放,小端排序就是以字节为单位,按照数据类型长度,低数据位排放在内存的低端,高数据排放在内存的高端,如0x12345678将会存储为78 56 34 12。相对于的,还有一个是大端排序,低数据放在高端,高数据放在低端。如0x12345678将会被存储为12 34 56 78

​ 由于这里讲到的是无符号的整数,不存在正负的问题,都是正数,所以无符号整数在内存中都是以真值的形式存放的,每一位都可以参与数据表达。无符号整数可以表示的正数范围是补码的一倍。

image-20191208201548574

  • 有符号整数

    ​ 有符号的整数中用来表示符号的是最高位——符号位。最高位是0的话就表示是正数,最高位是1的话就表示为负数。有符号整数在内存中同样也是占4个字节,但是由于最高位是符号位,不可以用来表示数值,因此有符号整数的取值范围要比无符号整数的取值范围少1位,即:0x80000000~0x7FFFFFFF,如果转换为十进制的数,则表示的取值范围是-2147483648~2147483647

    ​ 在有符号的整数中,正数的表示区间为:0x00000000~0x7FFFFFFF

    ​ 负数的表示区间为:0x80000000~0xFFFFFFFF

    ​ 负数在内存中都是以补码的方式进行存放的,补码的规则是用0减去这个数值的绝对值,也可以简单的表达为对这个数取反+1。例如:-3:可以表示为0-3,而0xFFFFFFFD+3等于0,所以-3的补码就是0xFFFFFFFD了,相对应的,0xFFFFFFFD作为一个补码,最高位是1,视为负数,转换回真值同样也可以用0-0xFFFFFFFD的方式。于是得到了-3,为了方便计算,人们尝尝也采用取反加一的方式来求得补码,因此对于任何四字节的数值x,都有x+x(反)=0xFFFFFFFF,于是x+x(反)+1=0,接着我们就可以推导出0-x=x(反)+1

    ​ 在我们讨论的C/C++中,有符号正数都是以补码的形式存储的,而且在几乎所有的编程语言中都是这样,因为计算机只会做加法,所以需要把减法转换为加法

    ​ 假设有符号数x,yx-y的值,我们可以推导出x-y=x+(0-|y|),根据补码的规则,当y为负数的时候,0-|y|等价于y的补码。对于y的补码,我们记为y(补),所以x-y=x+y(补)

​ 例如,(3-2)可以转换成(3+(-2)),运算的过程为:3的十六进制原码0x00000003加上-2的十六进制补码0xFFFFFFFE,从而得到0x10000001。由于存储范围为4字节大小,两个数相加之后产生了进位,超出了存储范围,超过的1将被舍弃。进位被舍弃后,结果是0x00000001

值得一提的是,对于四字节的补码,0x80000000所表达的意义可以是负数0,也可以是0x80000000减去1.由于0的正负值是相等的,所以没有必要还要来一个负数的0,因此,也就是把这个值的意义规定为0x80000000减去1。这样0x80000000也就成为4字节负数的最小值了。这也是为什么有符号整数的取值范围中,负数区间总是比正数区间多一个最小值的原因。

​ 在数据分析中,如果将内存解释为有符号的整数,我们使用十六进制数表示时的最高位,最高位小于8则为正数,大于8则为负数。如果是负数则需要转换成真值,从而得到对应的负数数值。

image-20191209093211710

​ 对于上图,地址0x00AFFD30对应的4字节为变量nNum的数据信息。nNum为一个有符号整数,在内存中的信息为0xFFFFFFFF,最高位为1,说明变量nNum为一个负数。按照转换规则,内存中存放的十六进制数为一个补码,需转换成真值再进行解释。0减去0xFFFFFFFF后,或者对0xFFFFFFFF取反加1,都可以得到真值-1

浮点类型

​ 在C/C++中,使用浮点方式存储实数,用两种数据类型来保存浮点数:float(单精度)、double(双精度)float在内存中占4个字节,double在内存中占8个字节。由于占用的空间比较大,double可以描述的精度更高,这两种数据类型在内存中同样以十六进制方式进行存储,但是与整数类型有所不同。

​ 整数类型是将十进制转换成二进制保存在内存中,以十六进制的方式去显示。浮点类型并不是将一个浮点小数直接转换成二进制数去保存,而是将浮点小数转换成的二进制码重新编码,再进行存储,C/C++的浮点数是有符号的。

​ 在C/C++中,将浮点数强制转换为整数时,不会采用数学上的四舍五入法,而是会舍弃掉小数部分。

​ 浮点数的操作不会用到通用寄存器,而会使用浮点处理器的浮点寄存器,专门对浮点数进行运算处理。

image-20191209101748181

但是在Visual Studio 2013是可以通过的,VC6.0:这是由于在浮点数寄存器没有初始化时使用浮点操作,将无法完成转换小数的部分,我们可以在代码任意的一个地方定义一个浮点类型的变量即可对浮点寄存器进行初始化。

浮点编码方式

​ 浮点数编码转换采用的是IEEE规定的编码标准,floatdouble这两种类型数据的转换原理相同,但是由于表示的范围不一样,编码方式有些区别,IEEE规定的浮点数编码会将一个浮点数转换成二进制数,用科学计数法划分,将浮点数拆分为3部分:符号、指数、尾数。

  1. float类型的IEEE编码

    ​ float类型在内存中占的是4个字节(32位)。最高位用于表示符号;剩下的31位中,从左到右取8位用于表示指数,其余用于表示尾数。

    ​ 在进行二进制转换前,需要对单精度浮点数进行科学计数法转换。例如,将float类型的12.25f转换为IEEE编码,需将12.25f转换成对应的二进制数1100.01整数部分为1100,小数部分为01,小数点向左移动,每移动一次指数加一,移动到除符号位的最高位为1处,停止移动,这里移动3次。对12.25f进行科学计数法转换后二进制部分为1.10001,指数部分为3。在IEEE编码中,由于在二进制情况下,最高位始终为1,为一个恒定的值,所以将其忽略不计,这里是一个正数,所以符号位添0。

    12.25经过IEEE转换后各位的情况:

    • 符号位:0

    • 指数位:十进制 3+127,转换为二进制是10000010

    • 尾数位:1000100000000000000000000000000(当不足32位时,低位补0填充)

      由于尾数位中最高位1是恒定值,故省略不计,只要在转换回十进制的时候加1即可。为什么指数位要加127呢?由于指数可能出现负数的情况,十进制127可以表示为二进制数01111111。IEEE编码方式规定,当指数域小于0111111的时候为一个负数,反之则为正数,因此,01111111为0。

      ​ 12.25f转换后的IEEE编码按照二进制拼接为:01000001010001000000000000000000,转换为十六进制为:0x41440000,在内存中小端排序进行排列的时候:00 00 44 41

      image-20191209175627338

  2. double类型的IEEE编码

C语言函数及汇编指令

更新与2020.02.16-05:05心情复杂,上面的基础先不写了,先往下写点了

  1. 什么是函数?

    有返回值,有代码,有参数,有名字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //void 表示没有返回值
    void Add(int x,int y)
    {
    printf("这是一个函数代码\r\n");
    }

    //int 表示返回有符号int类型(一般情况下4字节)
    int Add(int x,int y)
    {
    printf("这是一个函数代码\r\n");
    return x+y;
    }

    这是很简单的一个函数。

    那么到底函数是怎么实现的呢?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <stdio.h>
    #include <stdlib.h>
    int Add(int x,int y){
    return x+y;
    }
    int main(int argc, char const *argv[])
    {
    _asm mov eax,eax;//这里正常是不会产生这样一个汇编语句的,但是这里写这条是方便定位分析的。
    int Value = Add(2,3);
    return 0;
    }

    编译好之后拖进OD,断在程序入口处的时候选择搜索->命令序列,他是在当前模块搜索的,我们需要按下OD中上方的E,然后选择第一个右键跟随入口再去搜索,查找命令序列mov eax,eax,即可跳转到我们的具体分析位置,按下F2下断点就可以运行过去啦~

    我们可以看到定位的下方是这样一串指令

    1
    2
    3
    6A 03        push 3
    6A 02 push 2
    E8 97 FDFFFF call 001D11E0

    我们先来思考几个问题,我们的CPU是怎么知道我们要从哪里开始运行的呢?

    这个时候就要提到我们的EIP寄存器,它是指引CPU去执行哪里的代码的寄存器

    回过头来我们来看这些汇编代码,第一个push 3我们的EIP变为了指向push 2的地址,我们在执行push 3的时候,可以发现我们的寄存器窗口和堆栈窗口的数据发生了一些变化,首先是ESP-4EIP+2,然后3被压入栈中而且压入的地址是ESP-4的位置,那么我们可以讲push 3转换为一下汇编代码

    1
    2
    3
    push 3 ==>
    sub esp,4
    mov [esp],3

    当然我们也可以这样

    1
    2
    mov [esp-4],3
    sub esp,4

    他们在本质上是一样的

    我们再来看一下这个call指令,我们运行下去之后会发现寄存器窗口和堆栈窗口是有变化的,EIP=call的参数[esp-4]=call的下一条指令,这个时候我们可以观察到,如果call的刚好是下一条指令的话,他的汇编写法就是

    1
    2
    sub esp,4
    mov [esp-4],call的参数

    也就是说出现这种情况的时候,call指令和push的执行结果一样。大部分书上说call是在调用函数,我认为是要看具体的代码情况的(不同意的请打我)。我们再来看这样的几条语句

    1
    2
    3
    4
    push 1         --> ESP-4 [ESP] = 1
    mov [esp],0 --> [ESP] = 0
    ------------------------------------
    push 0 --> [ESP-4] [ESP] = 0

    我们可以看到push 1;mov [esp],0push 0的结果是一样的,这两条指令是等同的,我们可以发现[ESP] = 1的行为被下面一条语句被覆盖掉了。语句虽然不等价,但是结果是相同的。很多指令都是可以拆解的,动态加密壳就是这些原理。

    我们在执行call的时候按下F7步入是可以发现跳转到了一条jmp语句的,我们来看一下jmp的特征

    mov eip,参数,当然mov是不允许修改EIP的,我只是在这里给大家做一个简单方便理解的一个简化。

    我们再来学习几个汇编指令

    lea edi,dword ptr ss:[ebp-c0]他其实就是把ebp-c0的值存入edi寄存器,相当于是脱掉了外面的[]

    rep stos dword ptr es:[EDI]rep其实是重复指令,我们按下F7会发现一些特征,他每次都会将ecx这个用于计数的寄存器-1,知道0为止结束

    pop edi,也就是和push对应的操作,将值返回给寄存器,名为出栈,效果:参数=[ESP] ESP+4,也就等同于如下代码

    1
    2
    3
    4
    5
    mov esi,[esp]
    add esp,4
    ---------同时也可以这么写-----------
    add esp,4
    mov esi,[esp-4]

ret,执行后发现EIPESP发生了改变 ,观察可以发现是做了这样的操作:EIP=[ESP]esp+4,其实类似与pop eip,但是是不存在这个操作的,同样是为了方便大家理解的,同样可以发现的是,ret的地址其实是最开始call调用时压入的地址,大家都知道函数执行完之后是要继续执行下一条语句的,这就是实现的原理。我们就可以总结ret等同于如下代码:

1
2
add esp,4
jmp [esp-4]

这次是不能反过来的,因为jmp之后你下面的代码就执行不了了。

易语言破解小栗子

更新与2020.03.31

  1. 先来看一下demo代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    .版本 2
    .支持库 spec

    .程序集 窗口程序集_启动窗口

    .子程序 __启动窗口_创建完毕

    编辑框1.内容 = 系统_取CPU序列号 ()


    .子程序 _按钮1_被单击
    .局部变量 注册码, 文本型
    .局部变量 机器码, 文本型

    机器码 = 系统_取CPU序列号 ()
    注册码 = 文本_颠倒 (机器码)
    调试输出 (注册码)
    .如果 (注册码 = 编辑框2.内容)
    载入 (窗口1, , 真)
    .否则
    信息框 (“注册码错误”, 0, , )
    .如果结束
  2. 其实就是取了CPU的序列号作为机器码,注册码为机器码的颠倒

  3. 我们静态编译后拖入OD进行分析。

  4. 使用OD加载程序后来到了入口地址,这里我们常规的都是看一下注册码错误提示:注册码错误然后去回溯关键函数,但是我们这里有一个技巧就是先让程序正常的跑起来
    image-e1

    按下od里面e按钮查看模块,双击第一个,来到00401000的地址,这里不懂得自行补充PE的知识。

    然后在这边去智能搜索字符串,会比进入函数入口搜索的多。

  5. 然后在字符串窗口找到了关键字符串:注册码错误,双击进入,向上查找push ebp的位置

    image-20200331135406351

    然后下断点运行,随便输入注册码后确认,函数随即断下来

  6. 然后我们开始分析这些代码,首先遇到第一个call 0040110B这个函数,我们回车进去看一下

    image-20200331135528649

    看到了cpuid用来去CPU序列号的汇编指令,所以这个call就对应到我们易语言中的系统_取CPU序列号操作

    然后F8单步走过,看到eax寄存器确实已经获取到了CPU的序列号

    image-20200331135707095

    然后继续F8下去遇到了call 0040153C这个函数,同样回车进去看,好的看不懂,直接F8走过,看eax寄存器的结果

    image-20200331135830911

    很明显的就是将原本取出的序列号进行的倒序,对应易语言中的注册码 = 文本_颠倒 (机器码)

    继续向下走call 00401C8B中获取到了我们输入的假码,直到走到了call 004012F9处,我们回车进去看

    image-20200331140032933

    这是易语言典型的文本对比的样子,然后基本确定这个函数是在对机器码和注册码进行对比,因为我们的注册码肯定是错误的,所以函数F8过去之后eax的值变成了1这时候我们如果在这里将eax置0,直接跑下去可以发现弹出了成功,我们称这个决定成功与否的函数叫做关键call,继续向下看代码

    1
    2
    3
    4
    5
    6
    7
    0040142A  |.  83C4 08       add esp,0x8
    0040142D |. 83F8 00 cmp eax,0x0
    00401430 |. B8 00000000 mov eax,0x0
    00401435 |. 0f94c0 sete al
    00401438 |. 8945 F0 mov [local.4],eax
    0040143B |. 8B5D F4 mov ebx,[local.3]
    0040143E |. 85DB test ebx,ebx

    这是典型的判断,就是如果判断

    image-20200331140744685

    这里我们修改掉ZF位为1也是可以绕过的。

    我们继续向下看,走到cmp dword ptr ss:[ebp-0x10],0x0位置是一个比较,走过之后因为是相等的,所以Z标志位变成了1,在下面的JE就会实现,跳转到验证码错误的位置,也就是说这个JE就是一个关键跳转,我们NOP掉这个跳转也是可以去成功登陆的。

    那么我们下面来玩个小操作,我们的易语言代码中正确输入了注册码之后会载入一个新的窗体,我们在je跳转的下方看到了如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    00401455    68 02000080     push 0x80000002
    0040145A 6A 00 push 0x0
    0040145C 68 01000000 push 0x1
    00401461 6A 00 push 0x0
    00401463 6A 00 push 0x0
    00401465 6A 00 push 0x0
    00401467 68 01000100 push 0x10001
    0040146C 68 1E230106 push 0x601231E
    00401471 68 1F230152 push 0x5201231F
    00401476 68 03000000 push 0x3

    这是在干嘛呢?push 0x10001是易语言载入窗口的特征,在下面以0x520开头的就是窗体的ID,这时候呢我们就可以想到,直接在易语言主窗口加载的时候替换掉窗口ID不就是可以绕过开始的验证了嘛?

    我们首先复制:push 0x5201231F备用,然后重新载入窗口,在入口函数断点断下后不跑,我们ctrl+b搜索FF 25去找主窗口这时候会找到一堆的JMP跳转,这里就是易语言在初始化的,向上找会找到一个push 0x52010001这样一条语句,这里就是在加载易语言的主窗体,我们修改此处的汇编语句为push 0x5201231F

    image-20200331141835978

    然后跑起来就发现我们已经绕过了最开始的验证窗口了