IDA PRO 08 - 反汇编基础02

526 阅读23分钟

本文主要讨论数组与结构体在汇编中的表现形式。当然如果你对其他的基本单元有兴趣,有一本书一定以可满足你的需要,那就是《逆向工程权威指南》,书分上下两册,里面对各种指令与基本语法的汇编进行了详细的讨论,比如:switch 语句,循环等等。

数组成员的访问

全局分配的数组

如果一个数组在程序的全局数据区内分配(例如,在.data 或.bss 节),编译器在编译时可获知该数组的基址。由于基址固定,编译器可以计算出使用固定索引访问的任何数组元素的固定地址。以下面这个简单的程序为例,它同时使用固定偏移量和可变偏移量访问一个全局数组:

int global_array[3];

int main() {
 int idx = 2;
 global_array[0] = 10;
 global_array[1] = 20;
 global_array[2] = 30;
 global_array[idx] = 40;
}

这个程序的反汇编代码清单为:

.text:00401000 _main proc near
.text:00401000
.text:00401000 idx = dword ptr -4
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004 mov [ebp+idx], 2
.text:0040100B ➊ mov dword_40B720, 10
.text:00401015 ➋ mov dword_40B724, 20
.text:0040101F ➌ mov dword_40B728, 30
.text:00401029 mov eax, [ebp+idx]
.text:0040102C ➍ mov dword_40B720[eax*4], 40
.text:00401037 xor eax, eax
.text:00401039 mov esp, ebp
.text:0040103B pop ebp
.text:0040103C retn
.text:0040103C _main endp

尽管这个程序只有一个全局变量,但➊ 、➋和➌处的反汇编行会给我们一种错觉:它使用了 3 个全局变量。➍处对偏移量的计算(eax×4 )是暗示全局数组dword_40B720 存在的唯一线索。

在这个例子中,有两点需要注意。

第一,使用常量索引访问全局数组时,在对应的反汇编代码清单中,对应的数组元素将以全局变量的形式出现。换句话说,反汇编代码清单基本上不提供任何数组存在的证据。

第二,使用可变索引值,在计算要访问的数组元素的具体地址时,我们需要用数组的基址加上相应的偏移量,这时基址即呈现出来(如➍处所示)。

➍处的计算提供了另外一条有关数组的关键信息:数组中各元素的大小为 4。

栈分配的数组

如果数组是作为栈变量分配的,那访问数组会有何不同呢?凭直觉,我们认为这肯定会有所不同,因为编译器在编译时无法获得绝对地址,而且即使是使用常量索引的访问也必须在运行时进行某种计算。但实际上,编译器几乎以完全相同的方式处理栈分配的数组和全局分配的数组。

以下面这个使用一个小型栈分配的数组的程序为例:

int main() {
 int stack_array[3];
 int idx = 2;
 stack_array[0] = 10;
 stack_array[1] = 20;
 stack_array[2] = 30;
 stack_array[idx] = 40;
}

在编译时,stack_array 的地址未知,因此,编译器无法像在前面的全局数组例子中一样,预先计算出 stack_array[1] 的地址。这个函数的反汇编代码清单如下:

.text:00401000 _main proc near
.text:00401000
.text:00401000 var_10 = dword ptr -10h
.text:00401000 var_C = dword ptr -0Ch
.text:00401000 var_8 = dword ptr -8
.text:00401000 idx = dword ptr -4
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 10h
.text:00401006 mov [ebp+idx], 2
.text:0040100D ➊ mov [ebp+var_10], 10
.text:00401014 ➋ mov [ebp+var_C], 20
.text:0040101B ➌ mov [ebp+var_8], 30
.text:00401022 mov eax, [ebp+idx]
.text:00401025 ➍ mov [ebp+eax*4+var_10], 40
.text:0040102D xor eax, eax
.text:0040102F mov esp, ebp
.text:00401031 pop ebp
.text:00401032 retn
.text:00401032 _main endp

