C语言逆向——指针,函数指针,数组指针是比较难理解的,结构体指针要体会其编译器生成汇编代码的本质

指针类型

在C语言里面指针是一种数据类型,是给编译看的,也就是说指针与int、char、数组、结构体是平级的,都是一个类型。

"*"号的变量我们称之为指针类型,例如:

char* x;
short* y;
int* a;
float* b;
...

任何类型都可以带这个符号,格式就是:类型* 名称;星号可以是多个

指针变量的赋值格式如下:

images/download/attachments/12714021/image2021-3-11_17-46-21.png

指针类型的变量宽度永远是4字节,无论类型是什么,无论有几个星号。

指针类型和其他数据类型是一样的,也可以做自增、自减,但与其他数据类型不一样的是,指针类型自增、自减是加、减去掉一个星号后的宽度(注意:函数指针无法+-)

如下代码:

#include <stdio.h>
 
int main()
{
int** a;
char** b;
 
a = (int**)1;
b = (char**)2;
 
a++;
b++;
 
printf("%d %d \n", a, b);
 
return 0;
}

第一次自增,a+4 = 5,b+4 = 6,因为减去一个星号还是指针,指针的宽度永远是4:

images/download/attachments/12714021/image2021-3-11_18-6-47.png

自减同理可得,那么我们知道了自增、自减就可以知道指针类型的加减运算规律:

因为自增、自减,本质上就是+1,所以我们可以得出算式:

指针类型变量 + N = 指针类型变量 + N * (去掉一个*后类型的宽度)

指针类型变量 - N = 指针类型变量 - N * (去掉一个*后类型的宽度)

但需要注意,指针类型无法做乘除运算。

最后:指针类型是可以做比较的。

images/download/attachments/12714021/image2021-3-11_18-19-53.png

&的使用

&符号是取地址符,任何变量都可以使用&来获取地址,但不能用在常量上。

如下代码:

#include <stdio.h>
 
struct Point {
int x;
int y;
};
 
char a;
short b;
int c;
Point p;
 
int main()
{
printf("%x %x %x %x \n", &a, &b, &c, &p);
return 0;
}

images/download/attachments/12714021/image2021-3-11_20-20-12.png

在这里使用取地址符可以直接获取每个变量、结构体的地址,但是这种格式可能跟我们之前看到的8位不一样,前面少了2个0,这时候可以将%x占位符替换为%p来打印显示。

images/download/attachments/12714021/image2021-3-11_20-28-26.png

那么取地址符(&变量)的类型是什么呢?我们可以来探测下:

char a;
short* b;
int** c;
 
int x = &a;
int x = &b;
int x = &c;

以上代码我们在编译的时候是没法成功的,它的报错内容是:

images/download/attachments/12714021/image2021-3-11_20-32-32.png

通过报错内容我们可以看出类型不同无法转换,但是我仔细观察报错内容:char*无法转换成int,short**无法转换成int...那么就说明了一点,在我们使用取地址符时,变量会在其原本的数据类型后加一个星号

可以这样进行指针赋值:

char x;
char* p1;
char** p2;
char*** p3;
char**** p4;
 
p1 = &x; // &x -> char*
p2 = &p1;
p3 = &p2;
p4 = &p3;
 
p1 = (char*)p4;

取值运算符

取值运算符就是我们之前所了解的“*”星号,“*”星号有这几个用途:

  1. 乘法运算符(1*2)

  2. 定义新的类型(char*)

  3. 取值运算符(星号+指针类型的变量);也就是取地址的存储的值。

如下代码就是使用取值运算符:

int* a = (int*)1;
printf("%x \n", *a);

这段代码可以编译,但是是无法运行的,我们可以运行一下然后来看看反汇编代码:

images/download/attachments/12714021/image2021-3-11_20-54-42.png

images/download/attachments/12714021/image2021-3-11_20-55-56.png

如上反汇编代码,我们可以清楚的看见首先0x1给了局部变量(ebp-4),之后这个局部变量(ebp-4)给了eax,而后eax又作为了内存地址去寻找对应存储的值(*local_var的汇编就是这样简单,lea和mov),但是这里eax为0x1,所以在内存中根本就不存在这个地址,也就没办法找到对应的值,自然就无法运行。

