Windows 逆向-结构体

Oyst3r 于 2023-12-24 发布

前言

这节的核心思想就是把结构体看成一个数据类型,就和 int,char 一样,其实和数组最像,不过它可以去存一些不同类型的数值,这就是它特别之处

课堂

引出结构体

当需要一个容器,既需要存储 1 个字节的,2 个字节的,10 个字节的,那咋办呢,数组中只能存储相同类型的数据,比如都是 int 型或 char 型等。但是现在用一个容器存储不同数据类型的数据,此时就要引出另一个数据类型:结构体

struct AA{	      //struct是一个关键字 AA是用户自己定义的一个名字
    //可以定义多种类型
	int a;
	char b;
	short c;
    ....
};

基址和偏移

基址:一般都是全局变量,因为全局变量在程序编译时就分配了指定的内存空间,所以地址是相对确定的。我们可以通过找到基址来得到其他数据或者结构的地址。比如上述如果定义了结构体类型的变量AA x,那么 x 就表示一个基址

偏移:以基址作为出发点,向后数多少个地址。比如上述 x 是基址,那么x + 4就是 AA 结构体中第一个变量 a;x + 5就是 AA 中的 b 变量等

二级偏移:这里给个海东老师课上的例子

struct Point{    //定义一个结构体类型起名为Point,这个Point容器中存储了3个float类型的变量
	float x;
    float y;
    float z;  //坐标
};

struct AA{
    int life;  //生命
    int magic; //魔法
    int skill; //技能
    Point Der; //坐标

    float speed; //移动速度
    char name[20];  //20字符,10中文
};

此时 AA 的地址为基址,用 AA 加偏移量得到结构中的变量内存存储地址,比如:

因为 AA + 0x10 得到的又是一个基地址,Point 结构体,所以此时可以根据 Point 的基址再偏移得到 Point 结构体中的变量

结构体的赋值和使用

注意结构体定义和使用时是否分配内存以及何时分配内存!(小提示:注意定义的位置是全局变量还是局部变量),下面这个例子就展示了它在全局变量和局部变量的两种情况

struct st1{	   //此时只是告诉编译器我定义了一个结构体类型,叫st1,此时不给st1和其中的数据分配内存
	int a;
	int b;
};
struct st2{	   //此时不分配内存空间
	char a;
	short b;
	int arr[10];
	st1 s;
};

int m = 2;   //全局变量在编译后就分配了内存空间
st2 sss;  //这是定义了一个st2类型的变量sss,但是是全局变量,所以在编译时就给st2类型的sss分配了固定的内				存空间,当中成员默认初始值为0

void Funtion(){		//只有当Function函数被调用时,才会给当中的局部变量分配内存空间!
	st2 s2;     //调用后就给st2结构体类型的局部变量s2分配了内存,至于分配多少内存后面学习
    			//注意st1和st2只是结构体类型,但是真正使用时要定义st1或者st2类型的变量,用变量.使用

	s2.a = 'A';   //给结构体中变量赋值
	s2.b = 12;
	s2.arr[0] = 1;
	s2.arr[1] = 2;
	s2.arr[3] = 3;
	s2.s.a = 100;   //给st2结构体中的变量st1结构体中的a变量赋值
	s2.s.b = 200;

	printf("%d\n",s2.s.a);  //上面赋值了再读取,不然就是0
}

结构体分配内存

作为全局变量分配内存

#include "stdafx.h"
struct st2{
	char a;
	short b;
	int arr[10];
};
st2 s1;  //全局变量
void Func(){
    s1.a = 1;    //在这里设置断点
    s1.b = 2.2;
    s1.arr[0] = 3;
    s1.arr[3] = 4;
    printf("%d %d",s1.a,s1.arr[3]);
}
void main(int argc,char* argv[]){
	Func();
}

查看反汇编,发现确实是使用基址加偏移量的方式查找的结构体中的数据,而是可以看到 st2 类型的变量 s1 中的数据的地址是固定的,因为结构体类型变量 s1 是全局变量,在编译时就分配了固定的内存空间。且地址是连续但不等宽的。那么就表示给全局变量赋值,就是直接在给全局变量–st2 类型的 s1 分配的内存中修改数据

作为局部变量分配内存

这里注意一点,就是之前最开始说的在函数里面分配变量,看给 0x40h 加多少的问题由于本机尺寸的问题,统一是分 4 个字节,然后引出了数组,数组分的字节虽然是按数据宽度来说的,但在一些情况下还是要遵循本机尺寸的问题,同样的结构体也是要遵循的,这个问题叫做结构体对齐,会在下篇文章中具体讲

同样的给出个示例代码

