在C/C++程序的逆向分析中,结构体的逆向分析是非常重要的。结构体是C/C++中管理和组织数据的一种主要方式。了解它们的布局可以帮助你理解程序是如何存储、处理和传递数据的,结构体常用于组织和管理复杂的数据,理解结构体有助于我们更好地还原程序的逻辑、推断数据布局,并解读与操作这些数据的函数。因此,识别并分析结构体可以帮助你更全面地理解程序的内存布局和数据流。接着我们可以来简单说一下如何识别结构体。
分析结构体内存访问模式
程序中对内存的访问偏移往往可以透露结构体的布局。我们可以通过以下几点来进行观察:
①连续的偏移访问
:当看到对一个地址的多次访问,每次访问的偏移不同(如 mov eax, [ebp-4]
后有 mov ecx, [eax+8]
),这可能表明该地址处是一个结构体。
②结构化的读取或写入
:如果某个地址以特定偏移的方式反复读取或写入,通常意味着这是一个结构体或数组。
从汇编的角度,数组是一种简单的、线性连续存储的同类型元素集合,访问方式直接、计算简单。结构体则是一个包含不同类型成员的复杂数据类型,访问时需要考虑填充字节和成员的具体偏移,访问方式相对复杂(内存对齐相关文章可以看笔者前面的文章《C-结构体对齐》)。理解这些区别有助于更好地进行逆向分析和性能优化;数组和结构体的区别可以通过动态分析观察内存结构从而进一步识别。
推测成员类型
在大概分析完内存布局的基础上,再通过观察程序中访问结构体成员的方式,可以大概区分结构体和数组的区别以及推测成员的类型:
①整数或指针:如果使用32位或64位指令(如 mov),并且偏移量是4或8的倍数,这通常意味着访问的是整数或指针。 ②字符串或字符数组:当使用指针偏移方式(如 mov eax, [ebx+4] 后的 mov byte ptr [eax], 'A')并且访问的是一个字节时,可能是字符或字符数组。 ③浮点数或大整数:如果程序使用 fld、fstp 这样的浮点指令,可能意味着正在处理浮点数或结构体中的浮点类型成员。
分析实例
下面是一个简单的 C 语言程序,它包含一个结构体 Person
,并通过这个结构体存储并打印个人信息。你可以用它来练习逆向分析结构体的过程。
#include <stdio.h>
#include <string.h>
// 定义一个结构体 Person
struct Person {
int age; // 年龄
float height; // 身高(米)
char name[20]; // 姓名
};
void printPersonInfo(struct Person *p) {
printf("Name: %s\n", p->name);
printf("Age: %d\n", p->age);
printf("Height: %.2f m\n", p->height);
}
int main() {
struct Person person1;
// 初始化结构体成员
person1.age = 25;
person1.height = 1.75;
strcpy(person1.name, "Alice");
// 打印结构体信息
printPersonInfo(&person1);
return 0;
}
在这个程序中,Person
结构体包含了三个成员变量:
-
int age
:年龄,占用4字节。 -
float height
:身高,占用4字节。 -
char name[20]
:姓名,占用20字节。
接着在主函数中声明一个结构体变量person1
,用于存储一个人的信息。接着就是对该结构体变量中的成员函数进行赋值初始化,最后通过自定义函数printPersonInfo
打印该结构体中所有成员函数的内容。接着在VS中对程序进行编译,生成exe
程序后,放入IDA
中进行静态分析,查看反汇编代码中结构体的特征,得到代码如下:
mov [ebp+var_24], 19h
movss xmm0, ds:__real@3fe00000
movss [ebp+var_20], xmm0
push offset Source ; "Alice"
lea eax, [ebp+Dest]
push eax ; Dest
call j__strcpy
add esp, 8
lea eax, [ebp+var_24]
push eax ; struct Person *
call j_?printPersonInfo@@YAXPAUPerson@@@Z ; printPersonInfo(Person *)
add esp, 4
mov [ebp+var_24], 19h
首先将值 0x19
(十六进制19,即十进制的25)存储到 [ebp+var_24]
处。
movss xmm0, ds:__real@3fe00000
movss [ebp+var_20], xmm0
movss xmm0, ds:__real@3fe00000
将全局地址 __real@3fe00000
对应的浮点数(即 1.75
)加载到寄存器 xmm0
中。movss [ebp+var_20], xmm0
将 xmm0
中的值(1.75)存储到 [ebp+var_20]
中。
接着,设置name
成员,相关代码:
push offset Source ; "Alice"
lea eax, [ebp+Dest]
push eax ; Dest
call j__strcpy
add esp, 8
push offset Source
:将字符串 "Alice"
的地址推入堆栈;ea eax, [ebp+Dest]
:加载 Dest
的地址到 eax
中。Dest
在这里表示目标字符数组的起始地址,push eax
:将 Dest
的地址推入堆栈;call j__strcpy
:调用 strcpy
函数来将 "Alice"
复制到 Dest
中,add esp, 8
:恢复堆栈指针。
最后调用 printPersonInfo
函数:
lea eax, [ebp+var_24]
push eax ; struct Person *
call j_?printPersonInfo@@YAXPAUPerson@@@Z ; printPersonInfo(Person *)
add esp, 4
lea eax, [ebp+var_24]
:将 person1
结构体的基地址(即 var_24
的起始地址)加载到 eax
中。push eax
:将 person1
的地址推入堆栈,作为参数传递给 printPersonInfo
函数。call j_?printPersonInfo@@YAXPAUPerson@@@Z
:调用 printPersonInfo
函数,并传递结构体 person1
的指针。add esp, 4
:恢复堆栈指针。
接着我们将程序放入x86dbg
中进行解析,观察其特征;由于该程序在进行main函数定位时与此前其他的程序特征不一样,这边简单记录一下另外一种定位main
函数的手法。程序载入后,发现此时模块位ntdll.dll
,
直接按下F9
跳转模块至程序对应的模块中。
接着一直按着F8
,直到程序静止不动,且通过程序的输出可以断定此时程序已经运行完毕。
在这边我们记录下程序最后call
的指定地址(此处为006F1CC5
)。接着点击重新运行按钮;
按下ctrl + G
进行目的地址跳转。
定位到对应指令后按下F2
下断点,接着按下F9
将程序运行至此处。
按下F7
进入函数中。
再按下F8
则成功定位至main
函数。
定位到main
函数后接着回归正题,查看结构体特征。
上面这块是函数初始化的操作,不是本文重点,所以此不再过多赘述(相关内容可以查看笔者之前的文章《函数逆向分析-总体流程(整型&指针)》)接着往下看;
后续这串代码就是结构体的初始化相关代码了,在这边我们关注一下内存中结构体的内容。
在我们运行完上述代码后,可以看到结构体的成员数据就被放在了一个连续的内存块中(与数组有点像),但是结构体中成员变量的数据类型并不完全一致,并且一些结构体有很明显的为了数据对齐而进行的CC
填充特征,所以区分数组和结构体并不困难。结构体作为参数传入函数中,与数组一样,也是传入结构体的首地址。
lea eax,dword ptr ss:[ebp-24]
push eax
call structre.6ED7E2
最后来看一下结构体的成员数据寻址相关代码,以下是第一个成员数据的读取代码:
ebp+8
地址存储着结构体首地址,在这个基础上再加8就得到了结构体第三个成员变量的值,也就是Alice
。
第二个参数的寻址则直接传入ebp+8
地址存储着结构体首地址,获得到第一个成员变量(年龄:19)的首地址。
第三个传入的参数为身高:1.78
,对应代码如下:
代码先将ebp+8
中存储的结构体的首地址拿出放入eax
中,接着从 [eax+4]
地址(结构体中第二个元素的地址)读取一个 32 位单精度浮点数(即结构体中偏移为 4 字节处的浮点数),并将其转换为 64 位双精度浮点数,结果存储在 xmm0
寄存器中。
cvtss2sd 指令的作用是将一个 32 位的单精度浮点数(Single Precision Float)转换为 64 位的双精度浮点数(Double Precision Float)。
sub esp, 8
:这条指令将 esp
指针向下移动 8 字节,预留出一个双精度浮点数的空间。最后将 xmm0
中的双精度浮点数存储到栈上,位置由 esp
指针指定。最后就是传入格式化字符串(Height:%.2f\n
),调用printf
函数进行打印。
可以看到,实际上汇编代码中,结构体的各成员通常与数组元素寻址一样也是通过 基址寄存器 + 偏移量 的方式访问的。在这篇文章中,我们深入探讨了结构体在逆向工程中的寻址方式和结构推断方法。结构体的逆向分析不仅需要对汇编代码的深入理解,更需要细致观察和不断验证。掌握这些技巧,可以帮助我们更准确地还原程序的数据结构,为后续的逆向分析工作打下坚实的基础。