简单的栈回溯 & 简单的栈回溯欺骗 -- 简单分析

原文地址在: http://hi.baidu.com/iceboy_/item/924f349e10c9fbcfb62531f7#

对于我这样的新手好长时间才看懂,本文就那篇文章中的程序来简单分析一下。程序如下:

#include 
#include 
#include 

#pragma warning(disable: 4311 4312 4313)

int fake_ebp_1, fake_ebp_2;

void __stdcall _StackTrace(int StackBase, int ebp, int esp)
{
		int limit = 30, retaddr, calladdr;
		printf("ebp      ret      call\n");
		while ((ebp > esp) && (ebp < StackBase) && (limit--)) 
		{
			retaddr = *(int *)(ebp + 4);
			calladdr = 0;
			__try 
			{
				if (*(unsigned char *)(retaddr - 5) == 0xe8) 
				{	
					calladdr = *(int *)(retaddr - 4) + retaddr;
				}
			} __except (EXCEPTION_EXECUTE_HANDLER) {}
			printf("%08x %08x %08x\n", ebp, retaddr, calladdr);
			ebp = *(int *)ebp;
		}
		printf("trace completed.\n");
}

__declspec(naked) void __stdcall StackTrace()
{
		// iceboy's stack trace
		__asm {
			push esp
			push ebp
			push fs:[0x4]        //; StackBase
			call _StackTrace
			retn
		}
}

void b(int, int)
{
		StackTrace();
}

void a(int, int, int)
{
		b(0, 0);
}

int search_call(int fn1, int fn2)
{
    while (true) 
	{
        if (*(unsigned char *)(fn1++) == 0xe8)	
		{										
            if ((*(int *)fn1 + fn1 + 4) == fn2)
			{																	
				 return fn1 + 4;					
            }
        }
    }
}

// fake call
__declspec(naked) void __stdcall d(int, int)
{
    __asm {
        push fake_ebp_1
        push ebp
        mov ebp, esp
        push fake_ebp_2
        push ebp
        mov ebp, esp
        call StackTrace
        pop esp
        pop ebp
        pop eax
        retn 8
    }
}

// fake call & hide self
__declspec(naked) void __stdcall e(int, int)
{
    __asm {
        push ebp
        mov ebp, [ebp]
        push 0
        push 0
        call d
        pop ebp
        retn 8
    }
}

void c(int, int, bool hideself)
{
		if (!hideself) 
		{
			d(0, 0);
		} else 
		{
			e(0, 0);
		}
}

int main()
{
		fake_ebp_1 = search_call((int)main, (int)a);
		fake_ebp_2 = search_call((int)a, (int)b);


		printf("address of function a:    0x%08x\n", a);
		printf("address of function b:    0x%08x\n", b);
		printf("address of function c:    0x%08x\n", c);
		printf("address of function main: 0x%08x\n", main);

		printf("\ntest 1: standard call\n");
		a(0, 0, 0);

		printf("\ntest 2: fake call\n");
		c(0, 0, false);

		printf("\ntest 3: fake call & hide self\n");
		c(0, 0, true);

		printf("\npress any key to continue...");
		_getch();
		printf("\n");
		return 0;
}

 在我的机器上输出如下:

 

我们就顺着程序来一步一 步分析,需要什么知识点时就说明什么知识点。

首先

		fake_ebp_1 = search_call((int)main, (int)a);
		fake_ebp_2 = search_call((int)a, (int)b);

search_call的代码如下:

int search_call(int fn1, int fn2)
{
    while (true) 
	{
        if (*(unsigned char *)(fn1++) == 0xe8)	
		{										
            if ((*(int *)fn1 + fn1 + 4) == fn2)
			{																	
				 return fn1 + 4;					
            }
        }
    }
}

首先要知道的是x86平台call指令有很多种,有 e8 call, ff15 call, 还有 reg call. 对于 e8 call, 我们可以计算出 call 的地址, 也就是被调函数首地址, 其它的 call 就困难了许多.那么e8 call具体是如何计算机被调函数的首地址的呢?

