调试指南2:堆栈(原文By Toby Opferman http://www.codeproject.com/KB/debug/cdbntsd2.aspx)

 

简介
欢迎进入应用程序调试指南的第二部分。在这篇文章里,我将为大家研究一下堆栈,以及它在调试中扮演着怎样重要的角色。每当你问起:“当遇到陷阱时你会怎么做?”最常见的回答是“跟踪一下调用堆栈”。这样做是对的,当研究程序崩溃的dump时,第一件要做的事就是查看调用堆栈。
可能这篇指南对你来说太简单了,我感到很抱歉。我之所以把它的级别定为中级,是因为它需要一些汇编的知识,否则将它定为初级会比较好。
什么是堆栈?
这应该是最首要的问题。但不幸的是,我在第一部分里并没有给出答案,而是想当然的认为大家对堆栈都很熟悉。好吧,我现在就向大家解释这个问题,不过我们得先从进程开始。
什么是进程?
进程是应用程序在内存中的一个实例。所有库(可执行库、支持库)都会被载入这个地址空间。进程自己不会执行,而是定义了内存界限,资源以及其它类似的可以被进程中任何操作访问到的东西。
什么是线程?
线程是在内存边界内的一个运行实例。进程不会运行,而进程内的线程是可以的。在一个进程的上下文内可能会有很多线程在执行。尽快线程有个叫做“线程局部存储”的东西,但通常来说,在进程上下文中创建的所有内存和资源都是所有线程共享的。
全局和本地资源
这个例外可能会让你困惑。一些资源是全局的而不是局部的。这就意味着,这些资源可能会在进程以外的什么地方被使用。窗口句柄就是一个这样的例子。这样的资源在进程外也有它们自己的界限,有一些可能是系统级的,有一些也可能是桌面级或者会话级的。还有一种“共享”资源,进程之间可以进行协商然后通过某些机制来共享这些资源。
什么是虚拟内存?
总的来说,虚拟内存就是骗过系统让它误以为有比实际更多的内存可用。这种说法不全对。
其实操作系统并没有被骗。对于硬件来说,它已经知道其实并没有那么多的内存,它会通过某种机制来支持实现虚拟内存。而操作系统仅仅是利用了这种能力,所以它是没有被骗的。那到底谁被骗了?如果非要找出一个“受害者”的话,它就是运行在系统中的进程。
我也不相信这一点。开发人员通常对自己程序运行的系统很了解,也就是说,他知道操作系统在使用“虚拟内存”(或者说跟DOS的行为是不一样的),他还是按照原来的方式编写程序?一般情况下这也没什么问题。对于一个简单的程序来说,只要它能运行起来,就没必要担心这个。唯一有问题的时候是在一个协作的多任务系统和抢占的多任务系统中。但这个时候,开发者既然明白系统的这个特性,他就需要做一些改变编写出适合这个系统的程序。关于这两种系统的不同,已经超过了我们讨论的范围。
下面,我们再回到这个问题上。“虚拟内存”做的第一件事是它虚拟了机器的物理地址空间。也就是说,应用程序不会看到真正的物理地址,它们只能看到虚拟地址。CPU可以根据操作系统的特性将虚拟地址转变为物理地址。转化的细节不是我们讨论的重点,你只需要知道应用程序只接受虚拟地址,而处理器会将其映射为物理地址。
还有一点,虚拟地址不需要总是指向一个物理地址。操作系统可以将内存空间交换到磁盘上,这样,整个程序不必总存在于内存中,多个程序就能共存于内存中执行。如果程序试图访问不在物理内存中的地址,CPU会发出页访问异常,操作系统收到通知后会将那块内存从磁盘置换物理内存。于是程序就可以重新从中断处运行。
置换算法有很多种。除非想增加进程在物理内存中存在的几率,否则通常只是以页为单位进行置换。操作系统有很多算法来达到这个目的。最基本的一个就是,最近最少使用法。我们都不想让程序经常跨越页边界,这样才能避免频繁的换进换出。不过,这已经超过了我们讨论的范围。
虚拟内存另外一个好处就是可以实现保护。一个进程不能直接访问另一个进程的内存空间。这就是说,任何时候,对于任何进程CPU都只有该进程的虚拟地址映射表它没办法处理另外一个进程的虚拟地址。这点很有意义,因为它们是分别映射的,这样不同进程可以有相同的地址但它们却指向不同的位置。
这并不意味着没有任何可能去访问另一个进程的内存空间。如果操作系统内建了这样的支持,比如Windows,你就能够访问其它进程的内存空间。当然,如果你能够访问物理地址的话,你也可以通过改写CPU与地址映射有关的寄存器来实现这一点。但这是不可能的,当你试图执行敏感的汇编指令时,CPU就会检查你的权限级别,“虚拟内存”机制不会让你在用户模式下随便更改页表或描述符表(但Windows9x下是可以在用户模式下得到LDT的)。
什么是栈?
上面讲了一下操作系统有关的基本知识,现在可以回到栈的问题上了。通常来说,栈是一个通用的数据结构,数据项可以执行压入或弹出操作。可以把栈中的数据项比作一堆盘子,你可以从顶端放入盘子也可以从顶端拿走盘子。严格按照这样规则来实现的数据结构就是一个栈。栈代表着LIFO也就是后入先出。
应用程序会使用栈进行一些临时数据的存储。非汇编程序员可能不知道这一点,因为程序语言隐藏了这个细节。但实际上,你的程序中的代码会用到栈,而且CPU也支持栈的机制。
Intel系列CPU中入栈和出栈的汇编指令是PUSH和POP。也有一些处理器使用PUSH/PULL,但在Intel世界里面就是PUSH和POP。这只是个助记符而已。汇编语言总结了处理器使用的机器码和数字,这样一来,你就可以使用自然语言来调用这些指令。
每一个线程都有它自己的栈。因为多个线程同时访问同一个临时存储区域是不允许的。
函数调用是怎样实现的?
程序调用与调用规则有很大关系。调用规则是调用者和被调用者在传递和清理参数上约定的基本方法。在Windows下,通常会支持3种调用规则:“this call”、“standard call”、“CDECL”(C风格调用规则)。
This Call
这是C++的调用规则。如果你对C++内部机制很熟悉,你会知道,在调用成员函数时需要给它传递this指针。这个this指针一般都是栈上的第一个参数。但这并不是真正的“this call”的例子。在“this call”规则中,this指针是一个寄存器,确切的说时ECX。参数以逆序压入栈中,栈由被调用者来清理。
Standard Call
“Standard Call”规则是,参数以逆序压栈,被调用者负责清理。
CDECL (C 风格调用)
“CDECL”规则意味着,参数以逆序压栈,调用者同时负责清理。
Pascal 风格调用
在以前的程序中逆可能会看到“PASCAL”风格的调用。在WIN32中,__pascal实际上已经被废弃了。PASCAL宏被重定义为“Standard Call”。但Pascal风格其实是顺序压栈,被调用者负责清理。
清理堆栈
由谁来清理堆栈是有着很大不同的。第一个是节省指令字节数。如果由被调用者来清理堆栈,调用者就不需要在每次调用函数时增加额外的指令来清理堆栈,但也有缺点:这样一来就不能使用可变参数了。可变参数就是像printf那样使用参数的方法。实际调用者不知道堆栈上有多少参数,它只能根据传递给它的信息,比如传递给printf的格式化字符串,来猜测。如果用“%i %i %i”调用printf,printf就会试图使用堆栈上的另外3个参数来填入格式化字符串中,不管这3个参数是否存在。这也可能会引起陷阱。如果在栈中放入了更多的参数,将不会有问题,因为是由调用者来清理堆栈的,但printf并不知道这些多余的参数。记住一点,可变参数函数没有那么神奇,它们不知道堆栈上有多少参数,它们必须通过自己的方法来从参数列表中获得足够的信息。对于printf来说它的参数列表就是格式字符串,你甚至可以为它传递一个数字作为参数,但这对于编译器是不起作用的。
尽管由被调用函数来清理堆栈也是有可能的,但这样并不是完全切实可行的。因为在编译时被调用函数不知道有多少参数在栈上,它只能直接操纵堆栈,在返回值周围来回移动才能将栈清理干净。相比之下,有调用者清理堆栈会容易的多。
Intel处理器支持一个名为RET<Byte Count>的指令,被调用者可以用这个指令来直接清理堆栈。它是一个双字节指令,其中,Byte Count是当前栈上的参数的大小(以字节为单位)。
什么是堆栈
堆栈其实是一个本地的临时存储区。函数调用时,参数首先被压栈,然后是返回值被压栈。处理过程必须知道将返回值地址,CPU是很傻的,它只会逐条的执行指令,必须告诉它每一步要怎么走。我们需要将返回值保存起来,这样它才能返回到函数调用点。所有返回值就是函数调用后下一条指令的地址。汇编指令CALL就具备了这样的功能。而另外一个命令RET可以从当前位置返回到返回值处。另外,栈上可能还有本地变量以及其它临时存储对象。这就是为什么不能返回一个本地变量数组或数组指针的原因。因为函数返回后它们就消失了。
栈的格局如下:
[Parameter n          ]
...
[Parameter 2          ]
[Parameter 1          ]
[Return Address       ]
[Previous Base Pointer]
[Local Variables      ]
返回之前,栈会被清理至返回值处,然后“return”就会被调用。很显然,如果栈的顺序乱了,就会返回到错误的地址,这就会引起陷阱。
什么是“基址指针”
Intel处理器的“基址指针”一般都是EBP。ESP是动态的而且一直在改变,所以我们需要保存当前EBP的值,然后将EBP指向为栈中当前所在的位置。这样就可以直接通过一个偏移量来指向变量。也就是说第一个参数的位置总是EBP+xx,如果不保存EBP而总是使用ESP来定位,就必须时刻跟踪栈的情况以获得栈上的数据总数。偏移地址会随着栈上数据的增加而递增。如果不使用EBP,汇编程序也是可以生成函数的,所以这个方法并不是一成不变的。
通常来说,EBP+Value 可以得到函数的参数,EBP-Value可以得到函数的局部变量。
只使用一个栈会怎样
所以你现在应该明白了为什么线程都要有自己的堆栈。如果它们共享同一个栈,就会互相覆盖各自的返回值和数据!或者,最后栈资源会被消耗光。这是我们下面要讨论的问题。
栈溢出
当到达栈容量的最大值的时候就会出现栈溢出的情况。Windows通常会为用户模式的应用程序设定一个固定大小的栈空间。内核模式的程序有另外自己的空间。当栈空间被耗尽时就会发生溢出,递归就很容易发生这种情况。如果一直递归的调用某个函数,最后结果就是栈溢出然后出现陷阱。
Windows一般不会一次性的分配栈空间而是需要时才会去增加它。这显然是一种优化。
我们可以写一个小程序来展现栈溢出并以此来看到Windows到底为我们分配了多少栈空间:
0:000> g
(928.898): Stack overflow - code c00000fd (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00131ad8 ebx=7ffdf000 ecx=00131ad8 edx=00430df0 esi=00000000 edi=0003347c
eip=00401029 esp=00032ffc ebp=00033230 iopl=0         nv up ei pl nz ac po nc
cs=001b ss=0023 ds=0023 es=0023 fs=0038 gs=0000             efl=00010216
*** WARNING: Unable to verify checksum for COMSTRESS.exe
COMSTRESS!func+0x9:
0040102953               push    ebx
0:000> !teb
TEB at 7ffde000
    ExceptionList:        0012ffb0
    StackBase:            00130000
    StackLimit:           00031000
    SubSystemTib:         00000000
    FiberData:            00001e00
    ArbitraryUserPointer: 00000000
    Self:                 7ffde000
    EnvironmentPointer:   00000000
    ClientId:             00000928 . 00000898
    RpcHandle:            00000000
    Tls Storage:          00000000
    PEB Address:          7ffdf000
    LastErrorValue:       0
    LastStatusValue:      0
    Count Owned Locks:    0
    HardErrorMode:        0
0:000> ? 130000 - 31000
Evaluate expression: 1044480 = 000ff000
0:000>
简单的调用!teb就能做到这一点。这个命令会列出TEB(Thread Enviroment Block)所有的元素。用栈的极限值减去栈的初始地址就能的到栈的大小:1044480比特。
栈下溢
栈下溢与是栈溢出相反的情况。有时候你可能错误的认为栈上有更多的参数,所以你一直作出栈操作,但实际上并没有那么多,当到达栈底部时,继续作出栈操作就会出现栈下溢。
栈溢出和栈下溢
栈溢出和栈下溢其实都是程序流程无法与实际的堆栈保持一致而引起的崩溃。当函数清理了过多的堆栈数据然后返回时就会出现下溢,因为栈此时是错乱的,函数会返回到错误的地址。而栈发生混乱的原因就是你认为栈上有更多的数据但实际情况并非如此。
相反的情况也可能发生。如果你认为栈上并没有那么多数据,然后只想当然的清理了其中的一部分就返回了。当返回时陷阱就发生了,因为也会返回到错误的地址。
调试器是怎样实现栈跟踪的
我们进入下一个话题,栈跟踪是怎么实现的?我的第一回答是使用符号。符号可以告诉调试器栈上有多少参数,多少局部变量等等。这样调试器就能决定怎么遍历栈以及怎么显示栈的内容。
在没有符号的情况下,调试器会使用基址指针。基址指针其实是保存了前一个基址的地址,而基址指针+4则指向返回地址。这样调试器也可以遍历堆栈。如果大家都是用EBP,那堆栈跟踪就会很完美。调试器不知道当前有多少参数,它只是将参数应该存在位置的内容显示出来,至于那是不是正确的参数就靠你自己来判断了。
下面是第一篇中使用的一个简单的例子:
0:000> kb
ChildEBP RetAddr Args to Child
0012fef4 77c3e68d 77c5aca0 00000000 0012ff44 MSVCRT!_output+0x18
0012ff38 00401044 00000000 77f944a8 00000007 MSVCRT!printf+0x35
0012ff4c 00401147 00000001 00323d70 00322ca8 temp!main+0x44
0012ffc0 77e814c7 77f944a8 00000007 7ffdf000 temp!mainCRTStartup+0xe3
0012fff0 00000000 00401064 00000000 78746341 kernel32!BaseProcessStart+0x23
ESP将会指向本地的变量,所以我们下面dump一下EBP。这里我会使用“DDS”(“Dump Dwords with Symbols”)命令,调试器会用最接近的符号来匹配栈上的值,并显示出来。
这里EBP的当前值是0012fef4,还记得吗,这个值指向栈上的某个位置--前一个EBP的值。而EBP+4==返回值,EBP+8==参数。下面是栈遍历的情况,黑体部分是每一个EBP的值。
[Stack Address | Value | Description]
0012fef4 0012ff38
0012fef8 77c3e68d MSVCRT!printf+0x35
0012fefc 77c5aca0 MSVCRT!_iob+0x20
0012ff00 00000000
0012ff04 0012ff44
0012ff08 77c5aca0 MSVCRT!_iob+0x20
0012ff0c 00000000
0012ff10 000007e8
0012ff14 7ffdf000
0012ff18 0012ffb0
0012ff1c 00000001
0012ff20 0012ff0c
0012ff24 0012f8c8
0012ff28 0012ffb0
0012ff2c 77c33eb0 MSVCRT!_except_handler3
0012ff30 77c146e0 MSVCRT!`string'+0x16c
0012ff34 00000000
0012ff38 0012ffc0
0012ff3c 00401044 temp!main+0x44
0012ff40 00000000
0012ff44 77f944a8 ntdll!RtlpAllocateFromHeapLookaside+0x42
0012ff48 00000007
0012ff4c 00000000
0012ff50 00401147 temp!mainCRTStartup+0xe3
0012ff54 00000001
0012ff58 00323d70
0012ff5c 00322ca8
0012ff60 00403000 temp!__xc_a
0012ff64 00403004 temp!__xc_z
0012ff68 0012ffa4
0012ff6c 0012ff94
0012ff70 0012ffa0
0012ff74 00000000
0012ff78 0012ff98
0012ff7c 00403008 temp!__xi_a
0012ff80 0040300c temp!__xi_z
0012ff84 77f944a8 ntdll!RtlpAllocateFromHeapLookaside+0x42
0012ff88 00000007
0012ff8c 7ffdf000
0012ff90 c0000005
0012ff94 00323d70
0012ff98 00000000
0012ff9c 8053476f
0012ffa0 00322ca8
0012ffa4 00000001
0012ffa8 0012ff84
0012ffac 0012f8c8
0012ffb0 0012ffe0
0012ffb4 00401210 temp!except_handler3
0012ffb8 004020d0 temp!&#8962;MSVCRT_NULL_THUNK_DATA+0x80
0012ffbc 00000000
0012ffc0 0012fff0
0012ffc4 77e814c7 kernel32!BaseProcessStart+0x23
0012ffc8 77f944a8 ntdll!RtlpAllocateFromHeapLookaside+0x42
0012ffcc 00000007
0012ffd0 7ffdf000
0012ffd4 c0000005
0012ffd8 0012ffc8
0012ffdc 0012f8c8
0012ffe0 ffffffff
0012ffe4 77e94809 kernel32!_except_handler3
0012ffe8 77e91210 kernel32!`string'+0x98
0012ffec 00000000
0012fff0 00000000
0012fff4 00000000
0012fff8 00401064 temp!mainCRTStartup
可以看出,EBP指向0012fef4,而它的值指向前一个EBP也就是0012ff38。EIP==77c3f10b,它的符号是MSVCRT!_output+0x18。然后看一下参数,也就是EBP+8,使用“KB”命令可以显示栈上的前3个参数,但它不确保这些参数都是正确的。如果想看到剩下的,我们可以从栈上找一下,然后dump出来。
0012fefc 77c5aca0 MSVCRT!_iob+0x20
0012ff00 00000000
0012ff04 0012ff44
所以栈上第一个函数组合起来是这样的:
MSVCRT!_output+0x18(77c5aca0, 00000000, 0012ff44);
而EBP+4是返回地址,也就是第二个函数的地址,但这并不是该函数的首地址。调试器只能做到将返回地址与调用函数绑定在一起显示。调用函数如下:
0012fef8 77c3e68d MSVCRT!printf+0x35
然后来到前一个EBP处,0012ff38,加上8就可以得到它传递的参数:
0012ff40 00000000
0012ff44 77f944a8 ntdll!RtlpAllocateFromHeapLookaside+0x42
0012ff48 00000007
所以这个函数模样如下:
MSVCRT!printf+0x35(00000000, 77f944a8, 00000007);
可以看到,如果符号是不全的,返回的信息也是错误的。所以在解析这些值的时候,我们必须自己审视一下它们的正确性。
0012ff38的值是0012ffc0,也就是前一个EBP。这个函数的返回值是:
0012ff3c 00401044 temp!main+0x44
在0012ffc0+8处是它的参数。当然,这都是在EBP第一个被压栈的前提下。如果调试器足够智能,它也可以跳过一些值知道它找到第一个可辨识的符号然后把它当作返回值。这种情况发生在EBP压栈之前在保存了其它值。
这个函数的情形如下:
0012ffc8 77f944a8 ntdll!RtlpAllocateFromHeapLookaside+0x42
0012ffcc 00000007
0012ffd0 7ffdf000
 
temp!main+0x44(77f944a8, 00000007, 7ffdf000)
再来到0012ffc0处,+4是他的返回地址:
0012ffc4 77e814c7 kernel32!BaseProcessStart+0x23
EBP=0012ffc0,它指向前一个EBP,0012fff0,于是来到0012fff0处,它加上8就是传递的参数:
0:000> dds 0012fff0
0012fff0 00000000
0012fff4 00000000 <-- Previous return value is NULL so stop here.
0012fff8 00401064 temp!mainCRTStartup <-- + 8
0012fffc 00000000
00130000 78746341
 
kernel32!BaseProcessStart+0x23(00401064, 00000000, 78746341)
现在工作似乎结束了,因为出现了一个返回地址是NULL的函数。这样我们就手工生成了当前的栈:
MSVCRT!_output+0x18            (77c5aca0, 00000000, 0012ff44);
MSVCRT!printf+0x35             (00000000, 77f944a8, 00000007);
temp!main+0x44                 (77f944a8, 00000007, 7ffdf000);
kernel32!BaseProcessStart+0x23 (00401064, 00000000, 78746341);
而调试器的栈跟踪显示是:
ChildEBP RetAddr Args to Child
0012fef4 77c3e68d 77c5aca0 00000000 0012ff44 MSVCRT!_output+0x18
0012ff38 00401044 00000000 77f944a8 00000007 MSVCRT!printf+0x35
0012ff4c 00401147 00000001 00323d70 00322ca8 temp!main+0x44
0012ffc0 77e814c7 77f944a8 00000007 7ffdf000 temp!mainCRTStartup+0xe3
0012fff0 00000000 00401064 00000000 78746341 kernel32!BaseProcessStart+0x23
还是有差别的,为什么?因为我们一直以来都遵循一条规则:EBP总是指向前一个EBP。而且我们也没有使用符号来帮助我们遍历栈。如果删除这个程序的符号,用调试器的得到的栈如下:
0:000> kb
ChildEBP RetAddr Args to Child
0012fef4 77c3e68d 77c5aca0 00000000 0012ff44 MSVCRT!_output+0x18
0012ff38 00401044 00000000 77f944a8 00000007 MSVCRT!printf+0x35
WARNING: Stack unwind information not available. Following frames may be wrong.
0012ffc0 77e814c7 77f944a8 00000007 7ffdf000 temp+0x1044
0012fff0 00000000 00401064 00000000 78746341 kernel32!BaseProcessStart+0x23
0:000>
现在就跟我们的一模一样了。所以调试器会使用符号信息来更精准的显示栈的情况。如果没有符号的话,有一些函数调用就找不到了。也就是说,在符号错误、缺失或不全的情况下,栈跟踪是不值得信赖的。
后面的文章中我会向大家解释符号的含义然后去验证它。但在本文中我将向大家介绍验证函数调用的小技巧。
正如我们所看到的,我们会丢失一些函数。那怎么样才能确认他们被调用过呢?
检查函数调用
这次我重新运行了程序,然后得到了下面的堆栈:
0:000> kb
ChildEBP RetAddr Args to Child
0012fef4 77c3e68d 77c5aca0 00000000 0012ff44 MSVCRT!_output+0x18
0012ff38 00401044 00000000 00000000 00000000 MSVCRT!printf+0x35
WARNING: Stack unwind information not available. Following frames may be wrong.
0012ffc0 77e814c7 00000000 00000000 7ffdf000 temp+0x1044
0012fff0 00000000 00401064 00000000 78746341 kernel32!BaseProcessStart+0x23
0:000>
有一些值和先前不一样了。确实会发生这种情况,不能保证每次运行程序栈都是一模一样的。
第一个返回地址是77c3e68d
如果反汇编这个地址,会得到下面的东西:
0:000> u 77c3e68d
MSVCRT!printf+0x35:
77c3e68d 8945e0           mov     [ebp-0x20],eax
77c3e690 56               push    esi
77c3e691 ff75e4           push    dwordptr [ebp-0x1c]
将它标注一下:
<address> <opcode> <assembly instruction in english or mnemonic>
 
77c3e691 ff75e4 pushdwordptr [ebp-0x1c]
 
77c3e691 == Address
ff75e4   == Opcode or machine code. This is what the CPU understands
pushdwordptr [ebp-0x1c] == Assembly instruction in english. The mnemonic.
那什么是返回值?返回值是函数被调用后要执行的下一条指令。因此,如果我们一点一点的将这个值变小,我们最终会反汇编到函数调用的指令。我所说的技巧就是这样的。需要注意的是,Intel的机器码是可变的。也就是说,他们不是固定大小的,如果从某个指令的中间截断进行反汇编就会得到完全不同的指令。所以我们必须靠猜测,通常来说,如果减去的这个值足够大,我们就能倒回到合适的地方反汇编出合适的指令。
0:000> u 77c3e68d - 20
MSVCRT!printf+0x15:
77c3e66d bdadffff59       mov     ebp,0x59ffffad
77c3e672 59               pop     ecx
77c3e673 8365fc00         and     dwordptr [ebp-0x4],0x0
77c3e677 56               push    esi
77c3e678 e8c7140000       call    MSVCRT!_stbuf (77c3fb44)
77c3e67d 8945e4           mov     [ebp-0x1c],eax
77c3e680 8d450c           lea     eax,[ebp+0xc]
77c3e683 50               push    eax
0:000> u
MSVCRT!printf+0x2c:
77c3e684 ff7508           push    dwordptr [ebp+0x8]
77c3e687 56               push    esi
77c3e688 e8660a0000       call    MSVCRT!_output (77c3f0f3)
77c3e68d 8945e0           mov     [ebp-0x20],eax
看到了吗?返回地址是77c3e68d,因此77c3e688就是该函数的调用。所以我们可以确定_output是正确的函数调用。想再试一次吗?
栈上列出的下一个返回地址是00401044。我们用同样的方法做一次:
0:000> u 00401044 - 20
temp+0x1024:
004010242408             and     al,0x8
0040102657               push    edi
0040102750               push    eax
00401028 6a04             push    0x4
0040102a6820304000       push    0x403020
0040102f56               push    esi
00401030 ff1500204000     call    dwordptr [temp+0x2000 (00402000)]
0040103656               push    esi
0:000> u
temp+0x1037:
00401037 ff1508204000     call    dwordptr [temp+0x2008 (00402008)]
0040103d 57               push    edi
0040103e ff1510204000     call    dwordptr [temp+0x2010 (00402010)]
0040104459              pop     ecx
太不幸了,这就是汇编,我们得到的是一个函数指针。它的意思是,函数地存放在00402010地址处。
用“DD”看一下00402010的内容:
0:000> dd 00402010
00402010 77c3e658
77c3e658就是函数的地址,所以反汇编这个地址:
0:000> u 77c3e658
MSVCRT!printf:
77c3e658 6a10             push    0x10
我们看到了printf的符号,这正是我们调用的函数。
下一个返回地址是77e814c7,看一下这次我们找到的是直接调用还是函数指针。
0:000> u 77e814c7 - 20
kernel32!BaseProcessStart+0x3:
77e814a7 1012             adc     [edx],dl
77e814a9 e977e8288e       jmp     0610fd25
77e814ae ffff             ???
77e814b0 8365fc00         and     dwordptr [ebp-0x4],0x0
77e814b4 6a04             push    0x4
77e814b6 8d4508           lea     eax,[ebp+0x8]
77e814b9 50               push    eax
77e814ba 6a09             push    0x9
0:000> u
kernel32!BaseProcessStart+0x18:
77e814bc 6afe             push    0xfe
77e814be ff159c13e677 calldwordptr [kernel32!_imp__NtSetInformationThread (77e
6139c)]
77e814c4 ff5508           call    dwordptr [ebp+0x8]
77e814c7 50               push    eax
它在调用第一个参数指向的函数。而第一次个参数是:
0012fff0 00000000004010640000000078746341 kernel32!BaseProcessStart+0x23
 
0:000> u 00401064
temp+0x1064:
0040106455               push    ebp
00401065 8bec             mov     ebp,esp
00401067 6aff             push    0xff
看起来它在调用temp里面的某个东西。但我们不能肯定它就是我们在栈上看到的那个函数。还记得吗,printf的调用是在:
0040103e ff1510204000     call    dwordptr [temp+0x2010 (00402010)]
这就意味着我们必须从一开始就反汇编直到0401064处。或者用另外一种方法:使用DDS然后找到栈上是否有证明这个函数的符号。
如果对EBP使用DDS的话,我们看到:
0:000> dds ebp
0012fef4 0012ff38
0012fef8 77c3e68d MSVCRT!printf+0x35
0012fefc 77c5aca0 MSVCRT!_iob+0x20
0012ff00 00000000
0012ff04 0012ff44
0012ff08 77c5aca0 MSVCRT!_iob+0x20
0012ff0c 00000000
0012ff10 000007e8
0012ff14 7ffdf000
0012ff18 0012ffb0
0012ff1c 00000001
0012ff20 0012ff0c
0012ff24 ffffffff
0012ff28 0012ffb0
0012ff2c 77c33eb0 MSVCRT!_except_handler3
0012ff30 77c146e0 MSVCRT!`string'+0x16c
0012ff34 00000000
0012ff38 0012ffc0
0012ff3c 00401044 temp+0x1044
0012ff40 00000000
0012ff44 00000000
0012ff48 00000000
0012ff4c 00000000
0012ff50 00401147 temp+0x1147
0012ff54 00000001
0012ff58 00322470
0012ff5c 00322cf8
0012ff60 00403000 temp+0x3000
0012ff64 00403004 temp+0x3004
0012ff68 0012ffa4
0012ff6c 0012ff94
0012ff70 0012ffa0
0:000> dds
0012ff74 00000000
0012ff78 0012ff98
0012ff7c 00403008 temp+0x3008
0012ff80 0040300c temp+0x300c
0012ff84 00000000
0012ff88 00000000
0012ff8c 7ffdf000
0012ff90 00000001
0012ff94 00322470
0012ff98 00000000
0012ff9c 8053476f
0012ffa0 00322cf8
0012ffa4 00000001
0012ffa8 0012ff84
0012ffac e3ce0b30
0012ffb0 0012ffe0
0012ffb4 00401210 temp+0x1210
0012ffb8 004020d0 temp+0x20d0
0012ffbc 00000000
0012ffc0 0012fff0
0012ffc4 77e814c7 kernel32!BaseProcessStart+0x23
栈上有许多TEMP+xxxx。其中用黑体标出的那个是printf()的返回地址。00401064是这个函数的开始地址。哪个更接近呢?
这里我们需要猜一下。如果你认为栈上的函数不会跳回去的话,你就应该找比这个值大的。然后需要将这些指令一个一个的反汇编,但从哪里开始呢?我的建议是从最接近的符号开始。如下:
0012ff50 00401147 temp+0x1147 
 
0:000> u 00401147 - 20
temp+0x1127:
0040112740               inc     eax
00401128 00e8             add     al,ch
0040112a640000           add     fs:[eax],al
0040112d 00ff             add     bh,bh
0040112f1520204000       adc     eax,0x402020
00401134 8b4de0           mov     ecx,[ebp-0x20]
004011378908             mov     [eax],ecx
00401139 ff75e0           push    dwordptr [ebp-0x20]
0:000> u
temp+0x113c:
0040113c ff75d4           push    dwordptr [ebp-0x2c]
0040113f ff75e4           push    dwordptr [ebp-0x1c]
00401142 e8b9feffff       call    temp+0x1000 (00401000)
00401147 83c430           add     esp,0x30
我们可以看到,这应该是一个合法的地址。鉴别是否是合法返回地址的方法是看它前面的指令是不是CALL。
下面这个函数:
0:000> u 00401000
temp+0x1000:
0040100051               push    ecx
0040100156               push    esi
0040100257               push    edi
00401003 33ff             xor     edi,edi
0040100557               push    edi
0040100657               push    edi
00401007 6a03             push    0x3
0040100957               push    edi
0:000> u
temp+0x100a:
0040100a57               push    edi
0040100b 6800000080       push    0x80000000
004010106810304000       push    0x403010
00401015 ff1504204000     call    dwordptr [temp+0x2004 (00402004)]
0040101b 8bf0             mov     esi,eax
0040101d 83feff           cmp     esi,0xffffffff
00401020 741b             jz      temp+0x103d (0040103d)
00401022 8d442408         lea    eax,[esp+0x8]
0:000> u
temp+0x1026:
0040102657               push    edi
0040102750               push    eax
00401028 6a04             push    0x4
0040102a6820304000       push    0x403020
0040102f56               push    esi
00401030 ff1500204000     call    dwordptr [temp+0x2000 (00402000)]
0040103656               push    esi
00401037 ff1508204000     call    dwordptr [temp+0x2008 (00402008)]
0:000>
temp+0x103d:
0040103d 57               push    edi
0040103e ff1510204000     call    dwordptr [temp+0x2010 (00402010)]
0040104459               pop     ecx
00401045 5f               pop     edi
00401046 33c0             xor     eax,eax
00401048 5e               pop     esi
0040104959               pop     ecx
0040104a c3               ret
0:000>
看起来这是个合法的函数而且它调用了printf。那么我们就反汇编原始的函数,直到到达这条指令所在的地方,来看看原始函数有没有直接调用这个CALL还是中间还有别的函数。
0:000> u 0401064
temp+0x1064:
0040106455               push    ebp
00401065 8bec             mov     ebp,esp
00401067 6aff             push    0xff
00401069 68d0204000       push    0x4020d0
0040106e 6810124000       push    0x401210
00401073 64a100000000     mov     eax,fs:[00000000]
0040107950               push    eax
0040107a64892500000000   mov     fs:[00000000],esp
0:000> u
temp+0x1081:
00401081 83ec20           sub     esp,0x20
0040108453               push    ebx
0040108556               push    esi
0040108657               push    edi
00401087 8965e8           mov     [ebp-0x18],esp
0040108a 8365fc00         and     dwordptr [ebp-0x4],0x0
0040108e 6a01             push    0x1
00401090 ff153c204000     call    dwordptr [temp+0x203c (0040203c)]
0:000>
temp+0x1096:
0040109659               pop     ecx
00401097 830d40304000ff   or    dwordptr [temp+0x3040 (00403040)],0xffffffff
0040109e 830d44304000ff   or    dwordptr [temp+0x3044 (00403044)],0xffffffff
004010a5 ff1538204000     call    dwordptr [temp+0x2038 (00402038)]
004010ab 8b0d3c304000     mov     ecx,[temp+0x303c (0040303c)]
004010b1 8908             mov     [eax],ecx
004010b3 ff1534204000     call    dwordptr [temp+0x2034 (00402034)]
004010b9 8b0d38304000     mov     ecx,[temp+0x3038 (00403038)]
0:000>
temp+0x10bf:
004010bf 8908             mov     [eax],ecx
004010c1 a130204000       mov     eax,[temp+0x2030 (00402030)]
004010c6 8b00             mov     eax,[eax]
004010c8 a348304000       mov     [temp+0x3048 (00403048)],eax
004010cd e8e1000000       call    temp+0x11b3 (004011b3)
004010d2 833d2830400000   cmp     dwordptr [temp+0x3028 (00403028)],0x0
004010d9 750c             jnz     temp+0x10e7 (004010e7)
004010db 68b0114000       push    0x4011b0
0:000>
temp+0x10e0:
004010e0 ff152c204000     call    dwordptr [temp+0x202c (0040202c)]
004010e6 59               pop     ecx
004010e7 e8ac000000       call    temp+0x1198 (00401198)
004010ec 680c304000       push    0x40300c
004010f1 6808304000       push    0x403008
004010f6 e897000000       call    temp+0x1192 (00401192)
004010fb a134304000       mov     eax,[temp+0x3034 (00403034)]
00401100 8945d8           mov     [ebp-0x28],eax
0:000>
temp+0x1103:
00401103 8d45d8           lea     eax,[ebp-0x28]
0040110650               push    eax
00401107 ff3530304000     push    dwordptr [temp+0x3030 (00403030)]
0040110d 8d45e0           lea     eax,[ebp-0x20]
0040111050               push    eax
00401111 8d45d4           lea     eax,[ebp-0x2c]
0040111450               push    eax
00401115 8d45e4           lea     eax,[ebp-0x1c]
0:000>
temp+0x1118:
0040111850               push    eax
00401119 ff1524204000     call    dwordptr [temp+0x2024 (00402024)]
0040111f6804304000       push    0x403004
004011246800304000       push    0x403000
00401129 e864000000       call    temp+0x1192 (00401192)
0040112e ff1520204000     call    dwordptr [temp+0x2020 (00402020)]
00401134 8b4de0           mov     ecx,[ebp-0x20]
004011378908             mov     [eax],ecx
0:000>
temp+0x1139:
00401139 ff75e0           push    dwordptr [ebp-0x20]
0040113c ff75d4           push    dwordptr [ebp-0x2c]
0040113f ff75e4           push    dwordptr [ebp-0x1c]
00401142 e8b9feffff       call    temp+0x1000 (00401000)
00401147 83c430           add     esp,0x30
如果你懂汇编语言,你就能简单的看懂整个的逻辑并且能断定函数是否在这里被调用了。
如果是在这里调用的,若两个函数没有使用相同的基址,那么这两个函数在栈上是缺失的。现在,现在我们能够找到它们的返回值,那也就能从栈上找到他们的参数列表。这种情况说明有些函数没有使用EBP,因此我们无法得到一个准确的栈。当这种情况发生失,我们就需要亲自去检验一下。象我们所看到的,前一个函数并没有调用printf而是另外一个函数调用了它。
00401147就是那个丢失的返回值。我们可以从栈上找到它和它的参数:
00000000
00401147 temp+0x1147
00000001
00322470
00322cf8
而先前用KB显示的栈如下:
0:000> kb
ChildEBP RetAddr Args to Child
0012fef4 77c3e68d 77c5aca0 00000000 0012ff44 MSVCRT!_output+0x18
0012ff38 00401044 00000000 00000000 00000000 MSVCRT!printf+0x35
WARNING: Stack unwind information not available. Following frames may be wrong.
0012ffc0 77e814c7 00000000 00000000 7ffdf000 temp+0x1044
0012fff0 00000000 00401064 00000000 78746341 kernel32!BaseProcessStart+0x23
0:000>
我们需要将新的发现加进去:
ChildEBP RetAddr Args to Child
0012fef4 77c3e68d 77c5aca0 00000000 0012ff44 MSVCRT!_output+0x18
0012ff38 00401044 00000000 00000000 00000000 MSVCRT!printf+0x35
WARNING: Stack unwind information not available. Following frames may be wrong.
 
xxxxxxxx 0401147 00000001 00322470 00322cf8 temp+0x1044
0012ffc0 77e814c7 00000000 00000000 7ffdf000 temp+0x1147
0012fff0 00000000 00401064 00000000 78746341 kernel32!BaseProcessStart+0x23
0:000>
再来看一下源代码,其实调用printf的是main()。所以main函数的参数argc=1,*argv[]=322470。而*argv[]是一个指向ANSI字符串的指针:
0:000> dd 322470
00322470 00322478 00000000 706d6574 ababab00
00322480 abababab feeefeab 00000000 00000000
00322490 000500c5 feee0400 00325028 00320178
003224a0 feeefeee feeefeee feeefeee feeefeee
003224b0 feeefeee feeefeee feeefeee feeefeee
003224c0 feeefeee feeefeee feeefeee feeefeee
003224d0 feeefeee feeefeee feeefeee feeefeee
003224e0 feeefeee feeefeee feeefeee feeefeee
0:000> da 00322478
00322478 "temp"
可以看出这个数组仅包含了一个字符串,可以使用“da”命令来查看这个串的内容。
栈上的多个返回地址
为什么栈上会有多个返回地址呢?通常栈会被初始化为0,但在使用过程中,它会包含越来越多的脏数据。比如,本地变量并不强制初始化,函数调用仅会引起栈增长而不会将这些值置为0。而从栈上取数据也仅会使栈从逻辑上缩小,栈上的值其实还会保留在那里直到它被物理删除。有时候,栈会做一些优化处理而不会清除栈上的变量。所以,从栈上发现“幽灵”数据是很正常的。
但将数据一直保留在栈上有时候并不好。比如,如果你的程序将一个密码放在了栈上,后来这个程序出错了。如果dump栈的话仍旧能看到这个密码。所以,当我们在栈上放置了敏感数据时,我们就希望在返回之前清理这些数据。调用SecureZeroMemory()API函数可以做到这一点。其它的清理内存的函数可能使用了优化,但这个API函数可以保证内存被安全的清理,它会告知编译器这个变量不再使用了,不需要使用任何优化。
缓冲区溢出
缓冲区溢出经常会在栈上发生。在函数处理中,从内存上来讲,栈是递减的,而数组是递增的。因为,对于一个数组来说,通常是通过加操作来得到下一个元素,而不是减。看下面的程序:
{
      DWORD MyArray[4];
      int Index;
在栈上情形如下:
424 [Return Address               ]
420 [ Previous Base Pointer       ]
416 [ Local Array Variable Index 3]
412 [ Local Array Variable Index 2]
408 [ Local Array Variable Index 1]
404 [ Local Array Variable Index 0]
400 [ Local Integer Value         ]
可以看到,如果使用MyArray[4]或者MyArray[5],就会覆盖掉栈上的关键值,这就会导致陷阱。覆盖前一个EBP是很严重的,如果函数不再使用这个值了就没有问题但大多数时候肯定会引起陷阱。这就是为什么在使用局部数组时必须保证不能超过数组定义的范围。否则,可能会覆盖其它的变量、返回地址、参数等任何东西。
Windows2003
Windows2003引入一个新的方法来防止缓冲区溢出。在VS.NET中使用GS标志编译程序就能使用这个方法。在程序启动时回产生一个随机的cookie,函数调用时,这个cookie被用来与返回值进行XOR操作,然后将这个值放在EBP的后面,象下面的例子:
[Return Address            ]
[Previous Base Pointer     ]
[Cookie XOR Return Address ]
在返回时,再用cookie与返回值进行XOR然后与先前放入栈中的那个值进行比较,如果时一样的,说明一切正常,函数就会返回。如果不一样,就说明在函数处理期间发生了异常。其实,这样做的原因不是为了防止程序因为异常而崩溃,而是为了防止程序执行注入的代码。这是一个很大的安全漏洞,当其它程序插入恶意的代码并知道怎样将程序的缓冲区溢出的时候,程序就会转到执行恶意代码。
总结
这篇文章可能会让初学者感到困惑而让有经验者感到厌倦。但将复杂问题简单化是很难的,我已经尽力而为了。不管你是否喜欢,留下一些评论吧,如果你不想让我继续写下去了也直接告诉我吧。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值