那么取值运算符(星号+指针类型)是什么类型呢?我们来探测下:

int*** a;
int**** b;
int***** c;
int* d;
 
int x = *(a);
int x = *(b);
int x = *(c);
int x = *(d);

以上代码我们在编译的时候是没法成功的,它的报错内容是:

images/download/attachments/12714021/image2021-3-11_21-11-11.png

通过报错内容我们可以看出类型不同无法转换,但是我仔细观察报错内容:int**无法转换成int,int***无法转换成int...那么就说明了一点,在我们使用*这个取值运算符时,变量会在其原本的数据类型后减去一个星号

取值运算符举例

int x = 1;
int* p = &x;
int** p2 = &p;
*(p) = 2;
int*** p3 = &p2;
int r = *(*(*(p3)));

数组参数传递

之前我们学过几本类型的参数传递,如下代码所示:

#include <stdio.h>
 
void plus(int p) {
p = p + 1;
}
 
int main()
{
int x = 1;
plus(x);
printf("%d \n", x);
return 0;
}

如上代码变量x最终值是多少?相信很多人都知道答案了,是1,原封不动。

images/download/attachments/12714021/image2021-3-11_21-26-28.png

为什么是1?这是因为在变量x作为参数传入plus函数,是传入值而不是这个变量本身,所以并不会改变变量本身。

数组也是可以作为参数传递,我们想要传入一个数组,然后打印数组的值(定义数组参数时方括号中不要写入常量):

#include <stdio.h>
 
void printArray(int arr[], int aLength) {
for (int i=0; i<aLength; i++) {
printf("%d \n", arr[i]);
}
}
 
int main()
{
int arr[] = {0,1,2,3,4};
printArray(arr, 5);
return 0;
}

我们想打印数组不仅要知道数组是什么,也要获取数组的长度,所以需要两个参数(实际上我们也有其他方法获取长度,这里先不多说)。

images/download/attachments/12714021/image2021-3-11_21-34-18.png

我们来看下反汇编代码,看看数组是否和基本类型一样传入的是值:

images/download/attachments/12714021/image2021-3-11_21-36-47.png

通过如上代码所示,我们可以很清晰的看见,ebp-14也就是数组的第一个值的地址给了eax,最后eax压入堆栈,也就是传递给了函数。

所以我们得出结论:数组参数传递时,传入的是数组第一个值的地址,而不是值;换而言之,我们在printArray函数中修改传入的数组,也就修改了数组本身

我们再换个思路,在数组作为参数传递的时候,可以换一种形式,直接传入地址也是可以打印的,也就是使用指针来操作数组:

images/download/attachments/12714021/image2021-3-11_21-42-23.png

事实证明这是可行的,我们在传入参数的时候使用数组第一个值的地址即可。

指针与字符串

在学习完指针类型后,我们可以来了解一下这些函数:

int strlen(char* s); // 返回类型是字符串s的长度,不包含结束符号\0
char* strcpy(char* dest, char* src); // 复制字符串src到dest中,返回指针为dest的值
char* strcat(char* dest, char* src); // 将字符串src添加到dest尾部,返回指针为dest的值
int strcmp(char* s1, char* s2); // 比较s1和s2,一样则返回0,不一样返回非0

字符串的几种表现形式:

char str[6] = {'A','B','C','D','E','F'};
char str[] = "ABCDE";
char* str = "ABCDE";

指针函数(本质就是函数,只不过函数的返回类型是某一类型的指针):

char* strcpy(char* dest, char* src);
char* strcat(char* dest, char* src);

指针取值的两种方式

如下图所示的则是一级指针(一个星号)和多级指针(多个星号):

images/download/attachments/12714021/image2021-3-11_21-53-1.png

这段代码看着很复杂,但我们有基础后再看它轻而易举,脑子里浮现的就是汇编代码。

指针取值有两种方式,如下代码:

#include <stdio.h>
 
 
int main()
{
int* p = (int*)1;
printf("%d %d", *(p), p[0]);
return 0;
}

我们可以使用取值运算符,也可以使用数组的方式,因为其本质都是一样的,我们来看下反汇编代码

