【旧文章搬运】简单栈回溯及应用
原文发表于百度空间,2008-12-18
==========================================================================
标准栈回溯要求回溯中的每个函数都以如下指令作为开头(当然不是说不这样开头就不能回溯,那样就得特殊处理了):
push ebp
mov ebp,esp
接下来的工作通常是为临时变量开辟空间
sub esp,0x40
...
在函数结束时,会还原ebp和esp寄存器的值,即
mov esp,ebp
pop ebp
retn 0xC
不过有时候你看不到这两条指令,取而代之的是leave指令,两者是等效的
下面是实验用的代码:
// ShowCallStack.cpp : Defines the entry point for the console application. // #include "stdafx.h" #include <windows.h> ULONG FunA(ULONG para1,ULONG para2); ULONG FunB(ULONG para1,ULONG para2,ULONG para3); ULONG FunC(ULONG para1,ULONG para2); int main(int argc, char* argv[]) { FunA(0xA0000001,0xA0000002); printf("Finish!\n"); return 0; } ULONG FunA(ULONG para1,ULONG para2) { printf("FunA Called!\n"); return FunB(0xB0000001,0xB0000002,0xB0000003); } ULONG FunB(ULONG para1,ULONG para2,ULONG para3) { printf("FunB Called!\n"); ULONG tmp=FunC(0xC0000001,0xC0000002); printf("After FunB Called!\n"); return tmp; } ULONG FunC(ULONG para1,ULONG para2) { ULONG *pEBP; char **mainargv; ULONG *addr; printf("FunC Called!\n"); _asm { mov pEBP,ebp } //调用来自FunB,改变返回地址 addr=pEBP+1;//此时addr指针里面放的就是返回地址,保存一下,等下拿到父函数的返回地址就放这里了 printf("Call returned to 0x%08X ,From 0x%08X\n",pEBP[1],pEBP[1]-5);//ebp+4 printf("Argv1=0x%08X\tArgv2=0x%08X\n",pEBP[2],pEBP[3]); printf("=======================================\n"); //向上回溯一层 pEBP=(ULONG*)(*pEBP);//FunB *addr=pEBP[1];//pEBP[1]是父函数FunB的返回地址,以它替换FunC的返回地址 printf("Call returned to 0x%08X ,From 0x%08X\n",pEBP[1],pEBP[1]-5);//ebp+4 printf("Argv1=0x%08X\tArgv2=0x%08X\tArgv3=0x%08X\n",pEBP[2],pEBP[3],pEBP[4]); printf("=======================================\n"); pEBP=(ULONG*)(*pEBP);//FunA printf("Call returned to 0x%08X ,From 0x%08X\n",pEBP[1],pEBP[1]-5);//ebp+4 printf("Argv1=0x%08X\tArgv2=0x%08X\n",pEBP[2],pEBP[3]); printf("=======================================\n"); pEBP=(ULONG*)(*pEBP);//main printf("Call returned to 0x%08X ,From 0x%08X\n",pEBP[1],pEBP[1]-5);//ebp+4 printf("Argv1=0x%08X\tArgv2=0x%08X\n",pEBP[2],pEBP[3]); mainargv=(char**)pEBP[3]; for (ULONG i=0;i<pEBP[2];i++) { printf("%s\n",mainargv[i]); } printf("=======================================\n"); return 0xCCCCCCCC; }
我们看一下函数调用的过程,以代码中的FunB调用FunC为例,调用时代码如下:
push C0000002
push C0000001
call FunC
在FunC内部,
push ebp mov ebp,esp sub esp,50
把这个过程稍稍变变形,上面指令告诉我们:
push C0000002 push C0000001 push eip+5 //返回地址 jmp FunC push ebp 此时[esp]=ebp mov ebp, esp //把此时的栈顶指针赋值给了ebp,此时ebp=esp,因此[ebp]=ebp,当然后一个ebp是父函数的ebp了
此时栈的布局是这样的:
栈最上面的内容就是ebp,这个ebp与FunB中的ebp是相等的,因为控制从FunB转到FunC的中间并没有改变它
那我们可以很清楚的知道:
[ebp+0x0]是父函数的ebp
[ebp+0x4]就是返回地址
[ebp+0x8]是第一个参数
[ebp+0xC]是第二个参数
[ebp+0x10]是第三个参数(如果有的话)
当前ebp的内容,是上一个ebp的位置(这是由push ebp;mov ebp,esp两句所决定的了),因为调用FunC时,ebp是不动的,一直到FunC内部mov ebp,esp时才被改变
当函数嵌套调用时,ebp就成了一条链。如下;
FunA { save ebp in main FunB() { save ebp in FunA FunC() { save ebp in FunB } } }
所以,能过当前函数的ebp处,存放的是其父函数的ebp,父函数的ebp处,那就是爷爷辈的了~~
我来串联张图:
从当前函数的ebp出发,可以得到当前函数的返回地址(即调用者地址+5),参数等信息
进一步回溯,可以从得到的父函数的ebp得到父函数的返回地址和参数;
我们感兴趣的也就是返回地址和参数,如果你仅对返回地址感兴趣的话,推荐一个函数:
ULONG //有效的返回地址数
RtlWalkFrameChain (
OUT PVOID *Callers, //一个数组,存放回溯到的返回地址
IN ULONG Count, //最多回溯几层
IN ULONG Flags //回溯标志,用户态下应置0
)
很容易就到返回地址,其内部原理是一样,只是包装了一下而已
ntdll.dll和ntoskrnl.exe都导出了此函数,只是内核中Flags若为1,表示在内核状态下回溯用户态栈。如果你有兴趣,当然还可以继续回溯,直至ebp中的内容为0,因为一个线程的CONTEXT最初被初始化时,其ebp的值被置0.
这样程序中的参数和调用地址就一层层的被回溯出来。
如果你用调试器进入FunC内部,然后查看此时的调用栈,会发现跟这个输出结果是一样的~~
栈回溯至此基本清楚了!
这时再看MJ0011的《基于CallStack的Anti-Rootkit HOOK检测思路》和gzzy的《基于栈指纹检测缓冲区溢出的一点思路》应该就比较轻松了。
关于栈回溯的应用,我举一个小小的例子:
用回溯做一点点“非法”的事情:篡改返回地址!
FunC本应该返回到FunB,现在我们让它直接返回到FunA!
首先,得到本函数的ebp,ebp+4是当前函数返回地址存放的位置,也就是要修改的位置,保存此指针
向上回溯一层,得到FunB的返回地址,这个地址是在FunA中,将它赋值给刚才保存的指针,以它来替代当前FunC的返回地址。
重新编译修改后的程序,运行一下
现在你会发现FunB中那句"After FunB Called!"打印不出来了,因为我们从FunC直接返回到了FunA~~~
只是一个小小例子,具体应用嘛,就随便发挥想像了,我举几个例子:
比如我的一个程序中,hook了某函数XXX中的第一个call,然后通过栈回溯获取相关参数进行判断来决定是否修改返回值,而返回时直接返回到了XXX的调用者。
记得以前卡巴检测缓冲区溢出时,是Hook了LoadLibraryExA(W)和GetProcAddress以检测返回地址是否在栈中(TEB的NT_TIB结构中的当前线程栈的基址和大小),那么retn to lib就可以简单绕过了。
怎么return to lib 呢?
具体作法如下:
在系统dll中找一处retn,机器码为0xC3,记下它的位置,这将作为返回地址
以模拟call的方式调用LoadLibraryExA
push retaddr //这里是真实的返回地址 push 0 push 0 push 00403000 "kernel32.dll" push 7C81756A //这是前面找到的retn的位置,在kernel32.dll中,即(char*)7C81756A的值为0xC3,作为伪造的返回地址 jmp 7C801D4F //这是LoadLibraryExA的地址
这样,返回时将返回到7C81756A,而栈顶是retaddr,7C81756A处又是我们找好的retn指令
再retn一次,我们就成功回到了retaddr处~~~
当然对付这种方式的检测方式已经有了,但是效果不怎么好~retn可以找,也可以自个儿找个空地儿写点东西进去来实现~
但是这样卡巴就检测不出来了,因为7C81756A这个地址确实不在栈中~~~
其它的,RKU貌似Hook了ExAllocPool及其它部分函数并记录返回地址,以检测那些把自己代码扔在NopPagedPool中跑起来就退出的RK.
这在老V的blog里“技巧”分类中有一篇关于这个的文章,有兴趣的自己看看~~
说的差不多了,感觉我的想法还是很局限哦~~