e8是call的指令机器码,这点大家都很清楚。e8后面的四字节机器码是此时指令指针(EIP)的值与目的地址(被调函数首地址)的差值,也即两地址间的相对偏移。指令指针在这里可以叫做返回地址,意思就是从被调函数返回后应执行的第一条指令的地址。

明白了这些,就很容易计算出目的地址(被调函数首地址): 目的地址 =  返回地址 +  相对偏移 举个例子吧:

0045F761   > \8B8D D8000000 MOV ECX,DWORD PTR SS:[EBP+D8]
0045F767   .  53            PUSH EBX     
0045F768   .  03CA          ADD ECX,EDX  
0045F76A   .  51            PUSH ECX    
0045F76B   .  50            PUSH EAX     
0045F76C   .  E8 0FB14800   CALL SRO_Clie.008EA880   

看最后一行,E8后的偏移值是0048B10F(十六进制),返回地址应该是0045F76C+5=0045F771,所以目的地址计算出应该是0048B10F+0045F771=008EA880。  
看SRO_Clie.008EA880 就知道结果是正确的。

上面的代码fn1+4就是返回地址,fn2就是目的地址, *(int *)fn1就是偏移量。

综上所述,很容易得出fake_ebp_1是main函数调用a函数的后的返回地址, fake_ebp_2是a函数调用b函数后的返回地址。

 

接下来

		printf("address of function a:    0x%08x\n", a);
		printf("address of function b:    0x%08x\n", b);
		printf("address of function c:    0x%08x\n", c);
		printf("address of function main: 0x%08x\n", main);

这几行很好理解,不过反汇编后发下结果输出的并不是这几个函数的真正首地址,而是它们在 静态函数跳转表 中的地址,如下:

00401004 CC                   int         3
@ILT+0(?d@@YGXHH@Z):
00401005 E9 D6 02 00 00       jmp         d (004012e0)
@ILT+5(?search_call@@YAHHH@Z):
0040100A E9 51 02 00 00       jmp         search_call (00401260)
@ILT+10(?c@@YAXHH_N@Z):
0040100F E9 1C 03 00 00       jmp         c (00401330)
@ILT+15(?a@@YAXHHH@Z):
00401014 E9 F7 01 00 00       jmp         a (00401210)
@ILT+20(?e@@YGXHH@Z):
00401019 E9 F2 02 00 00       jmp         e (00401310)
@ILT+25(?_StackTrace@@YGXHHH@Z):
0040101E E9 3D 00 00 00       jmp         _StackTrace (00401060)
@ILT+30(?StackTrace@@YGXXZ):
00401023 E9 48 01 00 00       jmp         StackTrace (00401170)
@ILT+35(_main):
00401028 E9 63 03 00 00       jmp         main (00401390)
@ILT+40(?b@@YAXHH@Z):
0040102D E9 9E 01 00 00       jmp         b (004011d0)
00401032 CC                   int         3
00401033 CC                   int         3

 

 了解了下:ILT是INCREMENTAL LINK TABLE的缩写,这个@ILT其实就是一个静态函数跳转的表,它记录了一些函数的入口然后跳过去,每个跳转jmp占一个字节,然后就是一个四字节的内存地址,加起为五个字节(计算方法与上面的分析相同)
比如代码中有多处地方调用boxer函数,别处的调用也通过这个ILT表的入口来间接调用,而不是直接call 该函数的偏移,这样在编译程序时,如果boxer函数更新了,地址变了,只需要修改跳表中的地址就可以,有利于提高链接生成程序的效率。这个是用在程序的调试阶段,当编译release程序时,就不再用这种方法。

继续。

		printf("\ntest 1: standard call\n");
		a(0, 0, 0);

分析之前,我们先了解下什么是__declspec(naked)。

MSDN上这么说:

For functions declared with the naked attribute, the compiler generates code without prolog and epilog code. You can use this feature to write your own prolog/epilog code sequences using inline assembler code. Naked functions are particularly useful in writing virtual device drivers. Note that the naked attribute is only valid on x86, and is not available on x64 or Itanium.