#include "stdafx.h"
struct st2{
	char a;
	short b;
	int arr[10];
};
void Func(){
	st2 s1;   //局部变量
    s1.a = 1;
    s1.b = 2.2;
    s1.arr[0] = 3;
    s1.arr[3] = 4;
    printf("%d %d",s1.a,s1.arr[3]);
}
void main(int argc,char* argv[]){
	Func();
}

查看反汇编,发现 s1 在被函数调用时才会动态分配内存空间,且是在缓冲区中,地址不是固定的,而是用[ebp - xxx]来查找的。和局部变量的查找方式类似(而且分配空间是按照变量顺序分配的,但是是从低地址向高地址存储的,和数组类似正着从低地址向高地址存)

但是其实从这里就判断是什么数据类型不准确,或者无法确定,比如当结构体中定义相同类型时,且结构体类型的变量作为局部变量被定义时:会发现和数组分配空间并赋值的方式一模一样,所以无法从这里判断出来

但是要注意结构体类型变量作为局部变量,为不同数据类型分配内存空间大小不再都是 4 字节,而是有一个结构体对齐的概念,后面会学习!这里再提一嘴,如果是把结构体类型的变量作为局部变量被定义,结构体当中定义了多个不同类型的变量,那么计算机不会都使用 4 字节的内存来存储结构体当中的不同数据类型变量。如果结构体中的元素定义的大小顺序不一样,分配的空间不是确定的!

结构体变量做返回值

因为 eax 只能存下 4 字节,而返回值为结构体类型,就是要把结构体当中定义的变量的值返回,eax 明显存不下,而且一旦函数执行完后,肯定要把在函数中对结构体中变量的值做的修改想办法返回出来,因为不返回虽然这些值在堆栈中,但是由于函数调用完成要堆栈平衡,那么函数执行时内存中的值都成了垃圾值。所以结构体变量作为返回值是怎么传出来的呢?

#include "stdafx.h"
struct st1{
	char a;
    short b;
    int c;
    int d;
    int e;
};
st1 Func(){  //重点关注这里返回值
	st1 s1;
	s1.a = 1;
	s1.b = 2;
	s1.c = 3;
    s1.d = 4;
    s1.e = 5;
    return s1;
}
void main(int argc,char* argv[]){
    st1 s = Func();
}

通过汇编代码可以发现,在 call 指令前加了两行指令,因为结构体中还定义了很多类型的变量,光用 eax 和几个寄存器肯定是不够的,那么编译器所做的是,在 main 函数的缓冲区中选一块起始地址[ebp-30h],然后将这个地址作为返回数据存储的起始地址,从这个地址开始往高位地址依次存下去。但是由于 eax 寄存器在进入 Func 函数后要被使用用来存储其他的东西,比如填充 Func 函数缓冲区的时候要用 eax。那么为了不让 eax 中此时存的返回值起始地址丢了,就将它入栈!像参数一样,然后后面进入 Func 函数后通过[ebp + 8]来得到这个值

进入函数后可以发现将结构体变量作为返回值,其实就是将在 Func 函数中给结构体中变量赋的值,赋值一份到 main 函数的缓冲区中,就是在未进入 Func 函数是记录的[ebp -30h]这个地址开始往高地址存,最后再把这个返回值存入的起始地址再存入 eax 中作为返回值,即返回了一个地址

这就是计算机针对这种问题的处理方法,嗯设计的还是很巧妙的,也是挺会变通的,欸做题打比赛一样也是要多变一点哇

结构体作为函数参数

注意一点,结构体在这里是和数组不一样的,我们常常在初学 C 语言的时候,经常会听到这样的话—数组作为参数传递的时候,是传的地址,虽说结构体和数组很像,但在这点完全不同,结构体会把所有的数据都传入到堆栈里面

结构体变量作为参数传递时,不会像我们前面学的普通变量作为参数一样遵循本机尺寸规则,无论传入多少宽度的数据类型参数,都使用本机尺寸的宽度容器来存储(假设是 32 位计算机),那么 int、char、short 类型都使用 32 位寄存器或者内存来存储(传递)。即如果使用__cdecl 调用约定,那么使用 push 指令来存入到堆栈中(倒着存)

结构体里数目较少时

这个其实也是按照结构体对齐完的数据格式进行传的

#include "stdafx.h"
struct st1{
	int x;
	char y;
        short z;
};
void Func(stl s){

	s1.x = 1;
	s1.y = 2;
	s1.z = 3;
}
void main(int argc,char* argv[]){

    st1 s1;
    Func(sl);
}

当 main 函数调用时,st1 s1;:结构体类型的局部变量 s1 就被分配了空间,怎么分配的呢?要看明天的结构体对齐,先不深入。按照上述 st1 中变量定义的顺序,分配如下:[ebp - 8]分配给了 x,[ebp - 4]分配给了 y,[ebp - 2]分配给了 z,没有按照本机尺寸的规则任何数据变量都分 4 字节,然后依次赋值 1,2,3,其实这就是一个小的结构体对齐