和全局数组例子一样,这个函数似乎也使用了 3 个变量(var_10 、var_C 和var_8 ),而不是一个包含3 个整数的数组。根据➊、➋和➌处使用的常量,我们得知,函数似乎引用的是局部变量,但实际上它引用的是 stack_array 数组的3 个元素,该数组的第一个元素位于 var_10(内存地址最低的局部变量)所在的位置。

➊处的汇编代码对应的代码是:

stack_array[0] = 10;

这就意味着,在栈帧里,编译器将 stack_array 分配到了 ebp-0x10 这个位置。那么,stack_array[1] 的地址为ebp–0x10+4 (可简化为 ebp–0x0C )。

所以,IDA 将这些引用都看成是局部变量。最终,与全局分配的数组类似,使用常量索引值会隐藏有栈分配的数组存在这一事实。

唯有➍处的数组访问表明,var_10 是数组中的第一个元素,而不是一个简单的整数变量。此外,➍处的反汇编代码清单也有助于我们得出结论:数组中各元素的大小为4 字节。

根据栈中 idx 的位置可以推断出,以var_10 开始的数组最多包含3 个元素(否则,它将覆盖 idx )。

结论:编译器处理栈分配的数组和处理全局分配的数组的方式非常类似。

堆分配的数组

堆分配的数组是使用一个动态内存分配函数(如 C 中的mallo c或C++中的new )分配的。从编译器的角度讲,处理堆分配的数组的主要区别在于,它必须根据内存分配函数返回的地址值,生成对数组的所有引用。我们以下面这个函数为例:

int main() {
 int *heap_array = (int*)malloc(3 * sizeof(int));
 int idx = 2;
 heap_array[0] = 10;
 heap_array[1] = 20;
 heap_array[2] = 30;
 heap_array[idx] = 40;
}

反汇编代码如下:

.text:00401000 _main proc near
.text:00401000
.text:00401000 heap_array = dword ptr -8
.text:00401000 idx = dword ptr -4
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 8
.text:00401006 ➎ push 0Ch ; size_t
.text:00401008 call _malloc
.text:0040100D add esp, 4
.text:00401010 mov [ebp+heap_array], eax
.text:00401013 mov [ebp+idx], 2
.text:0040101A mov eax, [ebp+heap_array]
.text:0040101D ➊ mov dword ptr [eax], 10
.text:00401023 mov ecx, [ebp+heap_array]
.text:00401026 ➋ mov dword ptr [ecx+4], 20
.text:0040102D mov edx, [ebp+heap_array]
.text:00401030 ➌ mov dword ptr [edx+8], 30
.text:00401037 mov eax, [ebp+idx]
.text:0040103A mov ecx, [ebp+heap_array]
.text:0040103D ➍ mov dword ptr [ecx+eax*4], 40
.text:00401044 xor eax, eax
.text:00401046 mov esp, ebp
.text:00401048 pop ebp
.text:00401049 retn
.text:00401049 _main endp

数组的起始地址(由 EAX寄存器中的 malloc 返回)存储在局部变量heap_array 中。这个例子与前面两个例子不同,每一次访问数组时,首先必须读取 heap_array 的内容,以获得数组的基址,然后再在它上面加上一个偏移值,计算出数组中对应元素的地址

引用 heap_array[0]、heap_array[1] 和heap_array[2] 需要的偏移量分别为 0、4 和8 字节,如➊、➋和➌处所示。引用heap_array[idx] ➍处的操作与前面的例子最为相似,它在数组中的偏移量通过将数组索引与数组元素大小相乘计算得出。

堆分配的数组有一个非常有用的特点。传递给内存分配函数的参数(0x0C 在➎处传递给了malloc)即表示分配给数组的字节总数,用这个数除以元素大小(本例为4 字节,如➊、➋和➌处的偏移量所示),即可得到数组中元素的个数。

关于数组的使用,我们能够得出的唯一确定的结论是:只有当变量被用作数组的索引时,我们才最容易确定数组的存在。

结构体成员访问