images/download/attachments/12714021/image2021-3-11_21-58-55.png

也就说明:*()与[]的互换,如下是互换的一些例子:

int* p = (int*)1;
printf("%d %d \n",p[0],*p); //p[0] = *(p+0) = *p
 
int** p = (int**)1;
printf("%d %d \n",p[0][0],**p);
printf("%d %d \n",p[1][2],*(*(p+1)+2));
 
int*** p = (int***)1;
printf("%d %d \n",p[1][2][3],*(*(*(p+1)+2)+3));
 
/*
*(*(*(*(*(*(*(p7))))))))
= *(*(*(*(*(*(p7+0)+0)+0)+0)+0)+0)
= p7[0][0][0][0][0][0][0]
*/

总结:

*(p+i) = p[i]
*(*(p+i)+k) = p[i][k]
*(*(*(p+i)+k)+m) = p[i][k][m]
*(*(*(*(*(p+i)+k)+m)+w)+t) = p[i][k][m][w][t]

结构体指针

我们来了解一下结构体指针,如下代码:

#include <stdio.h>
 
struct Point {
int a;
int b;
};
 
int main()
{
Point p;
 
Point* px = &p;
 
printf("%d \n", sizeof(px));
 
return 0;
}

我们打印结构体指针的宽度,最终结果是4,这时候我们需要知道不论你是什么类型的指针,其特性就是我们之前说的指针的特性,并不会改变。

如下代码就是使用结构体指针:

// 创建结构体
Point p;
p.x=10;
p.y=20;
 
// 声明结构体指针
Point* ps;
 
// 为结构体指针赋值
ps = &p;
 
// 通过指针读取数据
printf("%d \n",ps->x);
 
// 通过指针修改数据
ps->y=100;
 
printf("%d\n",ps->y);

提问:结构体指针一定要指向结构体吗?如下代码就是最好的解释:==》这个例子诠释了指针的本质,编译器具有解释权,结构体在存储的时候本质上和多个局部变量没有区别,所以在使用p->x的时候,本质上就是在取局部变量!

#include <stdio.h>
 
struct Point
{
int x;
int y;
};
 
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
 
Point* p = (Point*)arr;
 
int x = p->x;
int y = p->y;
 
printf("%d %d \n", x, y);
return 0;
}

images/download/attachments/12714021/image2021-3-11_22-17-45.png

 

我们实践下,看看本质:

#include <stdio.h>
 
struct Point
{
	int x;
	int y;
};
 
int main()
{	
	Point A;
	A.x = 99;
	A.y = 100;
	
	Point* p2 = &A;
	int x2 = p2->x;
	int y2 = p2->y; 
	printf("%d %d \n", x2, y2);

	int arr[10] = {1,2,3,4,5,6,7,8,9,10};
	Point* p = (Point*)arr; //这个例子让你体会结构体指针精髓
	int x = p->x;
	int y = p->y; 
	printf("%d %d \n", x, y);
	return 0;
}

 

 

 

上面是vc6的编译结果,我们看下vs2022的汇编是不是一样的!

	Point A;
	A.x = 99;
00007FF72149189D  mov         dword ptr [A],63h  
	A.y = 100;
00007FF7214918A4  mov         dword ptr [rbp+0Ch],64h  

	Point* p2 = &A;
00007FF7214918AB  lea         rax,[A]  
00007FF7214918AF  mov         qword ptr [p2],rax  
	int x2 = p2->x;
00007FF7214918B3  mov         rax,qword ptr [p2]  
00007FF7214918B7  mov         eax,dword ptr [rax]  
00007FF7214918B9  mov         dword ptr [x2],eax  
	int y2 = p2->y;
00007FF7214918BC  mov         rax,qword ptr [p2]  
00007FF7214918C0  mov         eax,dword ptr [rax+4]  
00007FF7214918C3  mov         dword ptr [y2],eax  
	printf("%d %d \n", x2, y2);
00007FF7214918C6  mov         r8d,dword ptr [y2]  
00007FF7214918CA  mov         edx,dword ptr [x2]  
00007FF7214918CD  lea         rcx,[string "%d %d \n" (07FF721499CF0h)]  
00007FF7214918D4  call        printf (07FF72149118Bh)  

	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