接着将结构体变量 s1 作为参数传入 Func 函数,可以看到反汇编中没有三个 push 语句,而是把 y 和 z 合并,使用[ebp - 4]表示的 4 字节内存传递的,所以只有两个 push。结构体作为参数传递和普通变量作为参数传递还是有区别的

结构体里数目较多时

#include "stdafx.h"
struct st1{
	int x;
	char y;
        short z;
	int a;
	int b;
	int c;
	char d;
        short e;
};

void Func(st1 s){ //重点关注这里,结构体作为参数如何传入函数的

}

void main(int argc,char* argv[]){
        st1 s1;
        s1.x = 1;
	s1.y = 2;
	s1.z = 3;
	s1.a = 4;
	s1.b = 5;
	s1.c = 6;
	s1.d = 7;
        s1.e = 8;
	Func(s1);
}

当 main 函数被调用,st1 s1:就会给 s1 分配空间,分配并赋值如下,和上面类似没有区别,都是正着从低地址向高地址存储到 main 函数的缓冲区。且同样遵循结构体对齐的规则,不会任何数据类型变量都分配 4 字节内存空间

但是如果结构体中定义了大量的变量,而此结构体变量在作为参数出传递时,就不再每一个结构体中的变量都使用 push 语句一个一个倒着入栈。而是将栈顶一次性提升 0x18,因为要将 st1 结构体中的 7 个变量入栈,为了效率直接将栈顶提升 0x18 字节,刚好为存放 7 个结构体中的变量做准备。然后使用 rep 循环指令,ecx 决定循环次数,重复执行 7 次 movsd 指令,[esi]最开始表示[ebp - 18h]就表示为结构体中第一个变量分配的内存地址;[edi]就表示此时提升过后的栈顶;那么第一次 movsb 指令会第一个变量 x 的值存入堆栈,然后 esi + 4、edi + 4(注意:这里不是因为满足本机尺寸才 4 个字节 4 个字节传的,而是 movsb 指令的规则就是+4,这个过程只是为了将缓冲区中存放有结构体中变量的值的那些内存按照次序复制压入栈顶而已,可能一次复制的 4 个字节中包含了两个合并使用一个 4 字节内存的变量值,还是要看最开始结构体赋值时是如何分配的。这里如果满足本机尺寸传参那么就应该提升 7 * 0x4 = 0x1C 个字节长度,这样才能保证任何类型变量都用 4 字节内存传递),那么就相当于向下取了一个,[esi + 4]就刚好取到了结构体中第二个变量的内存地址;接着第二次执行 movsb 指令,将 y 值入栈;后面依次将 st1 结构体中的所有变量从低地址到高地址依次入栈,最后一个变量所在内存地址就是原来 mian 堆栈的栈顶 - 4;这个过程饿和 push 没什么区别,只是一次性将 esp 的值提升完了(而提升多少就是看结构体对齐了),然后利用循环一个一个存入堆栈

看了上述结构体变量作为参数传入函数的过程会发现,如果一个结构体中定义了大量的数据,可能还会结构体中套结构体,那么每一次将这个结构体变量作为参数传入函数中时,都有一个将结构体中大量变量的值复制一份入栈的过程,然后函数中如果要使用这些参数,都是使用的复制入栈的值,这样非常不合理!效率低,内存开销大!
那么现在没有学指针前我们最好把结构体类型变量定义成全局变量(这样如果在函数中直接定义该结构体类型变量以及赋值的操作,就直接是对编译时为全局变量分配的内存中的数据操作,而不会再重新开辟一个空间[ebp-x]等。但是如果把结构体类型变量作为参数传递,无论是全局还是局部,都有一个复制的过程,只有后面学了指针,传地址可以解决这个问题)

作业

这个还是很简单的,下面给出我的代码,大家参考

struct loction
{

	int x;
	int y;
	int z;

};

struct Game
{

	char name[10];
	int age;
	float height;
	int level;
	loction myself;

};

Game lol;

Game assignment(){

	lol.name[0] = 'A';
	lol.name[1] = 'k';
	lol.name[2] = 'a';
	lol.name[3] = 'l';
	lol.name[4] = 'i';
	lol.age = 20;
	lol.height = '1.8';
	lol.level = 16;
	lol.myself.x = 7;
	lol.myself.y = 7;
	lol.myself.z = 7;

	return lol;
}

void print_results(Game a){

	int i;
	for(i = 0;i < 4;i++){
		printf("%s",a.name[i]);
	}
	printf("\n");
	printf("%d",a.age);
	printf("%f",a.height);
	printf("%d",a.level);
	printf("%d",a.myself.x);
	printf("%d",a.myself.y);
	printf("%d",a.myself.z);
}