结构体的一个显著特点在于,结构体中的数据字段是通过名称访问,而不是像数组那样通过索引访问。不好的是,字段名称被编译器转换成了数字偏移量。结果,在反汇编代码中,访问结构体字段的方式看起来与使用常量索引访问数组元素的方式极其相似

我们以下面这个结构体为例来分析:

struct ch8_struct { //Size Minimum offset Default offset
 int field1; // 4 0 0
 short field2; // 2 4 4
 char field3; // 1 6 6
 int field4; // 4 7 8
 double field5; // 8 11 16
}; //Minimum total size: 19 Default size: 24

分配结构体需要的空间由结构体中的字段与编译器两者决定。默认情况下,编译器会设法将结构体字段与内存地址对齐,以最有效地读取和写入这些字段。例如,4 字节的整数字段将与能够被 4 整除的偏移量对齐,而 8 字节的双字则与能够被 8 整除的偏移量对齐。根据结构体的构成,满足对齐要求可能需要插入填补字节,使结构体的实际大小大于字段大小的总和。

当然,我们也可以设置强制1字节对齐,也就是不用对齐,此时,编译器会将结构体压缩到最小空间,但是可能会导致CPU异常。

全局分配的结构体

和全局分配的数组一样,编译器在编译时可获知全局分配的结构体的地址。这使得编译器能够在编译时计算出结构体中每个成员的地址,而不必在运行时进行任何计算。以下面这个访问全局分配的结构体的程序为例:

struct ch8_struct global_struct;
 int main() {
 global_struct.field1 = 10;
 global_struct.field2 = 20;
 global_struct.field3 = 30;
 global_struct.field4 = 40;
 global_struct.field5 = 50.0;
}

如果使用默认的结构体对齐选项编译这个程序,在反汇编时,我们得到下面的代码清单:

.text:00401000 _main proc near
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 mov dword_40EA60, 10
.text:0040100D mov word_40EA64, 20
.text:00401016 mov byte_40EA66, 30
.text:0040101D mov dword_40EA68, 40
.text:00401027 fld ds:dbl_40B128
.text:0040102D fstp dbl_40EA70
.text:00401033 xor eax, eax
.text:00401035 pop ebp
.text:00401036 retn
.text:00401036 _main endp

可以看到,在这个反汇编代码清单中,访问结构体成员不需要任何算术计算,如果没有源代码,你根本无法断定这个程序使用了结构体。因为编译器在编译时已经计算出所有的偏移量,这个程序似乎引用的是 5 个全局变量,而不是一个结构体中的 5 个字段。这种情况与前面例子中使用常量索引值的全局分配的数组非常类似。

栈分配的结构体

对前面的程序进行修改,使用一个栈分配的结构体,并在 main 中进行声明,可得到下面的反汇编代码:

.text:00401000 _main proc near
.text:00401000
.text:00401000 var_18 = dword ptr -18h
.text:00401000 var_14 = word ptr -14h
.text:00401000 var_12 = byte ptr -12h
.text:00401000 var_10 = dword ptr -10h
.text:00401000 var_8 = qword ptr -8
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 18h
.text:00401006 mov [ebp+var_18], 10
.text:0040100D mov [ebp+var_14], 20
.text:00401013 mov [ebp+var_12], 30
.text:00401017 mov [ebp+var_10], 40
.text:0040101E fld ds:dbl_40B128
.text:00401024 fstp [ebp+var_8]
.text:00401027 xor eax, eax
.text:00401029 mov esp, ebp
.text:0040102B pop ebp
.text:0040102C retn
.text:0040102C _main endp

同样,访问结构体中的字段不需要进行任何算术计算,因为在编译时,编译器能够确定栈帧内每个字段的相对偏移量。在这种情况下,我们同样会被误导,认为程序使用的是5 个变量,而不是一个包含 5 个字段的结构体。

堆分配的结构体

如果一个结构体在程序堆中分配,那么,在访问其中的字段时,编译器别无选择,只有生成代码来计算每个字段在结构体中的正确偏移量。

再次修改上面的例子,使其使用堆分配的结构体,从而得到下面的反汇编代码清单:

.text:00401000 _main proc near
.text:00401000
.text:00401000 heap_struct = dword ptr -4
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004 ➏ push 24 ; size_t
.text:00401006 call _malloc
.text:0040100B add esp, 4
.text:0040100E mov [ebp+heap_struct], eax
.text:00401011 mov eax, [ebp+heap_struct]
.text:00401014 ➊ mov dword ptr [eax], 10
.text:0040101A mov ecx, [ebp+heap_struct]
.text:0040101D ➋ mov word ptr [ecx+4], 20
.text:00401023 mov edx, [ebp+heap_struct]
.text:00401026 ➌ mov byte ptr [edx+6], 30
.text:0040102A mov eax, [ebp+heap_struct]
.text:0040102D ➍ mov dword ptr [eax+8], 40
.text:00401034 mov ecx, [ebp+heap_struct]
.text:00401037 fld ds:dbl_40B128
.text:0040103D ➎ fstp qword ptr [ecx+10h]
.text:00401040 xor eax, eax
.text:00401042 mov esp, ebp
.text:00401044 pop ebp
.text:00401045 retn
.text:00401045 _main endp

根据➏处malloc 所需的内存数量,我们推断出:结构体的大小为24 字节。

该结构体包含以下字段:

  • 一个4 字节字段(dword ),偏移量为 0(➊);
  • 一个2 字节字段(word ),偏移量为 4(➋);
  • 一个1 字节字段,偏移量为 6(➌);
  • 一个4 字节字段(dword ),偏移量为 8(➍);
  • 一个8 字节字段(qword ),偏移量为 16 (10h)(➎)。

结构体数组

在处理嵌套结构时,前面有关数组和结构体的讨论同样适用。以下面的这个程序为例,它是一个结构体数组,其中的 heap_struct 指向一个包含 5 个ch8_struct 元素的数组:

int main() {
 int idx = 1;
 struct ch8_struct *heap_struct;
 heap_struct = (struct ch8_struct*)malloc(sizeof(struct ch8_struct) * 5);
 ➊ heap_struct[idx].field1 = 10;
}

访问➊处的field1 所需的操作包括:用索引值乘以数组元素的大小(这里为结构体的大小),然后加上 field1 这个字段的偏移量。下面是对应的反汇编代码清单:

.text:00401000 _main proc near
.text:00401000
.text:00401000 idx = dword ptr -8
.text:00401000 heap_struct = dword ptr -4
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 8
.text:00401006 mov [ebp+idx], 1
.text:0040100D ➋ push 120 ; size_t
.text:0040100F call _malloc
.text:00401014 add esp, 4
.text:00401017 mov [ebp+heap_struct], eax
.text:0040101A mov eax, [ebp+idx]
.text:0040101D ➌ imul eax, 24
.text:00401020 mov ecx, [ebp+heap_struct]
.text:00401023 ➍ mov dword ptr [ecx+eax], 10
.text:0040102A xor eax, eax
.text:0040102C mov esp, ebp
.text:0040102E pop ebp
.text:0040102F retn
.text:0040102F _main endp

从代码清单中可以看出:堆请求了 120 个字节(➋),数组索引乘以 24(➌),然后加上数组的起始地址(➍ )。我们可以推断出数组的元素大小(24),数组中元素的个数(120/24=5 );同时,在每个数组元素中偏移量为0 的位置,有一个 4 字节的字段。至于每个结构体中剩余的 20 个字节是如何分配给其他字段的,这个简短的列表并没有提供足够的信息。

未标题-1.jpg

创建结构体

当我们在逆向时,发现了一个结构体之后,我们就可以创建一个结构体,这样会使我们的伪代码程序更清晰。拿上面的程序举例:

struct ch8_struct
{       // Size Minimum offset Default offset
 int field1;    // 4 0 0
 short field2;  // 2 4 4
 char field3;   // 1 6 6
 int field4;    // 4 7 8
 double field5; // 8 11 16
};       // Minimum total size: 19 Default size: 24