00007FF7214918D9  mov         dword ptr [arr],1  
00007FF7214918E3  mov         dword ptr [rbp+8Ch],2  
00007FF7214918ED  mov         dword ptr [rbp+90h],3  
00007FF7214918F7  mov         dword ptr [rbp+94h],4  
00007FF721491901  mov         dword ptr [rbp+98h],5  
00007FF72149190B  mov         dword ptr [rbp+9Ch],6  
00007FF721491915  mov         dword ptr [rbp+0A0h],7  
00007FF72149191F  mov         dword ptr [rbp+0A4h],8  
00007FF721491929  mov         dword ptr [rbp+0A8h],9  
00007FF721491933  mov         dword ptr [rbp+0ACh],0Ah  
	Point* p = (Point*)arr; //这个例子让你体会结构体指针精髓
00007FF72149193D  lea         rax,[arr]  
00007FF721491944  mov         qword ptr [p],rax  
	int x = p->x;
00007FF72149194B  mov         rax,qword ptr [p]  
00007FF721491952  mov         eax,dword ptr [rax]  
00007FF721491954  mov         dword ptr [x],eax  
	int y = p->y;
00007FF72149195A  mov         rax,qword ptr [p]  
00007FF721491961  mov         eax,dword ptr [rax+4]  
00007FF721491964  mov         dword ptr [y],eax  
	printf("%d %d \n", x, y);
00007FF72149196A  mov         r8d,dword ptr [y]  
00007FF721491971  mov         edx,dword ptr [x]  
00007FF721491977  lea         rcx,[string "%d %d \n" (07FF721499CF0h)]  
00007FF72149197E  call        printf (07FF72149118Bh)  
	return 0;
00007FF721491983  xor         eax,eax 

 可以看到,vs2022出来的汇编结果本质上没有差异!

 

指针数组与数组指针

指针数组和数组指针,这两个是完全不一样的东西,指针数组的定义:

char* arr[10];
 
Point* arr[10];
 
int********** arr[10];

指针数组的赋值方式:

char* a = "Hello";
char* b = "World";
 
// one
char* arr[2] = {a, b};
 
// two
char* arr1[2];
arr1[0] = a;
arr1[1] = b;
 
// three
char* arr2[2] = {"Hello", "World"};

一共有三种赋值方式,在实际应用中我们更偏向于第三种方式。

结构体指针也有数组,我们可以看下其定义和对应宽度:

images/download/attachments/12714021/image2021-3-11_22-26-54.png

接下来我们要学习的是数组指针,数组指针在实际应用很少用到,数组指针是最难学的。

首先分析一下如下代码:

int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
int* p = &arr[0];
int* p = arr; // &arr[0]==》这个本质差异要注意!!!
int* p = (int *)&arr; // &arr -> int *[10]

&arr就是我们要学的数组指针(注意:arr不是数组指针,&arr才是),也就是int *[10],数组指针的定义如下:

int(*px)[5]; // 一维数组指针
char(*px)[3];
int(*px)[2][2]; // 二维数组指针
char(*px)[3][3][3]; // 三维数组指针

px就是我们随便定义的名字,本质上就是指针,那么也就有着指针的特性,无论是长度还是加减法...

思考:int *p[5] 与 int(*p)[5]有什么区别?我们可以来看看宽度:

images/download/attachments/12714021/image2021-3-11_22-42-13.png

可以看见一个是20,一个是4;一个是指针变量的数组,一个是数组指针,本质是不一样的。

数组指针的宽度和赋值:

int(*px1)p[5];
char(*px2)[3];
int(*px3)[2][2];
char(*px4)[3][3][3];
 
printf("%d %d %d %d\n",sizeof(px1),sizeof(px2),sizeof(px3),sizeof(px4));
// 4 4 4 4
px1 = (int (*)[5])1;
px2 = (char (*)[3])2;
px3 = (int (*)[2][2])3;
px4 = (char (*)[3][3][3])4;

数组指针的运算:

int(*px1)p[5];
char(*px2)[3];
int(*px3)[2][2];
char(*px4)[3][3][3];
 
px1 = (int (*)[5])1;
px2 = (char (*)[3])1;
px3 = (int (*)[2][2])1;
px4 = (char (*)[3][3][3])1;
 
