程序员人生 网站导航

关于编译型语言函数的调用(一)

栏目:互联网时间:2014-11-05 08:53:23

终究真是团团转,真可以说是好事做尽,坏事做绝,

但是想一想写点东西既有助于记忆,又有益于他人参考,所以还是决定抽点时间草书此文

之前在有关破解的博文中也略微提到这个问题,现在就深入1点去考究它吧


狭义的编译1般指的是将程序语言代码转为CPU能履行的机器码,比如C++(VC++)

VB6的主程序也是切实编译的,但是大部份却类似java,生成了中间代码,由虚拟机在运行时解释为机器码

这1点跟脚本很类似,只是中间代码是2进制的,不容易为人所理解,脚本则更直观

对.NET(VB,C#等)则是纯洁的生成中间代码(微软中间语),因此这些语言生成的程序可以很容易的"反编译"并任意转换语言

生成中间代码,广义上也算是编译.


我们今天要说的主要是狭义的编译,而且主要以VC6为例子,考究函数调用的那些细节,其实我还是比较关注细节的

VC中经常使用的函数调用有以下几种:

1、_stdcall
2、__cdec(默许)
3、__fastcall
4、thiscall(隐式)
5、naked(裸函数)

其实naked不是1种调用约定,而是函数修饰符,是面向编译的,它允许http://www.wfuyu.com自由的控制函数的堆栈.

编译以后可以与thiscall之外所有调用方式相同.我们写个小demo来分别看看这些函数都是怎样调用的.

// call.h ... #ifndef __CALL_H_ #define __CALL_H_ #if _MSC_VER > 1000 #pragma once #endif // _MSC_VER > 1000 //#ifdef __cplusplus //extern "C" { //#endif class CCall { public: CCall(); ~CCall(); int Call(int arg1, short arg2, char arg3, void *arg4); protected: int m_Var1; }; //#ifdef __cplusplus //} //#endif #endif
处于种种目的, 我还是把函数体写在类外面:

// call.cpp ... #include "call.h" CCall::CCall() { m_Var1 = 18; } CCall::~CCall() { } int CCall::Call(int arg1, short arg2, char arg3, void *arg4) { int var1; short var2; char var3; int *p; var1 = arg1; var2 = arg2; var3 = arg3; p = (int *)arg4; *p = m_Var1; return 0; }
还有入口和全局函数:

// main.cpp ... #include <windows.h> #include "call.h" int g_var1; void fnVoid(int arg1, short arg2, char arg3) { int var1; short var2; char var3; var1 = arg1; var2 = arg2; var3 = arg3; arg1 = ⑴; g_var1 = 111; return; } int fnDefaultCall(int arg1, short arg2, char arg3, void *arg4) { int var1; short var2; char var3; int *p; var1 = arg1; var2 = arg2; var3 = arg3; p = (int *)arg4; *p = 7; return 0; } int __stdcall fnStandardCall(int arg1, short arg2, char arg3, void *arg4) { int var1; short var2; char var3; int *p; var1 = arg1; var2 = arg2; var3 = arg3; p = (int *)arg4; *p = 11; return 0; } int __fastcall fnFastCall(int arg1, short arg2, char arg3, void *arg4) { int var1; short var2; char var3; int *p; var1 = arg1; var2 = arg2; var3 = arg3; p = (int *)arg4; *p = 14; return 0; } __declspec(naked) int __cdecl fnNakedCall(int arg1, short arg2, char arg3, void *arg4) { // 1. 到这里所有寄存器的值与调用前1样 // 2. 用变量名援用任何局部变量同等于援用主调函数变量或参数 // 3. 必须负责寄存器的保护, 这里函数作为__cdecl __asm{ push ebp ; prolog begin mov ebp, esp sub esp, 50h push ebx push esi push edi lea edi, [ebp⑸0h] mov ecx, 14h mov eax, 0CCCCCCCCh rep stos dword ptr [edi] ; prolog end // var1 = arg1; mov eax, dword ptr [ebp + 8] ; [esp + 8] mov dword ptr [ebp⑷], eax ; [esp - 4] // var2 = arg2; mov cx, word ptr [ebp + 0Ch] mov word ptr [ebp - 8], cx // var3 = arg3; mov dl, byte ptr [ebp + 10h] mov byte ptr [ebp - 0Ch], dl // p = (int *)arg4; mov eax, dword ptr [ebp + 14h] mov dword ptr [ebp - 10h], eax // *p = ⑴; mov ecx, dword ptr [ebp - 10h] mov dword ptr [ecx], 0FFFFFFFFh // return 22; mov eax, 16h ; 0x16 = 22 pop edi ; epilog begin pop esi pop ebx mov esp, ebp pop ebp ; epilog end // return to caller function(do not use ret 10h) ret } } int main(int argc, char **argv) { CCall *pCall; int var1; int ret; fnVoid(1, 2, 3); ret = fnDefaultCall(4, 5, 6, &var1); ret = fnStandardCall(8, 9, 10, &var1); ret = fnFastCall(11, 12, 13, &var1); pCall = new CCall(); ret = pCall->Call(15, 16, 17, &var1); delete pCall; // pCall = NULL; ret = fnNakedCall(19, 20, 21, &var1); return 0; }

下面在DEBUG下看看调用进程,注意如果是VS.NET,VC编译时会在每一个变量前后都加1个DWORD,目的是检测缓冲区溢出

首先是调用无返回值的void函数,默许是__cdecl调用:

120: fnVoid(1, 2, 3); 0040135D push 3 0040135F push 2 00401361 push 1 00401363 call @ILT+5(fnVoid) (0040100a) 00401368 add esp,0Ch 121:
可以看出,参数被从右向左压入堆栈,而后call函数地址,然后add esp,清算堆栈

注:

堆栈是从高地址向低地址延伸的,比如第1个push之前esp(栈顶指针)=0x0012FF04,那末push 3以后esp=0x0012FF00

以此类推,push 2,esp=0x0012FEFC; push 1,esp=0x0012FEF8

接着是call指令,这个指令将返回地址,即下1条指令位置(eip,指令指针)压入堆栈,比如

call之前eip=0x00401363(下1条eip=0x00401368)

call以后eip=0x0040100A,esp=0x0012FEF4

然后调用结束,__cdecl约定函数最后的ret指令会pop 栈顶给eip指针

eip=0x00401368 ESP=0x0012FEF8

而后add esp,0xc,这里0xC=12即3个DWORD就是前面push的数量(pop要弹出给某个寄存器,add直接修改栈顶位置,减少堆栈大小)

到此,堆栈和eip恢复调用前的状态.


接着,我们进入函数内部,看看它都做了甚么见不得人的勾当:

7: void fnVoid(int arg1, short arg2, char arg3) 8: { 00401140 push ebp 00401141 mov ebp,esp 00401143 sub esp,4Ch 00401146 push ebx 00401147 push esi 00401148 push edi 00401149 lea edi,[ebp⑷Ch] 0040114C mov ecx,13h 00401151 mov eax,0CCCCCCCCh 00401156 rep stos dword ptr [edi] 9: int var1; 10: short var2; 11: char var3; 12: var1 = arg1; 00401158 mov eax,dword ptr [ebp+8] 0040115B mov dword ptr [ebp⑷],eax 13: var2 = arg2; 0040115E mov cx,word ptr [ebp+0Ch] 00401162 mov word ptr [ebp⑻],cx 14: var3 = arg3; 00401166 mov dl,byte ptr [ebp+10h] 00401169 mov byte ptr [ebp-0Ch],dl 15: 16: arg1 = ⑴; 0040116C mov dword ptr [ebp+8],0FFFFFFFFh 17: g_var1 = 111; 00401173 mov dword ptr [g_var1 (0042ae74)],6Fh 18: return; 19: } 0040117D pop edi 0040117E pop esi 0040117F pop ebx 00401180 mov esp,ebp 00401182 pop ebp 00401183 ret --- No source file -------------------------------------------------------------- 00401184 int 3
首先ebp是栈底指针,是高地址(比esp高),函数的堆栈应在esp到ebp之间,不应当读写高于ebp的堆栈内存

注意,不应当不是不可以,黑客所用的缓冲区溢出攻击就是利用这1点,当你的程序不谨慎写入了这些地方的时候他们就能够履行任意代码

包括添加管理员帐户等等,这类通常是strcpy之类的函数,比如char szText[256],但是源字符串超越256字节

push ebp是保存栈底的值,这个栈底是调用之前的,然后

mov ebp, esp把栈顶赋值给栈底,相当于调用前的栈顶作为现在的栈底,再接着

sub esp, 4Ch栈顶减小4C=76(19个DWORD),相当于堆栈大小是76字节,这样就创建了1个当前函数所使用的堆栈

接下来

push ebx将基址寄存器入栈,编译器是很机械的,其实到现在为止,其实不需要基址寄存器,固然不需要暂存它的值,不过编译器其实不是人,它不管这个

接着push esi和edi是串操作的原指针和目的指针,了解汇编语言的就知道,这小子开始批量处理了

lea edi,[ebp⑷Ch]其实ebp⑷Ch就是esp就是栈顶,栈顶地址作为目的(内存地址较低)

mov  ecx,13h数量0x13=19,还记得刚刚说的19个DWORD吗?

mov  eax,0CCCCCCCCh,串操作的值,0xCCCCCCCC

rep stos dword ptr [edi],向edi指向的dword写入eax的值,即0xcccccccc,如果ecx不为零,edi递增1个dword继续写入

知道为何VC变量为何默许值总是0xCC了吧,局部变量都保存在堆栈上,现在全部堆栈都是这个值

其实还有1个用途,等下函数返回时我们再说.

现在"春田花花同学会"正式开始,


// var1 = arg1;

mov eax,dword ptr [ebp+8]

mov dword ptr [ebp⑷],eax

ebp是新的栈底指针,也就是原来的栈顶,前面调用的时候说过,call会push返回地址(指令地址不是返回值地址),

也就是说现在ebp指向的是返回地址?错!注意开始的push ebp,它又压入了1个DWORD,因此此时ebp指向的是原来的ebp

堆栈向低地址扩大,那末ebp+4就是函数的返回地址,顺序倒过来,ebp+8就是最后1个push压入的参数,也就是第1个参数!

堆栈向低地址扩大,那末ebp⑷就是第1个局部变量了,有人问为何要mov到eax,再从eax放到第1个局部变量?狄春说:这不是屡次1举吗

元芳说:mov指令两个参数不能都是存储器,也就是内存,这就是为何叫寄存器的缘由,英文为REGISTER是登记的意思,既是名词也是动词


想通了这1点,后面的就好理解了,只不过用低字,低字节来转移而已

接着我们修改参数的值,其实也好理解了,由于调用后直接add esp,xx参数直接抛弃,因此其实不改变甚么,除临时废弃的堆栈

接着是赋值全局变量,将1个立即数传送给全局变量的内存地址,也好理解了

没有返回值单函数,函数结尾return没有任何意义,如果在上面return会生成1条jmp指令,跳到这里来


最后,清算现场,最早push的最后pop恢复他们之前的值,恢复原来栈顶的值,pop恢复原来的栈底

最后1条ret指令,在函数调用时我们已说了,这里说1下的是,如果此时堆栈中的返回地址(恢复后的栈顶esp指向的地址)被修改了,会有甚么情况产生呢?

比如指向了ShellExecute这个API的地址,参数是cmd /c net user admin1 123456 /add

这个就留给大家思考吧, 还记得刚刚说0xCC的另外一个用途吗,如果此时没有ret,履行到后面就是0xCC这个机器码对应的是int 3中断

在debug,比如OllyDebug等会在断点处插入0xCC,调试者继续运行才恢复这个字节原来的值再继续履行

所以,不经意间的缓冲区,常常酿成的是内存制止访问,或中断,而有些人却对此10分敏感,就像有个美女裙子被吹起来,

阿弥陀佛,罪过!罪过!


文章好像很长了,我先int31下,下文继续吧

------分隔线----------------------------
------分隔线----------------------------

最新技术推荐