在逆向工程中,数据类型识别是理解程序逻辑的重要步骤,因为它直接影响对程序逻辑和功能的理解,识别出数据类型有助于确定变量的含义和函数的行为。在分析恶意软件或者寻找安全漏洞时,识别数据类型能够帮助发现代码中的潜在问题。例如,缓冲区溢出问题通常与错误的数据类型或者数组边界检查不当有关,了解变量的类型和大小有助于发现这类安全问题。那接下去我们就通过一个简单的例子来针对不同类型的数据进行特征分析。
逆向分析实例
下面是一个含有多种类型的数据变量的代码示例:
#include <stdio.h>
#include <stdlib.h>
int main() {
short numA = 20;
int numB = 10;
long numC = 30;
long long numD = 40;
char letter = 'A';
char text[] = "Hello, world!";
float factor = 3.14;
int * nPtr = &numB;
long & nQuC = numC;
printf("numA=%d\r\n", numA);
printf("numB=%d\r\n", numB);
printf("numC=%d\r\n", numC);
printf("numD=%lld\r\n", numD);
printf("Letter=%c\r\n", letter);
printf("text=%s\r\n", text);
printf("text=%f\r\n", factor);
printf("text=%d\r\n", *nPtr);
printf("text=%d\r\n", nQuC);
system("pause");
return 0;
}
此时使用Visual Studio
对该代码进行编译,生成exe
文件,对应的编译配置为Debug-x86
;
生成完成后放入x96dbg
进行动态调试:
接着定位到main
函数进行反汇编代码分析(定位main
函数的方法请看笔者前面C/C++逆向:定位main函数
文章);重点需要看的反汇编代码为红线以下部分,我们重点关注变量初始化部分,也就是方框中的代码。
①Short类型
mov eax,14
mov word ptr ss:[ebp-C],ax
mov eax, 14
:将数值 20
加载到 eax
寄存器中。
mov word ptr ss:[ebp-C],ax
:将 eax
的低 16 位(即 ax
)的内容存储到内存地址 ss:[ebp-C]
,其中 [ebp-C]
通常表示函数栈帧中的一个局部变量。
word ptr
是什么含义?
word ptr 是用于指示操作数的数据类型大小的指令修饰符 word:表示 16 位的数据,即两个字节(2 bytes)。 ptr(pointer):指针的缩写,意味着操作的对象是一个内存地址。
ss:
前缀
ss 表示 Stack Segment(堆栈段);在 x86 架构中,内存地址可以由段寄存器和偏移量共同构成,ss: 表明目标内存地址是相对于堆栈段的。
因为short
类型占2个字节,即16位,所以在第一条指令将数值加载到 eax
寄存器以后,在将数值放入内存ss:[ebp-C]
时的源操作数为eax
寄存器的低16位ax
。后续在逆向过程中遇到这种特征的代码就可以初步进行判断。
②int类型
相关代码:
mov dword ptr ss:[ebp-18],A
可以看到int
类型的数据初始化就没有像short
类型那样子弯绕,直接就将数值A(10)
存入内存地址 ss:[ebp-18]
。此时用于指示操作数的数据类型大小的指令修饰符为dword ptr
也就是表示32位。
③long类型
相关代码:
mov dword ptr ss:[ebp-24],1E
long
类型数据与int
类型一样也是直接将将值1E(30)
直接放入内存堆栈中,原因是因为在Windows环境中long
的长度与int
类型是一致的,长度都是4字节。所以此时用于指示操作数的数据类型大小的指令修饰符为dword ptr
也就是表示32位。此外还有一个需要注意的点即长整型long在不用的操作系统中所占用的字节数不同,具体的占用字节如下图:
所以在写跨平台的应用时,如果使用到long型,需要考虑到精度丢失的问题,在写跨平台应用时也尽量避免使用long型。
④long long 类型
相关代码:
mov dword ptr ss:[ebp-34],28
mov dword ptr ss:[ebp-30],0
在 x86 体系结构中,long long
类型是一个 64 位(8 字节)
的数据类型,而在 32 位程序中,通常使用两个 32 位(4 字节)
的存储单元来存储 long long
变量的高 32 位和低 32 位。因此,初始化 long long
类型数据会涉及到两个内存位置的赋值操作。
mov dword ptr ss:[ebp-34], 28
:这条指令意味着 long long
变量的低 32 位被初始化为 28(40)
。
mov dword ptr ss:[ebp-30], 0
:[ebp-30]
是紧接着 [ebp-34]
的下一个 4 字节内存位置,用来存储 long long
变量的高 32 位;这里将高 32 位初始化为 0
,此时我们可以查看内存窗口中的值:
可以发现确实是低 32 位被初始化为 28(40)
,高 32 位初始化为 0
。
如果此时代码的编译配置设置位x64
,那么long long
数据的初始化的反汇编代码就会是这样的:
mov qword ptr [numD],28h
⑤char类型
相关代码:
mov byte ptr ss:[ebp-3D],41
byte ptr
表示操作的数据大小是 8 位(1 字节),char
类型的数据在内存中占用 1 字节,因此汇编代码使用了 byte ptr
来进行操作。值 41
(十六进制)对应于 ASCII 字符 'A'
,此时我们也可以通过查看内存来进行确定,在反汇编窗口中的对应反汇编代码中右击,选择在内存中转到:
接着选择地址(A)选项,然后此时内存窗口中就会将对该地址的值进行定位。
⑥字符串类型
相关代码:
mov eax,dword ptr ds:[FD7B30]
mov dword ptr ss:[ebp-58],eax
mov ecx,dword ptr ds:[FD7B34]
mov dword ptr ss:[ebp-54],ecx
mov edx,dword ptr ds:[FD7B38]
mov dword ptr ss:[ebp-50],edx
mov ax,word ptr ds:[FD7B3C]
mov word ptr ss:[ebp-4C],ax
这段汇编代码对应的是对一个字符串的逐步初始化过程。在 32 位系统中,由于寄存器和内存操作的限制,字符串数据是分块拷贝的。前面几条指令每次拷贝 4 字节,最后拷贝 2 字节。
char text[] = "Hello, world!";
这个字符串的初始化过程:
先将dword ptr ds:[FD7B30]
值也就是6C6C6548(Hell)
放入eax寄存器中;
然后将eax
中的值转入dword ptr ss:[ebp-58]
内存堆栈中,这就是第一个分块拷贝的过程,后续则是分别将内存中存储的字符串的值分别进行分块拷贝,因为过程都一样这边就不再一一列举了。这边主要说一下最后一个步骤:对于字符串Hello, world!
来说应该需要做3次4字节+1次1字节的内存分块拷贝才对,为什么最后一个步骤使操作的指示符为word ptr
两个字节呢?
mov ax,word ptr ds:[FD7B3C]
mov word ptr ss:[ebp-4C],ax
原因就是因为每个字符串都需要一个结束符号,所以最后两个字节应该是21(!) 00(结束符)
。
⑦float类型
相关代码
movss xmm0,dword ptr ds:[FD7BB0]
movss dword ptr ss:[ebp-64],xmm0
movss xmm0, dword ptr ds:[FD7BB0]
:从数据段(ds
)地址 [FD7BB0]
处读取一个 32 位(4 字节) 的单精度浮点数(float
),并将其存储到 xmm0
寄存器中。
movss 指令用于移动单精度浮点数(即 32 位的 float),它与整数的 mov 不同,专门用于处理浮点数据;xmm0 是一个用于浮点运算的寄存器。
此时寄存器中的如下:
movss dword ptr ss:[ebp-64], xmm0
:将 xmm0
寄存器中的单精度浮点数存储到栈帧中相对于基址指针 ebp
的偏移量 -64
处。
寄存器中XMM0
中的十六进制值就是浮点型数据在内存中的存储形式,下面我们就来说一下十六进制的值与浮点数之间的转化是如何进行的,将十六进制数转换为浮点数涉及将其按照 IEEE 754 标准进行解码。IEEE 754 是计算机中用来表示浮点数的标准格式,其中单精度浮点数(float
)占用 32 位(4 字节)。其转换的具体步骤如下:
IEEE 754 单精度(float)浮点数表示法
单精度浮点数的 32 位结构如下:
符号位(S):1 位,表示正负号(0 为正,1 为负)。 指数位(E):8 位,用于表示指数。 尾数位(M):23 位,用于表示有效数字(尾数)。
首先将十六进制数转化为二进制:0x4048F5C3
转换为二进制是:0100 0000 0100 1000 1111 0101 1100 0011
对应到 IEEE 754 的结构中:
符号位(S):第 1 位是 0,表示正数。 指数位(E):接下来的 8 位是 10000000,对应十进制为 128。 尾数位(M):剩下的 23 位是 0100 1000 1111 0101 1100 0011。
接着需要解码浮点数:
符号位:S = 0,表示正数。 指数位:E = 128,所以实际的指数为:E - 127 = 1。 尾数位:M = 1.01001000111101011100011(添加隐含的 1)。相当于1+0.25+0.00390625+0.0009765625+⋯≈1.57
接着将数值代入公式中进行计算,具体表示公式为:
Value = 1^0 * 2^1 * 1.57 = 3.14
通过使用 IEEE 754 格式将 0x4048F5C3
转换为浮点数,得到的值是大约 3.14。
IEEE 754 双精度(double)浮点数表示法
在 IEEE 754 双精度浮点数(double 类型)中,浮点数的表示和单精度有所不同。双精度使用 64 位来表示,其中包括符号位、指数位和尾数位,具体结构如下:
双精度浮点数的 64 位结构
符号位(S):1 位,用于表示正负号(0 表示正数,1 表示负数)。 指数位(E):11 位,用于表示指数,使用偏移量 1023,需要减去偏移量 1023 以得到实际的指数值。 尾数位(M):52 位,用于表示有效数字(尾数)。
双精度浮点数表示公式为:
计算方式都是一样的,这边就不做过多赘述。
⑧指针
相关代码
lea eax,dword ptr ss:[ebp-18]
mov dword ptr ss:[ebp-70],eax
lea
是 Load Effective Address
指令,用于将操作数的有效地址加载到指定的寄存器中。
所以lea eax, dword ptr ss:[ebp-18]
的作用是将该偏移量的地址加载到 eax
寄存器中,而不是读取该地址的内容。这种用法通常用来获取某个变量的地址,因此 eax
寄存器中存储的是变量的地址,即指针值。
mov dword ptr ss:[ebp-70], eax
:这条指令将 eax
寄存器的值(即之前计算出的地址)存储到栈中相对于基址指针 ebp
的偏移量 -70
处。
⑨引用类型
相关代码
lea eax,dword ptr ss:[ebp-24]
mov dword ptr ss:[ebp-7C],eax
在这边我们可以看到引用类型的初始化反汇编代码与指针类型的反汇编代码一致。lea eax, [ebp-24]
的作用是将该局部变量的地址加载到寄存器 eax
中。mov dword ptr ss:[ebp-7C], eax
这条指令将寄存器 eax
中的值(也就是局部变量的地址)存储到栈中相对于基址指针 ebp
的偏移量 -7C
处。
指针和引用在 C++ 中虽然有不同的特性,但在底层的实现中,它们的初始化通常会生成类似的汇编代码。之所以出现这种情况,是因为引用和指针的本质上都是地址的操作,而汇编层面只关心数据的地址和访问方式。引用和指针的差异是由编译器层面决定和实现的,而不是在底层硬件或者汇编指令层面体现的。