逆向-函数
Posted 嘻嘻兮
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了逆向-函数相关的知识,希望对你有一定的参考价值。
函数的工作机制主要是依托栈结构来实现的,首先栈的一种后进先出的结构,为什么使用这种数据结构,因为这和我们函数调用的流程很类似,当程序嵌套调用时,最后一个调用的函数总是最先返回。
对于函数,我们先从宏观上大体的先去了解,后面在看一些内部细节,简单理解函数调用的话,就是在程序动态的运行中,每进入一个函数,总有一块独立的栈函数空间供它使用,这段空间可以存储各类函数需要用到信息,如局部变量,参数等等,当函数返回时,该空间会销毁。
那么说到栈,当有函数调用发生,需要开辟一块新的栈空间,这时总得记录栈顶的位置吧,只有知道了栈顶的位置,这样子我们才能抬高栈顶(开辟新空间),相对于栈顶,还有一个概念就是栈底,也可以说是函数的底部位置。对于栈顶和栈底,CPU中使用ESP和EBP这两个寄存器来保存其内容。
ESP指向栈顶
//函数的内部空间
EBP指向栈底
下面来看一段递归程序,用于分析其函数调用的过程
int GetSum(int num)
num && (num += GetSum(num-1)); //num为0时不会执行后半部分,递归出口
return num;
int main(int argc, char* argv[])
printf("%d",GetSum(2));
return 0;
下面来画一下函数递归调用的流程图
函数的返回流程图
所以这里最终的打印结果就是3。看完上面的流程分析,那么现在对函数的栈结构应该有一定的宏观上的了解了。
下面就可以说说细节了,我们就来分析一下上述程序的汇编代码,看懂汇编后,你就能知道程序是如何转移的,又是如何返回的,又是如何获取参数的...
先来看Main函数中的调用
12: printf("%d",GetSum(2));
00401158 6A 02 push 2 //压入参数
0040115A E8 BF FE FF FF call @ILT+25(sub_4010A0) (0040101e) //调用GetSum
0040115F 83 C4 04 add esp,4
对于call指令而言,其实他的工作就是把下一行的汇编指令地址给压入栈中,也就是说我们可以如下等价替换
12: printf("%d",GetSum(2));
00401158 6A 02 push 2
push 0040115F //压入下一行的地址
mov EIP,0040101e //修改EIP使其到跳转到函数(模拟,真实该指令无效)
0040115F 83 C4 04 add esp,4
那么为什么会需要压入下一行的地址呢?这里主要是用于函数返回时用的,当你已经进入到被调用的函数内部,那么就无法知道函数需要返回到哪里了,所以需要先将函数的下一行汇编地址记录下来,当函数返回时,读取该地址以便于继续执行。
下面开始分析函数内部的实现
4: int GetSum(int num)
5:
00401030 55 push ebp //保存栈底指针
00401031 8B EC mov ebp,esp //调整当前栈底指针到栈顶
00401033 83 EC 40 sub esp,40h //抬高栈顶,开辟空间
00401036 53 push ebx //保存环境 ebx esi edi
00401037 56 push esi
00401038 57 push edi
00401039 8D 7D C0 lea edi,[ebp-40h]
0040103C B9 10 00 00 00 mov ecx,10h
00401041 B8 CC CC CC CC mov eax,0CCCCCCCCh
00401046 F3 AB rep stos dword ptr [edi] //debug版会将开辟的空间填充0xcc
6: num && (num += GetSum(num-1)); //num为0时不会执行后半部分,递归出口
00401048 83 7D 08 00 cmp dword ptr [ebp+8],0
0040104C 74 17 je GetSum+35h (00401065) //为零则递归结束,直接跳到返回
0040104E 8B 45 08 mov eax,dword ptr [ebp+8] //使用ebp+xx获取参数
00401051 83 E8 01 sub eax,1
00401054 50 push eax //num-1后当做参数压入
00401055 E8 C4 FF FF FF call @ILT+25(sub_4010A0) (0040101e) //递归调用
0040105A 83 C4 04 add esp,4
0040105D 8B 4D 08 mov ecx,dword ptr [ebp+8] //重新获取参数num
00401060 03 C8 add ecx,eax //将num加上面函数的返回值
00401062 89 4D 08 mov dword ptr [ebp+8],ecx
7: return num;
00401065 8B 45 08 mov eax,dword ptr [ebp+8] //eax存放返回结果
8:
00401068 5F pop edi //还原环境 edi esi ebx
00401069 5E pop esi
0040106A 5B pop ebx
0040106B 83 C4 40 add esp,40h //降低栈顶,释放空间
0040106E 3B EC cmp ebp,esp
00401070 E8 FB 04 00 00 call __chkesp (00401570) //debug下的堆栈平衡检查
00401075 8B E5 mov esp,ebp //还原esp
00401077 5D pop ebp //还原原先函数的栈底指针
00401078 C3 ret //返回函数
基本上对汇编指令的理解都写在上面了,下面我们还是来画一下其中的流程图,重心放在ESP和EBP如何变化
OK,看完上面的流程图,也就可以明白了,此时EBP+4的位置就是返回地址,而+8的地方就是参数了。而对于函数内部的局部变量,则使用EBP-xxx来访问,因为0x40开辟的空间就是用于存放局部变量。当内部的那个函数调用返回时,此时又会回到平衡的状态(保存环境后)。
下面再来看一下返回时的流程图
对于上面的流程图来说,主要需要解释一下ret指令,对于ret指令,其可以等价替换为
pop EIP
也就是将此时ESP指向的内存的值给EIP,其到返回继续执行流程的效果
而对于后面 add esp,4的指令,说明这里的平衡堆栈是调用方来平衡,至于如何平衡,这里就是调用约定的问题了,后面在来细说。到这里,函数的调用过程应该十分清晰了,下面来整理一下调用过程
1. 按调用约定传递参数
2. 保存函数返回的地址
3. 流程转移到被调用方的函数首地址
4. 保存调用方的栈底(栈底稳定)
5. 以当前栈顶作为被调用方的栈底
6. 为局部变量分配空间
7. 保存寄存器环境
8. 可选项,局部变量初始化为0xcc (debug)
9. 执行被调方的函数体
10.恢复处理器环境
11.释放局部变量空间
12.恢复调用方的栈底
13.弹出当前栈顶的值作为返回的代码流程地址
14.此时流程回到调用方
好了,下面开始说调用约定的事,对于调用约定的问题,从名字上就可以看出来,既然是约定,那么相当于是一种规则,也就是调用方和被调用方双方约定的规则,这个规则主要体现在两个方面
1.参数如何传递
2.函数栈谁来平衡(参数的平衡)
当参数使用栈空间进行传递时,这部分空间在函数调用结束后需要平衡
下面就来先看一下下面三个调用约定
cdecl - C约定
stdcall - 标准约定,跨平台
fastcall - 非标准,微软
注意,这里除了上面的三个调用约定外,还有很多调用约定,对于不同的编译器会有自己的调用约定。对于其他的调用约定,我们只需把握好上面说的两个规则就好。
先来看,cdecl约定,也就是常说的C约定,这也是默认的约定(函数不加约定时)
1.参数从右到左保存(push)
2.由调用方平栈
下面来看一个例子
int __cdecl fun(int a,int b,int c)
return a * b / c;
int main(int argc, char* argv[])
return fun(argc,1,2);
分析反汇编代码
//调用处汇编代码
00401078 6A 02 push 2
0040107A 6A 01 push 1
0040107C 8B 45 08 mov eax,dword ptr [ebp+8]
0040107F 50 push eax //参数右到左压入
00401080 E8 8A FF FF FF call @ILT+10(fun) (0040100f)
00401085 83 C4 0C add esp,0Ch //调用方来平衡堆栈
上面的分析完了其实也就可以明白了,对于最开始的递归程序例子,使用的就是c约定。下面再来看一下stdcall
1.参数从右到左保存(push)
2.由被调用方平栈
将上例的例子中的cdecl修改为stdcall后观察其汇编代码
//调用处汇编代码
00401078 6A 02 push 2
0040107A 6A 01 push 1
0040107C 8B 45 08 mov eax,dword ptr [ebp+8]
0040107F 50 push eax //参数从右到左压栈
00401080 E8 80 FF FF FF call @ILT+0(_fun@12) (00401005)
//此时后面无平栈汇编代码
//函数汇编代码
7: int __stdcall fun(int a,int b,int c)
8:
00401020 55 push ebp
00401021 8B EC mov ebp,esp
00401023 83 EC 40 sub esp,40h
00401026 53 push ebx
00401027 56 push esi
00401028 57 push edi
00401029 8D 7D C0 lea edi,[ebp-40h]
0040102C B9 10 00 00 00 mov ecx,10h
00401031 B8 CC CC CC CC mov eax,0CCCCCCCCh
00401036 F3 AB rep stos dword ptr [edi]
9: return a * b / c;
00401038 8B 45 08 mov eax,dword ptr [ebp+8]
0040103B 0F AF 45 0C imul eax,dword ptr [ebp+0Ch]
0040103F 99 cdq
00401040 F7 7D 10 idiv eax,dword ptr [ebp+10h]
10:
00401043 5F pop edi
00401044 5E pop esi
00401045 5B pop ebx
00401046 8B E5 mov esp,ebp
00401048 5D pop ebp
00401049 C2 0C 00 ret 0Ch //注意这里 ret n,
//此时在pop eip后还在做平衡堆栈的操作,0ch就是平衡的字节数,相当于 sub esp,0ch
从汇编可以看出来,stdcall相当于C约定,区别在于谁来平衡堆栈,而从函数内部的平衡堆栈的字节数,也可以反推出其参数的个数,因为压入总是四字节的,所以0xC除以4,那么就是3,说明此时有3个参数(严格来说只能判断函数调用时有几个push,毕竟有些参数的大小可能会大于四字节)。
下面再来看一下fastcall,这个约定是非标准的,也就是说是属于和编译器绑定的,这个是vs系列的编译器。
1.前两参数会优先使用ecx和edx进行传参,不够时使用栈传参(顺序还是右到左)
2.由被调用方平栈
//函数调用处汇编代码
00401088 6A 02 push 2 //参数三使用栈传递
0040108A BA 01 00 00 00 mov edx,1 //参数二使用edx寄存器
0040108F 8B 4D 08 mov ecx,dword ptr [ebp+8] //参数一使用ecx寄存器
00401092 E8 6E FF FF FF call @ILT+0(@fun@12) (00401005)
//函数
7: int __fastcall fun(int a,int b,int c)
8:
00401020 55 push ebp
00401021 8B EC mov ebp,esp
00401023 83 EC 48 sub esp,48h
00401026 53 push ebx
00401027 56 push esi
00401028 57 push edi
00401029 51 push ecx //保存参数ecx
0040102A 8D 7D B8 lea edi,[ebp-48h]
0040102D B9 12 00 00 00 mov ecx,12h
00401032 B8 CC CC CC CC mov eax,0CCCCCCCCh
00401037 F3 AB rep stos dword ptr [edi]
00401039 59 pop ecx //还原参数ecx
0040103A 89 55 F8 mov dword ptr [ebp-8],edx //在函数内部无对edx赋值就直接使用说明是外部传递的
0040103D 89 4D FC mov dword ptr [ebp-4],ecx //这里虽然上方有使用,但是push和pop还原相当于直接使用外部传递
9: return a * b / c;
00401040 8B 45 FC mov eax,dword ptr [ebp-4]
00401043 0F AF 45 F8 imul eax,dword ptr [ebp-8]
00401047 99 cdq
00401048 F7 7D 08 idiv eax,dword ptr [ebp+8]
10:
0040104B 5F pop edi
0040104C 5E pop esi
0040104D 5B pop ebx
0040104E 8B E5 mov esp,ebp
00401050 5D pop ebp
00401051 C2 04 00 ret 4 //内平栈,此时只需平衡一个参数的空间大小即可,因为另外两个使用寄存器
可以看出来,使用fastcall约定时,由于使用的是寄存器传参,其速度也会比另外两个快那么一点点。
最后需要注意的是,对于调用约定的判断,千万不能从调用函数处判断,如c约定来说,虽然其特征都是在调用处,但是我们推断参数的个数时还是需要从函数的内部进行判断,看函数内有对参数的引用的个数(ebp+xxx),以及最后有没有平栈的动作。因为在函数调用处判断约定很容易看走眼,特别在release版中有流水线优化时其更容易分析错误,所以需要从函数内部入手去判断调用约定。
以上是关于逆向-函数的主要内容,如果未能解决你的问题,请参考以下文章