我的理解:naked顾名思义 -- 赤裸、裸露的意思,用naked修饰的函数(裸函数),当编译器生成代码的时候,在函数开始时没有通常的形成栈的语句块(参数、局部变量的入栈操作),在函数结束时也没有恢复栈的动作(参数、局部变量的出栈操作,以及返回语句)。我们可以用这个特征来自己完成参数等的入栈出栈操作及返回操作,当然这些操作得用内嵌汇编码的方式完成。裸函数在写虚拟设备驱动时特别有用。注意naked属性仅在x86系列CPU中有效,在x64和安腾系列CPU中无效。

如果使用_declspec(naked)修饰的话,要注意自己恢复堆栈平衡......

现在分析函数调用a(0, 0, 0)的函数栈帧:

可以看到main函数的栈基址ebp等于0012FF80,可以看出上一级的栈基址为0012FFC0,main函数返回地址为00401C89。

 从地址0012FF30~0012FF20分别为main函数传递给a函数的参数、返回地址、main函数的栈基址,新的栈基址ebp等于0012FF20。

从地址0012FED0~0012FEC4分别为a函数传递给b函数的参数、返回地址、a函数的栈基址,新的栈基址ebp等于0012FEC4。

可以看出由于__declspec(naked)的原因,尽管调用了StackTrace函数,但栈基址ebp没变,依然是0012FEC4。从地址0012FE74~0012FE68一次是返回地址、入栈的esp、入栈的ebp、入栈的fs:[0x4]。

这里需要提一下,线程运行在RING0(系统地址空间)和RING3(用户地址空间)时,FS段寄存器分别指向不同内存段的。线程运行在RING0下,FS段值是0x3B(WindowsXP下值,在Windows2000下值为0x38);运行在RING3下时,FS段寄存器值是0x30。

可以看出当前程序运行在ring0下,所以FS指向的段是GDT中的0x3B段。该段的长度也为4K,基地址为0xFFDFF000。该地址指向系统的处理器控制区域(KPCR)。这个区域中保存这处理器相关的一些重要数据值,如GDT、IDT表的值等等。下面就是WindowsXP sp2中的KPCR数据结构:

_NT_TIB
+0x000 ExceptionList: Ptr32 _EXCEPTION_REGISTRATION_RECORD
+0x004 StackBase
: Ptr32 Void
+0x008 StackLimit
: Ptr32 Void
+0x00c SubSystemTib
: Ptr32 Void
+0x010 FiberData
: Ptr32 Void
+0x010 Version
: Uint4B
+0x014 ArbitraryUserPointer : Ptr32 Void

+0x018 Self

: Ptr32 _NT_TIB
 





 到达_StackTrace函数,新的栈基址ebp等于0012FE60,地址从0012FE60~0012FE64依次为上级栈基址、返回地址。

可以简略的画出函数栈帧图如下

                                                                                       

 可以很容易得看出回溯顺序为main()->a()->b()。

 

		printf("\ntest 2: fake call\n");
		c(0, 0, false);

用上面相同的方法,可以得到函数栈帧简略图如下:

                

                                                                      

                             

可以看出在d函数中在栈上压入了fake_ebp_1和fake_ebp_2,所以回溯的时候的路径与函数调用顺序是不同的,这就是栈欺骗。

我们再看

	printf("\ntest 3: fake call & hide self\n");
   	c(0, 0, true);

的函数栈帧:

                                                              
                                                       

这里最需要我们注意的是e函数中的

	push ebp
        	mov ebp, [ebp]

这里并不是将ebp赋值为当前的esp,而是[ebp],也就是main函数的栈基址,所以在我们回溯的时候就会忽略函数c的函数栈帧,这也是栈欺骗的一个手段。

好了,我就分析到这里了。

也许有人问我分析这些有什么用? 也许的确用处不大,但是我认为这个过程可以培养我的分析问题的能力,以及程序调试能力。

就这样吧... ... ^_^


《C函数调用过程原理及函数栈帧分析》
                                                          

你可能感兴趣的:(汇编语言,程序调试)