px1++; //int(4) *5 +20 =21
px2++; //char(1) *3 +3 =4
px3++; //int(4) *2 *2 +16 =17
px4++; //char(1) *3 *3 *3 +9 =10
 
printf("%d %d %d %d \n",px1,px2,px3,px4);

数组指针的使用:

// 第一种:
int arr[] = {1,2,3,4,5,6,7,8,9,0};
int(*px)[10] = &arr;
// *px 是啥类型? int[10] 数组类型
// px[0] 等价于 *px 所以 *px 也等于 int[10]数组
printf("%d %d \n",(*px)[0],px[0][0]);
 
px++; // 后 (*px)[0]就访问整个数组地址后的地址内的数据
 
 
// 第二种:
int arr[3][3] = {
{1,2,3},
{4,5,6},
{7,8,9}
};
 
// 此时的 px指针 指向的 {1,2,3}这个数组的首地址
int(*px)[3] = &arr[0];
 
// *px -> 此时就是数组{1,2,3}本身
 
// 越过第一个数组 此时px指针指向 {4,5,6}的首地址
px++;
 
printf("%d %d \n",(*px)[0],px[0][0]);
// 这里打印的就是 4 4

思考:二维数组指针可以访问一维数组吗?==》数组本质认识又来了!!!

int arr[] = {1,2,3,4,5,6,7,8,9,0};
int(*px)[2][2] = (int(*)[2][2])arr;

是可以的,因为*px实际上就是int[2][2],我们之前学过多维数组,int[2][2]也就等于int[4],所以{1,2,3,4}就给了int[2][2],也就是{{1,2}, {3,4}},所以(*px)[1][1]为4。

images/download/attachments/12714021/image2021-3-11_22-56-38.png

 

实验下:

#include <stdio.h>

int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
//	int x = arr;
//	int y = &arr;
	printf("%p %p", arr, &arr);
	int (*px)[2][2] = (int(*)[2][2])arr;
	return 0;
}

 

所以,你实际上看到arr[0]这个东西,和一个普通的局部变量[ebp-28h]没有区别,编译器内部在看arr的时候,应该就是当做一个数组名而已了。

 

 

 

调用约定

函数调用约定就是告诉编译器怎么传递参数,怎么传递返回值,怎么平衡堆栈。

常见的几种调用约定:

调用约定

参数压栈顺序

平衡堆栈

__cdecl

从右至左入栈

调用者清理栈

__stdcall

从右至左入栈

自身清理堆栈

__fastcall

ECX/EDX 传送前两个,剩下:从右至左入栈

自身清理堆栈

一般情况下自带库默认使用 __stdcall,我们写的代码默认使用 __cdecl,更换调用约定就是在函数名称前面加上关键词:

int __stdcall method(int x,int y)
{
return x+y;
}
 
method(1,2);

函数指针

函数指针变量定义的格式:

// 返回类型 (调用约定 *变量名)(参数列表);
 
int (__cdecl *pFun)(int,int);

函数指针类型变量的赋值与使用:

// 定义函数指针变量
int (__cdecl *pFun)(int,int);
// 为函数指针变量赋值
pFun = (int (__cdecl *)(int,int))10;
 
// 使用函数指针变量
int r = pFun(1,2);

我们来看下函数指针的反汇编代码:

images/download/attachments/12714021/image2021-3-11_23-5-37.png

可以很清晰的看见函数指针生成来一堆汇编代码,传参、调用、以及如何平衡堆栈;而如上这段代码最终会调用地址0xA,但它本身不存在,所以无法运行。

所以我们想要调用某个函数时可以将地址赋值给pFun即可,并且在定义时写好对应的参数列表即可;也就是说函数指针通常用来使用别人写好的函数。

我们也通过函数指针绕过调试器断点,假设有攻击者要破解你的程序,它在MessageBox下断点,你正常的代码就会被成功断点,但是如果你使用函数指针的方式就可以绕过。

首先,我们先写一段正常的MessageBox程序,然后使用DTDebug来下断点:

images/download/attachments/12714021/image2021-3-11_23-29-48.png