void print(struct ch8_struct* ch8) {

}

int main()
{
 struct ch8_struct ch8_struct;
 ch8_struct.field1 = 10;
 ch8_struct.field2 = 20;
 ch8_struct.field3 = 30;
 ch8_struct.field4 = 40;
 ch8_struct.field5 = 50.0;

 print(&ch8_struct);

 return 0;
}

编译之后,main 方法处的汇编代码如下:

; Attributes: bp-based frame

; int __cdecl main(int argc, const char **argv, const char **envp)
public main
main proc near

var_20= dword ptr -20h
var_1C= word ptr -1Ch
var_1A= byte ptr -1Ah
var_18= dword ptr -18h
var_10= qword ptr -10h

push    rbp
mov     rbp, rsp
sub     rsp, 40h
call    __main
mov     [rbp+var_20], 0Ah
mov     [rbp+var_1C], 14h
mov     [rbp+var_1A], 1Eh
mov     [rbp+var_18], 28h ; '('
movsd   xmm0, cs:qword_140004000
movsd   [rbp+var_10], xmm0
lea     rax, [rbp+var_20]
mov     rcx, rax
call    print
mov     eax, 0
add     rsp, 40h
pop     rbp
retn
main endp

其反汇编代码如下:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v4; // [rsp+20h] [rbp-20h] BYREF
  __int16 v5; // [rsp+24h] [rbp-1Ch]
  char v6; // [rsp+26h] [rbp-1Ah]
  int v7; // [rsp+28h] [rbp-18h]
  __int64 v8; // [rsp+30h] [rbp-10h]

  _main();
  v4 = 10;
  v5 = 20;
  v6 = 30;
  v7 = 40;
  v8 = 0x4049000000000000i64;
  print(&v4);
  return 0;
}

IDA 使用Structures窗口(如图8-2 所示)来创建新的结构体。我们切到此窗口:

00000000 ; Ins/Del : create/delete structure
00000000 ; D/A/*   : create structure member (data/ascii/array)
00000000 ; N       : rename structure or structure member
00000000 ; U       : delete structure member
00000000 ; [00000030 BYTES. COLLAPSED STRUCT MEMORY_BASIC_INFORMATION. PRESS CTRL-NUMPAD+ TO EXPAND]
00000000 ; [00000028 BYTES. COLLAPSED STRUCT _exception. PRESS CTRL-NUMPAD+ TO EXPAND]
00000000 ; [00000004 BYTES. COLLAPSED STRUCT _startupinfo. PRESS CTRL-NUMPAD+ TO EXPAND]
00000000 ; [00000028 BYTES. COLLAPSED STRUCT IMAGE_TLS_DIRECTORY. PRESS CTRL-NUMPAD+ TO EXPAND]
00000000 ; [00000028 BYTES. COLLAPSED STRUCT CRITICAL_SECTION. PRESS CTRL-NUMPAD+ TO EXPAND]
00000000 ; [0000000C BYTES. COLLAPSED STRUCT RUNTIME_FUNCTION. PRESS CTRL-NUMPAD+ TO EXPAND]
00000000 ; [00000004 BYTES. COLLAPSED STRUCT UNWIND_INFO_HDR. PRESS CTRL-NUMPAD+ TO EXPAND]
00000000 ; [00000002 BYTES. COLLAPSED STRUCT UNWIND_CODE. PRESS CTRL-NUMPAD+ TO EXPAND]
00000000 ; [00000010 BYTES. COLLAPSED STRUCT C_SCOPE_TABLE. PRESS CTRL-NUMPAD+ TO EXPAND]

想要IDA识别一个结构体,那么该结构体必须在 Structures窗口中列出来。

Structures窗口的前4 行文本用于提醒用户该窗口中可能进行的操作。我们使用的主要操作包括添加、删除和编辑结构体。

添加结构体使用热键INSERT 启动,它打开如图8-3 所示的Create structure/union(创建结构体/联合)对话框:

为了创建一个新的结构体,首先在 Structure name(结构体名称)字段中指定结构体的名称。

前两个复选框用于决定新结构体在 Structures窗口中的显示位置,或者是否在窗口中显示新结构体。第三个复选框Creat union (创建联合)。

Add standard structure(添加标准结构体)按钮用于访问IDA 当前能够识别的全部结构体数据类型。这个按钮的作用暂不讨论。

为了给新结构体添加字段,可以使用字段创建命令 D、A 和数字键盘上的星号键(*)。建议采用下面的步骤给结构体添加字段:

  1. 要给结构体添加新字段,将光标放在结构体定义的最后一行(包含ends 的那一行)并按下D 键。这时,IDA 就会在结构体的末尾添加一个新字段。字段的名称为 field_N ,这里的N 为结构体开头到新字段(如field_0 )开头的数字偏移量。

  1. 如果需要修改字段的大小,首先将光标放在新字段的名称上,然后重复按下 D 键,另外,你还可以使用Options → Setup Data Types来指定一个在数据转盘上不存在的大小。如果新字段是一个数组,右击其名称并在上下文菜单中选择 Array,将打开“数组规范”对话框。
  2. 要更改一个结构体字段的名称,单击字段名称并按下 N 键,或者右击该名称并在上下文菜单中选择 ReName,然后在输入框中输入一个名称即可。

  • 可以给结构体字段添加注释,就像给任何反汇编行添加注释一样。
  • 与Structures窗口顶部的说明不同的是,只有当一个字段是结构体中的最后一个字段时,使用U 键才能删除该字段。对于所有其他字段,按下 U 键将取消该字段的定义,这样做仅仅删除了该字段的名称,并没有删除分配给该字段的字节。
  • 必须手动对结构体定义中的所有字段进行适当的对齐。
  • 分配到结构体中间的字节只有在取消关联字段的定义后才能删除。使用Edit → Shrink Struct Type (缩小结构体类型)即可删除被取消定义的字节。
  • 可以在结构体的中间添加新的字节:选择新字节后面的一个字段,然后使用Edit → Expand Struct Type (扩大结构体类型)在选中的字段前插入一定数量的字节。
  • 如果知道结构体的大小,而不了解它的布局,你需要创建两个字段。第一个字段为一个数组,它的大小为结构体的大小减去 1 个字节(size-1 );第二个字段应为 1 个字节。创建第二个字段后,取消第一个(数组)字段的定义。这样,结构体的大小被保留下来,随后,当你进一步了解该结构体的布局后,你可以回过头来定义它的字段及其大小。

重复这些操作,我们就可以定义出源码的结构体:

上图我们手动填充了2个padding,使用 U 键解除定义:

觉得结构体定义在 Structures窗口中占用了太多空间,你可以选择结构体中的任何字段并按下数字键盘中的减号键,将结构体的定义折叠成一行摘要。

你可能已经注意到,结构体定义看起来与函数的详细栈帧视图有些类似。这并非巧合,因为在IDA 内部,IDA 处理它们的方式完全相同。它们都属于相邻的内存块,能够细分成若干已命名字段,并且每个字段都拥有一个数字偏移量。它们之间的细微区别在于,栈帧以一个帧指针或返回地址为中心,同时使用正值和负值字段偏移量,而结构体仅使用正值偏移量(以结构体开头位置为起始点)。

使用结构体

有两种方法可对反汇编代码清单中的结构体定义加以利用。

找到 main 的汇编代码,双击其变量,进入栈视图:

使用Edit → Struct Var (ALT+Q)命令显示一组已知的结构体:

选择,然后点击 ok,可将栈中对应的字节数组合成对应的结构体类型,并将所有相关内存引用重新格式化成结构体引用。

再看我们的汇编代码:

这样就非常好看了。在反汇编代码清单中使用结构体表示法的好处在于,它从总体上提高了反汇编代码清单的可读性。在重新格式化后的窗口中使用字段名称,能够更加准确地反映源代码是如何操纵数据的。

ida 将一个 double 类型识别成了一个 long  类型,还是挺有意思的。