C语言逆向——指针,函数指针,数组指针是比较难理解的,结构体指针要体会其编译器生成汇编代码的本质
指针类型
在C语言里面指针是一种数据类型,是给编译看的,也就是说指针与int、char、数组、结构体是平级的,都是一个类型。
带"*"号的变量我们称之为指针类型,例如:
char
* x;
short
* y;
int
* a;
float
* b;
...
任何类型都可以带这个符号,格式就是:类型* 名称;星号可以是多个。
指针变量的赋值格式如下:
指针类型的变量宽度永远是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:
自减同理可得,那么我们知道了自增、自减就可以知道指针类型的加减运算规律:
因为自增、自减,本质上就是+1,所以我们可以得出算式:
指针类型变量 + N = 指针类型变量 + N * (去掉一个*后类型的宽度)
指针类型变量 - N = 指针类型变量 - N * (去掉一个*后类型的宽度)
但需要注意,指针类型无法做乘除运算。
最后:指针类型是可以做比较的。
&的使用
&符号是取地址符,任何变量都可以使用&来获取地址,但不能用在常量上。
如下代码:
#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
;
}
在这里使用取地址符可以直接获取每个变量、结构体的地址,但是这种格式可能跟我们之前看到的8位不一样,前面少了2个0,这时候可以将%x占位符替换为%p来打印显示。
那么取地址符(&变量)的类型是什么呢?我们可以来探测下:
char
a;
short
* b;
int
** c;
int
x = &a;
int
x = &b;
int
x = &c;
以上代码我们在编译的时候是没法成功的,它的报错内容是:
通过报错内容我们可以看出类型不同无法转换,但是我仔细观察报错内容: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*2)
-
定义新的类型(char*)
-
取值运算符(星号+指针类型的变量);也就是取地址的存储的值。
如下代码就是使用取值运算符:
int
* a = (
int
*)
1
;
printf(
"%x \n"
, *a);
这段代码可以编译,但是是无法运行的,我们可以运行一下然后来看看反汇编代码:
如上反汇编代码,我们可以清楚的看见首先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);
以上代码我们在编译的时候是没法成功的,它的报错内容是:
通过报错内容我们可以看出类型不同无法转换,但是我仔细观察报错内容: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,原封不动。
为什么是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
;
}
我们想打印数组不仅要知道数组是什么,也要获取数组的长度,所以需要两个参数(实际上我们也有其他方法获取长度,这里先不多说)。
我们来看下反汇编代码,看看数组是否和基本类型一样传入的是值:
通过如上代码所示,我们可以很清晰的看见,ebp-14也就是数组的第一个值的地址给了eax,最后eax压入堆栈,也就是传递给了函数。
所以我们得出结论:数组参数传递时,传入的是数组第一个值的地址,而不是值;换而言之,我们在printArray函数中修改传入的数组,也就修改了数组本身。
我们再换个思路,在数组作为参数传递的时候,可以换一种形式,直接传入地址也是可以打印的,也就是使用指针来操作数组:
事实证明这是可行的,我们在传入参数的时候使用数组第一个值的地址即可。
指针与字符串
在学习完指针类型后,我们可以来了解一下这些函数:
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);
指针取值的两种方式
如下图所示的则是一级指针(一个星号)和多级指针(多个星号):
这段代码看着很复杂,但我们有基础后再看它轻而易举,脑子里浮现的就是汇编代码。
指针取值有两种方式,如下代码:
#include <stdio.h>
int
main()
{
int
* p = (
int
*)
1
;
printf(
"%d %d"
, *(p), p[
0
]);
return
0
;
}
我们可以使用取值运算符,也可以使用数组的方式,因为其本质都是一样的,我们来看下反汇编代码:
也就说明:*()与[]的互换,如下是互换的一些例子:
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
;
}
我们实践下,看看本质:
#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"
};
一共有三种赋值方式,在实际应用中我们更偏向于第三种方式。
结构体指针也有数组,我们可以看下其定义和对应宽度:
接下来我们要学习的是数组指针,数组指针在实际应用很少用到,数组指针是最难学的。
首先分析一下如下代码:
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]有什么区别?我们可以来看看宽度:
可以看见一个是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。
实验下:
#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
);
我们来看下函数指针的反汇编代码:
可以很清晰的看见函数指针生成来一堆汇编代码,传参、调用、以及如何平衡堆栈;而如上这段代码最终会调用地址0xA,但它本身不存在,所以无法运行。
所以我们想要调用某个函数时可以将地址赋值给pFun即可,并且在定义时写好对应的参数列表即可;也就是说函数指针通常用来使用别人写好的函数。
我们也通过函数指针绕过调试器断点,假设有攻击者要破解你的程序,它在MessageBox下断点,你正常的代码就会被成功断点,但是如果你使用函数指针的方式就可以绕过。
首先,我们先写一段正常的MessageBox程序,然后使用DTDebug来下断点:
可以看见,我们下断点成功断下来了,断点本质上就是在MOV EDI, EDI所在那行的地址下断点,那么我们可以直接跳过这行调用调用下一行,实际上这段汇编的核心在于我标记的部分,准确一点的说就是CALL指令哪一行,我们可以右键Follo跟进:
我们可以从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
;
}
这样就可以绕过断点了:???没有明白
实践,我们来做一个隐藏代码到数据区的例子:
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,可以确定将代码隐藏进了数据区,这里实际上是将硬编码存储进了数据区。