【旧文章搬运】简单栈回溯及应用

原文发表于百度空间,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!
");
return 0;
}

ULONG FunA(ULONG para1,ULONG para2)
{
printf("FunA Called!
");
return FunB(0xB0000001,0xB0000002,0xB0000003);
}

ULONG FunB(ULONG para1,ULONG para2,ULONG para3)
{
printf("FunB Called!
");
ULONG tmp=FunC(0xC0000001,0xC0000002);
printf("After FunB Called!
");
return tmp;
}

ULONG FunC(ULONG para1,ULONG para2)
{
ULONG *pEBP;
char **mainargv;
ULONG *addr;
printf("FunC Called!
");
_asm 
{
   mov pEBP,ebp
}
//调用来自FunB,改变返回地址
addr=pEBP+1;//此时addr指针里面放的就是返回地址,保存一下,等下拿到父函数的返回地址就放这里了
printf("Call returned to 0x%08X ,From 0x%08X
",pEBP[1],pEBP[1]-5);//ebp+4
printf("Argv1=0x%08X	Argv2=0x%08X
",pEBP[2],pEBP[3]);
printf("=======================================
");
//向上回溯一层
pEBP=(ULONG*)(*pEBP);//FunB
*addr=pEBP[1];//pEBP[1]是父函数FunB的返回地址,以它替换FunC的返回地址
printf("Call returned to 0x%08X ,From 0x%08X
",pEBP[1],pEBP[1]-5);//ebp+4
printf("Argv1=0x%08X	Argv2=0x%08X	Argv3=0x%08X
",pEBP[2],pEBP[3],pEBP[4]);
printf("=======================================
");
pEBP=(ULONG*)(*pEBP);//FunA
printf("Call returned to 0x%08X ,From 0x%08X
",pEBP[1],pEBP[1]-5);//ebp+4
printf("Argv1=0x%08X	Argv2=0x%08X
",pEBP[2],pEBP[3]);
printf("=======================================
");
pEBP=(ULONG*)(*pEBP);//main
printf("Call returned to 0x%08X ,From 0x%08X
",pEBP[1],pEBP[1]-5);//ebp+4
printf("Argv1=0x%08X	Argv2=0x%08X
",pEBP[2],pEBP[3]);
mainargv=(char**)pEBP[3];
for (ULONG i=0;i<pEBP[2];i++)
{
   printf("%s
",mainargv[i]);
}
printf("=======================================
");
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里“技巧”分类中有一篇关于这个的文章,有兴趣的自己看看~~
说的差不多了,感觉我的想法还是很局限哦~~

原文地址:https://www.cnblogs.com/achillis/p/10180885.html