C++逆向:基本数据类型在内存中的表现形式的分析
编辑在C++中分为基本数据类型(Primitive Data Types)、导出数据类型(Derived Data Types)以及用户自定义的数据类型(User-Defined Data Types),这些数据类型在内存中的表现形式是各不相同的,对于编码人员与逆向分析人员掌握其在内存中的表现形式在实际工作中是十分重要的。本篇主要着重于基本数据类型在内存中的表现形式,其余两个数据类型再另起一篇文章来进行分析和讨论。
下面是C++中的基本数据类型的种类以及其内存所占的字节数大小:
DataType | Keyword | Size in Bytes |
---|---|---|
Integer | int | 4 |
Floating-point | float | 4 |
Double | double | 8 |
Character | char | 1 |
Boolean | bool | 1 |
上面表格中的基本数据类型在内存中是怎么存储的呢?它们的表现形式是什么样子的呢?让我们进入汇编的世界来揭秘吧。
变量与内存读写
CPU在读写内存数据时是使用的内存地址来进行的,也就是说,它只认内存地址。程序编写人员在编写代码时,经常使用变量名来直接进行读写内存的(如:i++
),似乎没有提供这块内存的具体地址,CPU也正确的读写了内存,这与上面CPU的功能在逻辑上有了冲突,那么变量名(标识符,Identifier
)与内存地址的关系是什么呢?
由于在程序编写的过程中,可能用到非常多的变量,如果对这些变量必须提供内存地址才能进行存储数据的话,那么是不方便程序编写人员进行程序编写的。因此,语言的设计者就提供了变量这一语法,使得程序编写人员可以使用变量名就可以读写内存了,编译器在这上面就起到了重要的作用。
编译器所扮演的角色就相当于是一个翻译员(Translator),它将用户用文本形式编写出来的代码翻译成CPU能够听懂的二进制机器指令。在翻译的过程中,它就会自动的将上面对于变量名的操作与内存地址关联起来,这样CPU就可以正确的读写内存了。
基本数据类型的在内存中的表现形式
整数类型
示例代码:
int main(int argc, int argv)
{
printf("%d", argc);
return 0;
}
反汇编代码如下:
.text:00401040 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401040 _main proc near ; CODE XREF: __scrt_common_main_seh(void)+F5↓p
.text:00401040
.text:00401040 argc = dword ptr 8
.text:00401040 argv = dword ptr 0Ch
.text:00401040 envp = dword ptr 10h
.text:00401040
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043 push dword ptr [ebp+8] ; ArgList
.text:00401046 push offset Format ; "%d"
.text:0040104B call sub_401010
.text:00401050 add esp, 8
.text:00401053 xor eax, eax
.text:00401055 pop ebp
.text:00401056 retn
.text:00401056 _main endp
可以发现,其直接将通过dowrd ptr [address]
的形式进行处理的。上面这句话的功能类似于C++
中*(DWORD*)address
的功能,所以在理解各种变量在内存中的表现形式时,首先要弄明白byte/word/dword ptr [address]
语句的含义。它们的含义是什么呢?在内存中访问内存单元是要进行解释的:
- 你要访问的是哪个地址处的内存单元
- 你一次要访问几个内存单元
所以,在读写内存单元中的数据,需要对内存地址进行解释(因为内存地址本身不具有类型信息,它只是一个值而已),就有了上述语句,它所代表的含义是:从address
地址开始,读写byte/word/dword
个大小的内存单元的数据。
通过上述反汇编代码,可以验证有符号整数类型在内存中所占的大小是DWORD
,即:4个字节。
修改示例程序:
int main(int argc, int argv)
{
int i = argc + 1;
while (i != argc) {
printf("%d\r\n", i++);
}
return 0;
}
上例代码的目的是为了:
- 验证有符号整数类型的表示范围真的是如同书上说的那样是
[-2147483648, 2147483647]
- 可以通过动态调试器所提供的内存窗口,对有符号整数在内存中的存放形式进行分析
在运行上述程序后,程序最终退出了循环,说明发生了溢出的情况。观察输出窗口的数据,可以得到验证,有符号整数类型的表示范围与逻辑分析的表示范围是一致的。
对变量i
的所在的内存单元进行跟踪的时候发现,数据在内存中的存储是使用16进制进行存储的。在尝试对该内存单元中的数据格式进行分析时,发现一件怪事儿:它表示的整数是按照从高地址处一个字节一个字节的汪低地址处编码的。这是为什么呢?原因是:数据在内存中存放的顺序要做一个约定,否则大家都按照自己的个人理解来进行处理,就会导致含义出现偏差的情况,因此出现了两种派别:
- 高地址存放高位数据,低地址存放低位数据,称为小端存储方式(Little Endian),如:
0x78563412
- 高地址存放低位数据,低地址存放高位数据,成为大端存储方式(Big Endian),如:
0x12345678
而数据在内存中存储总得需要一种方式,这两种存储方式没有什么优劣,只要有一个约定就可以了,不用纠结为什么。就像有人在吃水煮蛋的时候,喜欢从较小、较尖的那一端打破鸡蛋皮;而有的人喜欢从较大、较粗的那一端打开鸡蛋皮,没有什么好坏优劣,全凭喜好。
对于无符号整数类型的分析与上述分析的方法一致,由读者自行分析。但是在逆向分析时,如何判断一段数据是有符号类型还是无符号类型呢?弄清楚这个问题对于逆向分析是至关重要的。方法是:查看指令或者已知的函数如何操作给定的内存地址
实数类型
实数类型分为单精度数据类型和双精度数据类型,其中单精度数据类型小数部分只有6位,精度较低;而双精度数据类型小数部分有15位。那么它们在内存中的表现形式什么呢?
单精度浮点数
示例代码:
int main(int argc, int argv)
{
float f = 3.75f;
printf("%f", f);
return 0;
}
观察其反汇编:
.text:00411F65 movss xmm0, ds:dword_417B30
.text:00411F6D movss [ebp+f], xmm0
.text:00411F72 cvtss2sd xmm0, [ebp+f]
通过浮点操作指令,读取DWORD
大小的数据存储到变量f
中,可以验证其所占内存大小为4个字节。然后在动态调试器的内存窗口,观察变量f
所在的内存单元的数据:
转换顺序,可以得到40700000h
。这个值是怎么来的呢?这就需要引入IEEE
浮点数编码格式了。对于实数,有定点编码法和浮点编码法,C++选择的就是浮点编码法,也就是IEEE
浮点数编码格式了。IEEE
浮点数编码格式简单来说,就是对一个二进制数使用科学计数法来表示一个实数,它的格式如下:
bit 31 | bit 30 - bit 23 | bit22 - bit 0 |
---|---|---|
符号域 | 指数域 | 数据域(小数域) |
那么IEEE
浮点数编码格式的设计思路是什么呢?科学计数法要求有底数和指数两个部分组成的,而在内存中,数据是以二进制的形式进行存储的,那么对于一个单精度浮点数,其大小为4个字节,也就是有32个比特位。根据科学计数法的定义,其小数点的位置应该位于高位第一个不为0的数的后面。而在二进制数中,第一个不为0的数一定是1,所以数据中的最高有效位是不需要进行保存的,只需要保存小数部分即可。
实数有正实数和负实数之分,则使用一个比特位来进行表示其是正是负,是正数时,符号位为0;是负数时,符号位为1。
由于科学计数法需要小数点能够左右移动,即:指数部分可以为正数和负数两种可能。则需要在二进制中表示出左右移动的两种情况。在二进制中没有负数,那么如何处理指数域部分呢?使用平移零点坐标来进行处理。对于单精度浮点数,它的指数部分是使用的8个比特位,所以可以将零点移动到127的位置,这样就可以做到左右平衡:如果指数部分与127相加后得到的结果,小于127的是负数,大于127的是正数,等于127的是0。为什么平衡了呢?因为[-128, 1]
共有128个数,[0, 127]
也是128个数,故数轴是对称的。
理解了IEEE
的单精度浮点数的编码格式后,回头看看3.75f
是如何变为上面的4字节的数的:
- 将
3.75f
转为二进制的格式:11.11
- 确定符号位。由于其值是一个正数,所以符号位应该为0
- 获取指数部分。$11.11 = 1.111 \times 2^1$,可以得到指数需要向右移动1位,所以指数部分应该为:$127 + 1 = 128$,转换为二进制为:
1000,0000
- 确定数据位,也就是确定小数部分。小数部分为
0.111
,小数部分占3位,不足IEEE
规定的23位,所以后面直接补0即可 - 将结果按照小端方式存储,得到最终的结果
还原时,只需要按照上述步骤进行还原即可,唯一需要注意的是,需要在还原时添加数据中的最高有效位的那个1.
双精度浮点数
双精度浮点数比单精度的浮点数精度要高,因为其存储大小变为了8个字节,所以利用科学计数法可以存储的范围就越高了。它的格式为:
bit 63 | bit 62 - bit 52 | bit51 - bit 0 |
---|---|---|
符号域 | 指数域 | 数据域(小数域) |
即:
- 符号域占1位
- 指数域占11位
- 数据域占52位
在指数域中,将0点平移到1023(011, 1111, 1111
)位置处,也就是说,使用1023作为0点。
字符类型
示例代码:
int main(int argc, int argv)
{
char ch = (char)argc;
printf("%c", ch);
return 0;
}
查看反汇编结果:
.text:00411F65 mov al, byte ptr [ebp+argc]
.text:00411F68 mov [ebp+varCh], al
可以发现其所占内存大小是1个字节。
对于字符类型的编码格式是ASCII编码格式,将0-127
的数字与ASCII字符进行一一映射,在输出和处理的时候自动进行转换。对于字符类型的还原方式,是需要根据具体情况,查看其对这个内存单元的处理方式或已知函数如何操作给定的函数地址来进行分析判断。
布尔类型
示例代码:
int main(int argc, int argv)
{
bool bVal = (argc > 0);
printf("%d", bVal);
return 0;
}
反汇编结果:
.text:00411F81 mov al, byte ptr [ebp+var_D0]
.text:00411F87 mov [ebp+bVal], al
可以看到,布尔类型所占内存大小是1个字节,而其表现形式是真为1,假为0.
在还原时,需要根据其功能来进行判断到底是字符类型数据还是布尔类型:布尔类型的出现场合都是在做真假判断。有了这个特性就可以还原为其等价代码了。
- 0
-
分享