可以看见,我们下断点成功断下来了,断点本质上就是在MOV EDI, EDI所在那行的地址下断点,那么我们可以直接跳过这行调用调用下一行,实际上这段汇编的核心在于我标记的部分,准确一点的说就是CALL指令哪一行,我们可以右键Follo跟进:

images/download/attachments/12714021/image2021-3-11_23-49-11.png

images/download/attachments/12714021/image2021-3-11_23-50-10.png

我们可以从0x77D5057D开始执行,通过汇编代码可以知道这个函数需要5个参数,并且最后的RETN则表示内平栈则使用__stdcall,所以我们函数指针(操作系统API返回通常是4字节)可以这样写

#include <windows.h>
 
int main()
{
int (__stdcall *pFun)(int,int,int,int,int);
pFun = (int (__stdcall *)(int,int,int,int,int))0x77D5057D;
 
MessageBox(0,0,0,0);
 
pFun(0,0,0,0,0);
 
return 0;
}

这样就可以绕过断点了:???没有明白

images/download/attachments/12714021/image2021-3-11_23-52-19.png

 

实践,我们来做一个隐藏代码到数据区的例子:

 

Function的汇编:

 

 

取出code bytes(二进制op code):

 

 将上述op code放入数组:

 

 

然后修改代码:

 

 

 

最后效果:

依然可以正常求和!!!

 

是不是发现一个本质,那就是数据和代码本质上没有区别!!!

下面贴一些代码:

隐藏代码到数据区的“骚”操作

在我们逆向学习的过程中,我们知道有些程序是被加了“壳”的,为逆向分析带来了许多困难,而这里的隐藏代码到数据区,如果代码较多,也能为逆向分析带来些许困难,实现对函数的“加密”。
接下我们就来了解一下将代码隐藏到数据区的“骚”操作:
首先来看看没有隐藏的源代码:

#include<stdio.h>
int add(int a,int b);
int main(int argc, char* argv[])
{
	printf("%d",add(3,4));
	return 0;
}
int add(int a,int b){
	return a+b;
}

我们来到反汇编:

55                   push        ebp
8B EC                mov         ebp,esp
83 EC 40             sub         esp,40h
53                   push        ebx
56                   push        esi
57                   push        edi
8D 7D C0             lea         edi,[ebp-40h]
B9 10 00 00 00       mov         ecx,10h
B8 CC CC CC CC       mov         eax,0CCCCCCCCh
F3 AB                rep stos    dword ptr [edi]
8B 45 08             mov         eax,dword ptr [ebp+8]
03 45 0C             add         eax,dword ptr [ebp+0Ch]
5F                   pop         edi
5E                   pop         esi
5B                   pop         ebx
8B E5                mov         esp,ebp
5D                   pop         ebp
C3                   ret

这里是add函数的反汇编代码和硬编码,我们将这里的硬编码放入一个数组中:

#include <stdio.h>
int main(int argc, char* argv[])
{
	char add[] = {
	0X55,0X8B, 0XEC,0X83, 0XEC, 0X40, 0X53,0X56,0X57,0X8D, 0X7D, 0XC0,0XB9, 0X10, 0X00, 0X00, 0X00,0XB8, 0XCC, 0XCC, 0XCC, 0XCC,0XF3, 0XAB,0X8B, 0X45, 0X08,0X03, 0X45, 0X0C,0X5F,0X5E,0X5B,0X8B, 0XE5,0X5D,0XC3,             
};
    int (*sub)(int,int);
    sub = (int (__cdecl *)(int,int))&add;
    printf("%d\n",sub(3,4));
    return 0;
}

 

 

 

这样隐藏后,在反汇编窗口就无法直接找到add函数了,必须通过数组地址,找到硬编码,再将会硬编码转换至汇编进行分析,在非常庞大的数据量下寻找硬编码也是比较困难的,这样就能做到对代码的很好的隐藏(在计算机看来,代码和数据并无两样,计算机只负责存储)。
通过编译运行我们能够在程序终端看到仍然输出7,可以确定将代码隐藏进了数据区,这里实际上是将硬编码存储进了数据区。

 
posted @ 2023-04-03 23:10  bonelee  阅读(174)  评论(0编辑  收藏  举报