为了正常的体验网站,请在浏览器设置里面开启Javascript功能!
首页 > 认识到堆栈破坏的原因以及分析的方法

认识到堆栈破坏的原因以及分析的方法

2018-09-24 5页 doc 571KB 66阅读

用户头像 个人认证

Sky

暂无简介

举报
认识到堆栈破坏的原因以及分析的方法第五章MemoryCorruption第一部分:堆栈有两个原因导致内存破坏成为最棘手的程序出错类型之一。首先,破坏的源头和现象可能相隔很远,很难将原因和结果关联起来。其次是由于只有在比较罕见的条件下症状才显露出来,使得要一致的重现错误比较困难。原则上只要满足以下两个条件中的任意一个,就会发生内存破坏。·线程对一块不属于它的内存进行写操作。·线程对属于它自己的内存进行写操作时,破坏了该内存的状态。这里有个小程序可以作为展示第一个条件的例子。#include<windows.h>#defin...
认识到堆栈破坏的原因以及分析的方法
第五章MemoryCorruption第一部分:堆栈有两个原因导致内存破坏成为最棘手的程序出错类型之一。首先,破坏的源头和现象可能相隔很远,很难将原因和结果关联起来。其次是由于只有在比较罕见的条件下症状才显露出来,使得要一致的重现错误比较困难。原则上只要满足以下两个条件中的任意一个,就会发生内存破坏。·线程对一块不属于它的内存进行写操作。·线程对属于它自己的内存进行写操作时,破坏了该内存的状态。这里有个小程序可以作为展示第一个条件的例子。#include<windows.h>#defineBAD_ADDRESS0xBAADF00Dint__cdeclwmain(intargc,wchar_t*pArgs[]){char*p=(char*)BAD_ADDRESS;*p=’A’;return0;}上面的小程序先声明了一个char型指针,然后对其初始化,赋给其一个不可访问的地址(0xBAADF00D)。运行该程序的最终结果就是程序崩溃,紧接着弹出可怕的Dr.Watson(译注)。很明显这是因为这个小程序中执行了一次无效的内存访问导致,但是在很复杂的系统中要指出错误很麻烦。例如应用程序分配了一块内存,并且了其生命周期。如果过早的释放了它,失效的指针访问就会导致内存破坏。应用程序对不属于自己的内存进行写访问会导致程序崩溃,这是最好的情况。等等!!读者看到这里可能会问:你是说程序崩溃是最好的情况?!没错,对于内存破坏来说,发生崩溃的话也许能立即指明发生内存破坏的原因。就像上面的小程序一样,由于被写的内存无效,所以立即发生了崩溃。这是个好消息,因为我们很轻松的就看到了错误原因:一个指针指向了无效的内存地址。再看看第2种情况,如果无效指针指向的是属于程序中别的部分分配的内存的话,可能出现如下几种症状:·程序崩溃:跟前面的程序崩溃的主要区别在于发生的时间会延后一些。上面的示例程序因为尝试写一块被操作系统认为无效的内存导致了崩溃。第2种情况下,应用程序尝试写入的是操作系统认为有效的内存,所以允许其写入,没有错误发生。随后应用程序可能会试着使用被错误改写过的内存,也许就会崩溃(依赖于内存访问的性质)。·不会崩溃,但是有意料之外的行为:由于之前写了无效数据到其他部分所拥有的内存中,不一定程序就会崩溃。这种情况相当多。应用程序的其他部分会继续使用被写入异常数据的内存,甚至内存的状态都已经被修改过(通常情况下不会发生这种状况)。看个例子,假设有个线程池的类,除了能对线程池的请求排队外,还有个用于设置一个标志以控制流程结束。线程池周期性的检查该标志,一旦发现该标志为TRUE则停止工作。应用程序会初始化出该线程池的一个单体对象来使用。现在假设线程池正在处理200个请求(信用卡授权)时,一个线程错误的将标志设置为TRUE了。于是线程池突然间就停止工作,客户在用信用卡交易时发生错误,电话铺天盖地的响起来……这是典型的内存破坏的例子:线程破坏内存最终导致发生不可预料的行为。由于修改内存的线程已经对内存数据造成了损害,随后使用这块内存有时(通常总是)无法预料。要找到这些类型的内存破坏的根源那是相当的难啊。译注:如果你发现没有弹出Dr.Watson对话框也不要太惊讶。实际上依赖于注册表中的一个键值:HKEY_LOCAL_MECHINE\SOFTWARE\Microsoft\WindowsNT\CurrentVersion\AeDebug。其中包括Auto,Debugger和UserDebuggerHotKey三个值。默认情况下Debugger的内容为"drwtsn32-p%ld-e%ld",指出系统的默认调试器为Dr.Watson。Auto键值为0或1。为0时表示系统不自动处理,当有应用程序崩溃时会弹出一个对话框用户;当Auto为1时,系统就自动调用调试器发生错误的程序的相关信息然后退出,不通知用户。UserDebuggerHotKey是设置一个快捷键用来发送一个DebugBreak()调用,就好像进断点了一样,前提是程序是由调试器加载起来的。内存破坏诊断流程本节讲述一下内存破坏问的处理流程。下面的流程图简单描述了每一个步骤。有一点很重要:要找到内存破坏问题的根源,对于不同的状况,可能需要反复执行Figure5.1中的流程才行。Figure5.1内存破坏分析过程步骤1:状态分析在开始研究内存破坏的问题前,首先应该确保你当前发现到的错误确实是因为内存破坏的缘故导致的。这个步骤还可以进一步分解,如Figure5.2Figure5.2状态分析过程如同前面提到的,内存破坏的特征无非就是这两种:程序崩溃或者不崩溃但是行为异常。最开始可以通过分析被破坏的内存的状态来对其行为进行初步的分析。那么我们如何知道要分析哪些状态呢?随着程序崩溃,寻找起点就(译注:这里的起点应该是指代码中程序崩溃的位置吧)十分简单了。由于一些未预期的状态导致程序中的代码执行崩溃,而在崩溃的时候的代码是已知的。通过观察程序发生崩溃时的内存状态以及对相关代码的检视,我们可以对原始状态作出准确的判断。“没问题”,尽管问题多多,但是代码执行路径可以产生当前状态。如果你遇到的是这种情况,就其本身而言,这不是内存破坏问题,但仍可能是由于未预期的代码执行路径写内存错误。然而,如果没有代码执行路径能令内存变成当前状态,唯一貌似可能的解释就是发生了内存破坏,内存被改写了。如果你遇到的不是应用程序崩溃,而是发生周期性的奇特行为的话,要找到被偷偷破坏的内存可就没程序崩溃时那么简单明了。一般来说,当程序发生异常行为时,你最好用调试器中断程序以进行些初步的分析。看个例子,如果客户在进行信用卡认证时不断的发生错误,你应该赶紧检查线程池(线程池处理所有的信用卡认证)的状态,看看为啥会失败。如果你注意到线程池已经停止工作,根本不接受请求的话,那就进行第2步:源代码分析,确认一条“合法”的(译注:这里的合法是指线程池合法停止工作)代码执行路径或者(如果不存在这样的路径的话)做结论:发生了内存破坏。步骤2:源代码分析根据步骤一确认自己可能面临一个内存破坏问题后,接下来就是进行代码分析,看是否能找到其根源。当线程对不属于自己的内存块写数据时肯能会发生内存破坏。照这么说,我们就有了一个重要的方向。线程向内存块写入数据,可以推测写入的数据应该与某个特定的线程相关。就这点来说,如果我们能够分析出这些数据的含义,那么就能进一步缩小可怀疑的范围。来看个例子,Listing5.1是一个非常简单的控制台程序,该程序给用户提供两个选项:1.显示应用程序信息(比如全名和版本信息),2.模拟内存破坏。读者可以试着不看完整源代码,只看看下表列出的部分。Listing5.1模拟内存破坏的简单控制台程序int__cdeclwmain(intargc,wchar_t*pArgs[]){wint_tiChar=0;g_AppInfo=newCAppInfo(L”Simpleconsoleapplication”,L”1.0”);if(!g_AppInfo){return1;}wprintf(L”Press:\n”);wprintf(L”1Todisplayapplicationinformation\n”);wprintf(L”2Tosimulatememorycorruption\n”);wprintf(L”3Toexit\n”);wprintf(L”\n\n>“);while((iChar=_getwche())!=’3’){if(iChar==‘1’){g_AppInfo->PrintAppInfo();}elseif(iChar==’2’){SimulateMemoryCorruption();wprintf(L”\nmemorycorruptioncompleted\n”);}else{wprintf(L”\nInvalidoption\n”);}wprintf(L”\n\n>“);}deleteg_AppInfo;return0;}Listing5.1完整的源代码和执行程序存放在下列位置:SourceCode:C:\AWD\Chanpter5\MemCorruptBinary:C:\AWDBIN\WinXP.x86.chk\05MemCorrupt.exe通过下面的命令行执行该程序:C:\AWDBIN\WinXP.x86.chk\05MemCorrupt.exe此程序由一个封装了应用自定义信息(包括完整的应用名称和版本信息)的类组成。主函数允许用户输出自定义信息、模拟内存破坏或者退出。Press:1Forapplicationinformation2Forsimulatedmemorycorruption3Toexit按1的话,显示如下:>1FullapplicationName:SimpleconsoleapplicationVersion:1.0按2的话,显示如下:>2Memorycorruptioncompleted如果你再按1的话,不必惊讶,程序会崩溃的。现在开始变得有趣了。我们要如何才能找出是程序中哪部分导致的崩溃呢(withoutsteppingthroughthecodeforstep2)?最开始,在调试器下运行该程序,选择跟刚才相同的选项序列。当你第二次选择选项1时,调试器会因为访问为例而中断程序。………0:000>gModLoad:5cb700005cb96000C:\WINDOWS\system32\ShimEng.dllPress:1Todisplayapplicationinformation2Tosimulatememorycorruption3Toexit>1FullapplicationName:SimpleconsoleapplicationVersion:1.0>2Memorycorruptioncompleted>1(bdc.8d8):Accessviolation-codec0000005(firstchance)Firstchanceexceptionsarereportedbeforeanyexceptionhandling.Thisexceptionmaybeexpectedandhandled.eax=72726f43ebx=7ffd0073ecx=00000007edx=7ffffffeesi=00000020edi=00000002eip=77c43869esp=0007fa68ebp=0007fed8iopl=0nvupeiplnznaponccs=001bss=0023ds=0023es=0023fs=003bgs=0000efl=00010202msvcrt!_woutput+0x695:77c4386966833800cmpwordptr[eax],0ds:0023:72726f43=????0:000>kbChildEBPRetAddrArgstoChild0007fed877c4229077c5fca0010012080007ff28msvcrt!_woutput+0x6950007ff1c010014480100120872726f4300032cb0msvcrt!wprintf+0x350007ff30010013b200032cb000032cb07ffd0031memcorrupt!CAppInfo::PrintAppInfo+0x180007ff44010015fa0000000100032bf00003688005memcorrupt!wmain+0xb20007ffc07c816fd7000119707c9118f17ffdf00005memcorrupt!wmainCRTStartup+0x12f0007fff000000000010014cb0000000078746341kernel32!BaseProcessStart+0x23我们从堆栈中能看到主函数调用了CAppInfo::PrintAppInfo函数,该函数再调用了wprintf函数。结合源代码以及调试器中看到的,看起来非常合理。接下来的问题是为什么wprintf函数会出错。看看源代码中给wprintf函数传入了什么:VOIDPrintAppInfo(){wprintf(L"\nFullapplicationName:%s\n",m_wszAppName);wprintf(L"Version:%s\n",m_wszVersion);}一个合理的解释就是我们传入的两个指针(m_wszAppNme和m_wszVersion)必定无效。wsprintf函数假定我们传入的指针(这里是字符串类型)指向的是以NULL结尾的宽字符串。如果这个假定部成立,那么函数就可能会崩溃。现在我们再将注意力转过来分析一下有问题的对象的状态。具体的看一下CAppInfo的状态:0:000>X05memcorrupt!g_*0100200805memcorrupt!g_AppInfo=0x00032cb00:000>dtCAppInfo0x00032cb0+0x000m_wszAppName:0x72726f43->??+0x004m_wszVersion:0x01747075->??看看我们感兴趣的两个指针m_wsaAppName和m_wszVersion各自指向什么内容:0:000>dd0x72726f4372726f43????????????????????????????????72726f53????????????????????????????????72726f63????????????????????????????????72726f73????????????????????????????????72726f83????????????????????????????????72726f93????????????????????????????????72726fa3????????????????????????????????72726fb3????????????????????????????????0:000>dd0x0174707501747075????????????????????????????????01747085????????????????????????????????01747095????????????????????????????????017470a5????????????????????????????????017470b5????????????????????????????????017470c5????????????????????????????????017470d5????????????????????????????????017470e5????????????????????????????????上面的问号表明这块内存不可访问。有趣吧~~第一次让程序显示信息,一切都挺正常的。而现在这些指针都指向了不可访问的位置。不知为啥CAppInfo对象的内容被破坏了。简单C++类对象的内存布局由其成员变量构成,在上面的例子中就是两个指针。如果内存对象被改写,我们就陷入了拥有两个被破坏的指针的情形。在此基础上,这两指针指向什么就值得看看了:0:000>x05memcorrupt!g_*0100200805memcorrupt!g_AppInfo=0x00032cb00:000>dd0x00032cb000032cb072726f4301747075abababababababab00032cc0000000000000000000040012001c07f200032cd0005000410044005000540041003d004100032ce0003a00430044005c0063006f006d007500032cf0006e006500730074006100200064006e00032d000053002000740065006900740067006e00032d10005c00730061006d006900720068006f00032d200041005c007000700069006c00610063这次内存dump看到的仍然是我们之前看到的指针值。不用dd命名了,尝试将指针(0x00032cb0)当成字符串指针看看:0:000>da0x00032cb000032cb0“Corrupt.........”越来越有趣了!看起来CAppInfo类对象的成员指针被改写为“Corrupt”。现在我们通过检视代码来看看程序中有没有代码维护一个内容为“Corrupt”的字符串。正如你猜想的一样,在我们选择第2个选项(模拟内存破坏)时,程序强制将CAppInfo对象的成员指针的内容改写为了(“Corrupt”)。  那么我们如何知道要用何种形式来dump数据才能弄清其含义呢?没有明显的规则可循,只有大致方向。下面的几个策略在进行内存分析时可以试试:1.使用dc命令转储出指针指向的内存数据。此命令以Double-word的形式转储数据,同时也显示对应的ASCII。如果看到输出中有字符串,那就用da或者du命令显示这些字符串。2.使用!address扩展命令来收集关于内存的数据。此命令能为你提供内存的类型(比如私有)、保护级别(比如读或写)、状态(比如提交或保留)以及用途(比如堆或堆栈)。3.使用dds命令以双字(double-word)的形式转储内存数据以及符号,帮助我们将内存和符号对应起来。4.使用dpp命令来解引用给定的指针,以双字的形式显示内存数据。如果有任何一个双字匹配上一个符号,那么符号也会相应的显示出来。这个技巧在内存指针包含虚函数表时很有用。5.使用dpa或dpu命令将内存以ASCII或者UNICODE的形式转储出来。6.如果内存数据是个比较小的数字(是4的倍数),这可能是一个句柄。可以用!handle命令来转储该句柄的信息。7.如果前面的步骤都没有得到信息,可以尝试搜索整个地址空间来寻找对这个内存地址的引用。这种在被破坏的内存中识别数据的技巧在试图找出破坏内存的错误代码时非常有用。但是,再说一遍,这个技巧不可能总会找到罪犯(offender)。下一步是使用内存破坏诊断工具,这样你就轻松多了。步骤3:使用内存破坏诊断工具在开始讲这些工具前,明白这些工具不会对捕获内存破坏提供保证是很重要的。这些工具只是能够捕获一些很常见的内存破坏情况。针对不同的内存破坏类型,使用的工具也不同。对于堆栈的破坏,最好的工具就是编译器。它会在你的程序中插入一些堆栈校验的代码。对于堆被破坏的情况,最好的工具是ApplicationVerifier(见第一章工具介绍)。ApplicationVerifier有大量关于内存破坏的测试设置。所有这些工具的共同之处是企图在内存破坏刚发生时,立即捕获常规的内存相关的程序错误,而不是等到发生很多棘手的副作用之后。在本章后面将会看到编译器在堆栈破坏方面是如何帮助我的。在第6章"内存破坏PartII-堆"中再使用ApplicationVerifier来分析基于堆的破坏。步骤4:InstrumentSourceCode如果前面几步都没能帮到你,那你就得干点苦活了。接下来你要把前面几步获得的信息以及可能的推测都收集起来,当你想到一点可能时,就自己写代码来验证正确与否。Instrumentation技术是跟操作系统支持的追踪完全不同的一种简单的跟踪方式。步骤4:定义回避策略最后,可以说是最重要的,利用你学到的知识定义一个深入的回避策略。回避策略可以引入在整个开发过程中使用工具帮助捕获一般内存破坏问题的形式,同时正在编写的代码中有明显的步骤用于最小化潜在内存破坏的风险。本章余下的部分挨个儿看看一些常见的内存破坏场景。展示如何应用这些处理流程来找出隐藏在内存破坏背后的原因。本章中的场景集中基于堆栈的破坏,第6章关注基于堆破坏的情况。堆栈破坏堆栈大概是大家最常用的,也是大家都熟悉的数据结构之一。很多算法入门课都以学习堆栈数据结构作为开始。这确实是一个非常简单而又直观的数据结构,就好像一堆纸。你每放(对应push)一张纸到纸堆都是放在纸堆的顶部,而每次取走(对应pop)一张纸也都是从纸堆的顶部拿走。同样的,对堆栈执行的两个基本操作(push和pop)也总是在顶部。因为往纸堆放一张纸或者从纸堆取走一张纸都是从顶部开始,所以说这个算法有后进先出(LIFO)的语义。堆栈对于Windows系统中的执行代码而言很简单,就是操作系统分配给执行线程的一块内存。除了其他的目的外,堆栈用于跟踪函数调用链(为局部变量分配空间,参数传递等等)。任何时候有函数被调用时,就有一个堆栈帧(译注:包括传入的参数,返回地址等)被创建并保存在堆栈中。随着线程调用越来越多的函数,堆栈增长的也越来越大。Figure5.3分析了一个函数调用时的堆栈。在接下来的例子中我们会精确的看到堆栈中的每个元素是如何形成的。现在暂时看看Figure5.3中说明的在x86结构的系统中一个函数调用过程中堆栈的一般布局。Figure5.3函数调用时的堆栈分析注意如果你编译本章的代码,需要先确认在你的编译环境中已将BUFFER_OVERFLOW_CHECKS环境变量设置为0来禁用缓存区溢出检查。(译注:没见过这个环境变量,google后发现大多是跟驱动有关,猜测作者可能是DDK的编译器。)为了更好的理解堆栈是如何工作以及如何被破坏,我们来看个例子。Listing5.2的应用展示了一个会进行一些嵌套的函数调用的新线程的启动点,在每个函数中都声明了些局部变量。Listing5.2展示线程创建的应用示例#include<windows.h>#include<stdio.h>#include<conio.h>DWORDWINAPIThreadProcedure(LPVOIDlpParameter);VOIDProcA();VOIDSum(int*numArray,intiCount,int*sum);void__cdeclwmain(){HANDLEhThread=NULL;wprintf(L"Startingnewthread...");hThread=CreateThread(NULL,0,ThreadProcedure,NULL,0,NULL);if(hThread!=NULL){wprintf(L"Successfullycreatedthread\n");WaitForSingleObject(hThread,INFINITE);CloseHandle(hThread);}}DWORDWINAPIThreadProcedure(LPVOIDlpParameter){ProcA();wprintf(L"Pressanykeytoexitthread\n");_getch();return0;}VOIDProcA(){intiCount=3;intiNums[]={1,2,3};intiSum=0;Sum(iNums,iCount,&iSum);wprintf(L"Sumis:%d\n",iSum);}VOIDSum(int*numArray,intiCount,int*sum){for(inti=0;i<iCount;i++){*sum+=numArray[i];}}Listing5.2完整的源代码和执行程序存放在下列位置:SourceCode:C:\AWD\Chanpter5\StackDescBinary:C:\AWDBIN\WinXP.x86.chk\05StackDesc.exe大体概括一下Listing5.2中的代码:main函数调用CreateThreadAPI创建了一个新线程,设定了一个名为ThreadProcedure的函数作为其启动函数。这个ThreadProcedure函数也是我们研究堆栈的的起点。根据我们前面关于堆栈的讨论,线程每调用一个函数,便会有一个由执行该函数所需要的数据组成的新帧(译注:frame,即堆栈帧)被push到堆栈中。那么在我们新创建的线程堆栈中的第一个函数帧是ThreadProcedure吗?不是。在我们的线程获得机会执行ThreadProcedure之前,操作系统要为线程的创建执行一系列的函数。要知道执行的是什么的话,就编译Listing5.2中的例子吧。然后加载到调试器,在启动ThreadProcedure函数(见Listing5.3)的地方设置一个断点。输入Go命令之后调试器会停在该函数上,你就能看到执行中线程的堆栈了。Listing5.3显示新建线程的调用堆栈………0:000>X05stackdesc!*ThreadProcedure*0100121005stackdesc!ThreadProcedure(void*)0:000>bp05stackdesc!ThreadProcedure0:000>gModLoad:5cb700005cb96000C:\WINDOWS\system32\ShimEng.dllStartingnewthread...successBreakpoint0hiteax=00000000ebx=00000000ecx=002bffb0edx=7c90eb94esi=00000000edi=00030000eip=01001210esp=002bffb8ebp=002bffeciopl=0nvupeiplzrnapenccs=001bss=0023ds=0023es=0023fs=003bgs=0000efl=0000024605stackdesc!ThreadProcedure:0100121055pushebp0:001>kbChildEBPRetAddrArgstoChild002bffb47c80b68300000000000300000000000005stackdesc!ThreadProcedure002bffec00000000010012100000000000000000kernel32!BaseThreadStart+0x37可以看到ThreadProcedure确实不是第一个执行的函数,而是一个定义在kernel32.dll中的名为BaseThreadStart的函数,接着才是我们的ThreadProcedure。BaseThreadStart就是个被操作系统定义在所有的新创建线程执行之前的拦截器。现在我们已经到了线程的起点,我们亲自来近距离看看堆栈是如何组织的。如前所说,堆栈操作-如push和pop-总是从堆栈的顶部工作。那这样的话,我们就需要保存一个指针来告诉我们当前栈顶的位置。在x86架构中,esp寄存器就是用于这个目的。在我们深入验证堆栈的真实内容之前,先看一下我们函数的前几条指令。ThreadProcedure函数刚开始的汇编代码见Listing5.4。Listing5.4ThreadProcedure函数的汇编代码(译注)0:000>u05stackdesc!ThreadProcedure05stackdesc!ThreadProcedure:010012208bffmovedi,edi0100122255pushebp010012238becmovebp,esp01001225e826000000call05stackdesc!ProcA(01001250)0100122a68b0100001pushoffset05stackdesc!`string’(010010b0)0100122fff1550100001calldwordptr[05stackdesc!_imp__printf(01001050)]0100123583c404addesp,401001238ff1548100001calldwordptr[05stackdesc!_imp___getch(01001048)]译注:从代码看,这里应该调用wprintf函数才对。调试了一下附带的可执行文件05stackdesc.exe,里面也是调用的wprintf。谨慎怀疑作者调试的代码是调用的printf函数,后来改为wprintf函数的,后面多处汇编代码都这样。不过没影响☺在调用函数ProcA(汇编代码从上往下数第4条指令)前,还执行了几条有趣的指令。特别是下面几条指令在涉及分析调用堆栈的时侯很有意思。010012208bffmovedi,edi0100122255pushebp010012238becmovebp,esp第二条指令把ebp寄存器压栈(push)。后面会看到ebp寄存器是怎么使用的。而现在已经足够看出ebp寄存器总是包含指向任意给定帧的基指针。由于每个帧的基指针都要保存下来,所以在任何帧创建(即call指令)之前基指针先压栈。下一条指令保存栈指针到ebp寄存器作为新堆栈帧的开始。这三条指令形成了函数的prologue。一般来说,大多数你遇到的函数都具有如下的轮廓:■Functionprologue■Functioncode■Functionepilogue函数prologue保证为新函数的执行完全准备好了。接下来是真正的函数代码,最后函数epilogue保证在返回调用者前堆栈已经恢复到正确的状态了。现在我们已经到了准备通过call指令调用ProcA函数的地方了。当call指令执行后,堆栈的状态就更新了。更确切地说是在call指令的整个执行期间,其返回地址(即call指令之后的下一条指令地址)被push到堆栈里了。这是很必要的,因为在被调用的函数要返回时,会执行ret指令。ret指令应该返回到紧接着call指令的下一条指令的位置。为了找到这个位置,ret指令从堆栈上pop出这个地址,然后跳转过去了。Figure5.4是线程堆栈在call指令前当前的状态。Figure5.4调用ProcA函数前的堆栈内容注意,有一点很重要,在x86架构上,堆栈的增长是从高往低的。从Figure5.4就能看到堆栈的地址是如何随着数据压栈而递减的。x86的push指令分2个步骤:1.将堆栈指针(即esp)减去一个操作数大小2.将源数据传到堆栈中(Figure5.4中的ebp)Figure5.4中,esp最初指向堆栈中的0x002bffb8。当执行完push指令后,esp首先减去4个字节(0x002bffb4),然后将ebp的数据传到堆栈中的这个位置。mov指令确保ebp和esp指向堆栈中的同一个位置,即新的调用帧的基地址。这时候,堆栈已经为将执行流程转移到下一个被调用函数(ProcA)的真实call指令做好了准备。在call指令的位置输入t指令继续往下执行,跟进到下一个函数里面。一进入这个函数,我们马上反汇编出整个函数的代码,见Listing5.5Listing5.5ProcA函数的汇编代码0:000>uf05stackdesc!ProcA05stackdesc!ProcA:010012508bffmovedi,edi0100125255pushebp010012538becmovebp,esp0100125583ec14subesp,14h01001258c745ec03000000movdwordptr[ebp-14h],30100125fc745f401000000movdwordptr[ebp-0Ch],101001266c745f802000000movdwordptr[ebp-8],20100126dc745fc03000000movdwordptr[ebp-4],301001274c745f000000000movdwordptr[ebp-10h],00100127b8d45f0leaeax,[ebp-10h]0100127e50pusheax0100127f8b4decmovecx,dwordptr[ebp-14h]0100128251pushecx010012838d55f4leaedx,[ebp-0Ch]0100128652pushedx01001287e824000000call05stackdesc!Sum(010012b0)0100128c8b45f0moveax,dwordptr[ebp-10h]0100128f50pusheax0100129068d0100001pushoffset05stackdesc!`string’(010010d0)01001295ff1550100001calldwordptr[05stackdesc!_imp__printf(01001050)]0100129b83c408addesp,80100129e8be5movesp,ebp010012a05dpopebp010012a1c3retuf命令用于一次性反汇编整个函数,而不是像u命令一样,默认情况下一次仅反汇编前8条指令。ProcA函数的前四条指令是functionprologue的一部分:010012508bffmovedi,edi0100125255pushebp010012538becmovebp,esp0100125583ec30subesp,0x14前三条指令跟前一帧一样,只是简单的确保帧基指针(译注:即ebp)和堆栈指针(译注:即esp)为建立ProcA函数帧做好准备。最后一条指令(subesp,0x14)看着非常有趣,将堆栈指针减了0x14字节(或十进制的20字节)。为什么要减呢?这是在为局部变量分配空间。正如你在Listing5.5中看到的ProcA函数的源代码,它在堆栈上分配了下面的几个局部变量:intiCount=3;intiNums[]={1,2,3};intiSum=0;这些变量总共的大小为:4(iCount)+12(iNums)+4(iSum)=20字节当我们从堆栈指针中减去20字节,堆栈中表面上是空格,实际上是为声明在函数中的局部变量保留的空间。Figure5.5说明了sub指令执行之后的堆栈内容。在调整完esp堆栈指针为局部变量腾出空间后,下一组执行的指令是用源代码中给定的值对堆栈上的局部变量初始化。05stackdesc!ProcA+0x8:01001258c745ec03000000movdwordptr[ebp-14h],30100125fc745f401000000movdwordptr[ebp-0Ch],101001266c745f802000000movdwordptr[ebp-8],20100126dc745fc03000000movdwordptr[ebp-4],301001274c745f000000000movdwordptr[ebp-10h],0这些mov指令中重点要观察的是ebp寄存器带上一个偏移后就引用到局部变量在堆栈中的位置了。为什么是用ebp寄存器而不是esp呢?我们之前说过ebp寄存器总是指向调用堆栈的开端,还记得吗?这是因为总是需要这么一个参考点,利用它我们可以访问到与该帧关联的一Figure5.5为ProcA函数中的局部变量保留空间后的堆栈内容切。按惯例,我们总是把ebp寄存器用于该目的。这也是为何在创建一个新的帧之前总是要特别小心的把ebp寄存器保存到堆栈上的原因,这样的话当离开帧时(即函数返回时)就能够安全的恢复堆栈啦。相比之下,esp寄存器在函数的整个执行期间总是不断地在变化,很难(或者说已很小的代价)将它作为帧的基指针使用。帧指针省略帧指针省略是一种优化技术,可以将帧指针当作通用寄存器使用,而不是跟本章说的专门保留着。这种用法可以使程序执行得更快,而且编译器可以把帧指针寄存器当作是一个通用寄存器来使用。初始化局部变量后,接着是一系列指令为调用零一个函数作准备,如Listing5.6Listing5.6调用Sum函数前的汇编代码0100127b8d45f0leaeax,[ebp-10h]0100127e50pusheax0100127f8b4decmovecx,dwordptr[ebp-14h]0100128251pushecx010012838d55f4leaedx,[ebp-0Ch]0100128652pushedx01001287e824000000call05stackdesc!Sum(010012b0)粗看一眼,在call指令之前似乎有很多数据被压栈了。我们来看看Sum函数的原型:VOIDSum(int*numArray,intiCount,int*sum);函数中传入了3个参数:■一个数组指针,指向我们要加的数字■一个整数说明数组中的元素个数■一个整形指针用于(在函数执行成功之后)记录数组中所有数字的和。参数从ThreadProcedure函数传入Sum函数的途径,正如你所猜测的,就是堆栈。任何时候当call指令导致要调用带参数的函数时,调用函数都有责任将以从右至左的顺序(stdcall调用习惯)将参数压栈。在我们例子中,第一个要压栈的参数是用于存放数字和的指针。Listing5.6中的前两条指令揭示了参数是如何被压栈的。我们又一次看到用ebp寄存器引用到目标局部变量了。因为传递的是指针,所以用的是lea指令(加载有效地址)。剩下的两个参数也以类似的方式压栈了(记住,是从右到左的顺序)。Figure5.6准备调用Sum函数前的调用堆栈进入Sum函数后新的帧在堆栈中是怎样的留给大家做练习。提示一下:因为参数通过堆栈被传进去了,要访问传入的参数需要将ebp寄存器与一个偏移值关联起来。在Sum函数执行结束返回到调用帧(ProcA),堆栈指针esp被改为0x002bff98,正好是将参数压栈准备调用Sum函数前堆栈的最后位置。那么堆栈指针esp是如何调整回这个位置的呢?在即将分析的ProcA函数如何返回的时候再揭晓答案。下图是ProcA函数刚调用完Sum函数后的指令:Listing5.7刚执行完Sum函数的汇编指令0100128c8b45f0moveax,dwordptr[ebp-10h]0100128f50pusheax0100129068d0100001pushoffset05stackdesc!`string’(010010d0)01001295ff1550100001calldwordptr[05stackdesc!_imp__printf(01001050)]0100129b83c408addesp,80100129e8be5movesp,ebp010012a05dpopebp010012a1c3ret在上面第4行又是一条call指令,这次调用的是printf函数。跟源代码中的一样,现在准备打印出调用Sum函数的结果(保存在iSum中)。在调用printf函数前,堆栈又一次设置好了函数可能用到的任何参数。确切地说,穿入了两个参数:■一个字符串:“Thesumis:%d\n”■iSum的值记住参数总是从右往左压栈(译注)。所以先把iSum的值压栈,见Listing5.7的前两条指令。因为iSum就是ProcA函数帧的局部变量,可以通过ebp寄存器减0x10来访问。从Figure5.6(译注:原文是Figure5.4,恐怕是个笔误)能看到ebp-0x10就是局部变量iSum。最后一个字符串参数是pushoffset05stackdesc!`string’(010010d0)指令压栈的。我们用da命令(dumpASCII)来验证一下被压栈的字符串是否正确:0:001>da0x10010d0010010d0“Sumis:%d.”这就证明字符串确实被正确的传进去了。在printf函数调用完后,ProcA函数还有最后几条指令用于恢复堆栈到调用ProcA函数之前的原始状态。见Listing5.7。Listing5.8ProcAFunctionepilogue0100129b83c408addesp,80100129e8be5movesp,ebp010012a05dpopebp010012a1c3ret第一条指令将堆栈指针esp加了个8。这是为啥呢?嗯,之前printf函数返回后,我们看到esp被设为准备调用前被压栈的最后一个参数。记住,函数帧每调用了一次call都必须确保堆栈恢复到调用之前的状态。因为我们为调用printf函数将两个参数压栈了,所以现在将堆栈指针esp加8(2*4字节,即压栈的两个参数的大小)以便恢复到调用printf之前的状态(译注:这里由调用者ProcA函数来为printf函数恢复堆栈还是因为调用约定的问题,printf函数采用的是__cdecl调用约定)。现在我们就可以准备从ProcA函数返回了。因为ProcA函数在堆栈上分配了一些局部变量,esp寄存器现在指向的是堆栈中最后一个局部变量的地址。现在要从ProcA函数返回了,我们需要保证将esp寄存器被设为在调用ProcA函数之前的值。要完成这个工作的关键就是记住在ProcA函数prologue中都干啥了。说得更准确些,在prologue里,moveebp,esp指令将esp寄存器的值保存到了堆栈中。要恢复的话,就跟Listing5.8一样,只需要简单的执行一条moveesp,ebp的指令就行了。Figure5.7描述了当前堆栈的状态。Figure5.7ProcA函数返回前的堆栈状态译注:参数的压栈顺序并非一定是从右至左的。Delphi程序的参数压栈顺序即为从左至右。依赖于函数调用约定。从微软网站上找了个VisualC/C++编译器的调用约定对照表: 关键字 堆栈清理 参数传递 __cdecl Caller 所有参数压栈,逆序(从右至左) __clrcall n/a 按顺序(从左至右).将参数压入CLRexpressionstack __stdcall Callee 参数全部压栈,逆序(从右至左) __fastcall Callee 前两个长度为DWORD或者更小的参数保存到寄存器(ecx和ecx),剩下的参数按逆序(从右至左)压栈。 __thiscall Callee 参数全部压栈,this指针保存到ECX因为ebp寄存器是作为帧基址使用,所以恢复ebp寄存器和恢复esp寄存器同样重要。从ProcA函数返回之后,我们希望调用函数ThreadProcedure能像调用ProcA函数(译注:原文是FuncA函数,疑似笔误)前一样的使用ebp寄存器。因为堆栈中的栈顶数据就是被预先保存下来的寄存器ebp(即调用函数ThreadProcedure的帧指针),只需要简单的弹出栈顶数据到ebp寄存器即可。最后我们来看看用于返回到调用函数的ret指令。咦?等等,我们的esp寄存器(0x002bffb0)看起来是指向了一个返回地址,这是在执行call指令的时候自动压栈的。我们要不要在返回前对这个堆栈位置的数据作些什么?答案是既需要又不需要。说需要是因为我们确实需要靠这个地址才能知道返回到哪儿;说不需要是因为我们不必显式的从堆栈中弹出它。当ret指令执行的时候,返回地址会被弹出并且将控制转移到那(指返回地址)以后才能恢复执行。如你所见,堆栈是个非常多变的数据结构,也是Windows线程执行的核心。它使得程序的控制能以一种非常结构化而有序的形式在函数之间前后转换。因为编译器能生成处理控制转换的所有代码(包括管理堆栈,参数传递,局部变量寻址等等),程序员通常不必太关心背后(译注:这里应该是指编译器的行为)实际发生了什么。大多数时候,程序员不会去关心,但是一些常见的编程错误会导致程堆栈被破坏。这时候,理解堆栈的管理就能明白一个成功运行的程序和一个失败的程序之间的差异。下一节我们详细讨论那些导致堆栈破坏的最常见的情形,以及应用内存破坏诊断流程找到根源的方法。神秘的movedi,edi指令函数prologue的责任是设立当前帧。前面已经看到,函数prologue的一般结构就是设置帧基址指针,帧基址压栈,为局部变量保留空间。下面是个关于FindFirstFileExW函数的prologue的例子:0:000>ukernel32!FindFirstFileExWkernel32!FindFirstFileExW:7c80ec7d8bffmovedi,edi■没用的指令?7c80ec7f55pushebp■保存旧的帧指针7c80ec808becmovebp,esp■设置新的帧指针7c80ec8281eccc020000subesp,0x2cc■为局部变量保留空间variables7c80ec88837d0c01cmpdwordptr[ebp+0xc],0x17c80ec8ca1cc36887cmoveax,[kernel32!__security_cookie(7c8836cc)]7c80ec9153pushebx7c80ec928945fcmov[ebp-0x4],eax我们还没有讨论的就是第一条神秘的指令:movedi,edi。每个函数开始都是这么条看着似乎没用的指令。大多数情况下,movedi,edi指令就是个NOP(无操作)。但是在某些特定的情况下,它可以用于hotpatch。Hotpatch是指为运行中的程序打补丁,而不必麻烦的先停下来再打补丁。这种机制在系统可靠性方面对于避免故障停机时间十分重要。其基本原理是那2字节的指令movedi,edi可以被替换成一条jmp指令,可以跳转到任何新代码需要的位置。因为这是条2字节的指令,所以只适合一个短跳转,前后最多跳转127个字节。一般来说,这不够。希望你能够跳转到一个已定位的代码的位置。要越过这个限制,我们往movedi,edi指令前面看看:0:000>ukernel32!FindFirstFileExW-9kernel32!OpenMutexW+a6:7c80ec7433c0xoreax,eax7c80ec76eb98jmpkernel32!OpenMutexW+0xad(7c80ec10)7c80ec7890nop7c80ec7990nop7c80ec7a90nop7c80ec7b90nop7c80ec7c90nopkernel32!FindFirstFileExW:7c80ec7d8bffmovedi,edimov指令之前的5条指令都是1字节长的nop指令,通过一条短跳转语句替换mov指令到NOP指令这,在把这些NOP指令替换成一个长转移指令,我们就能轻松的选择任何位置打个HotPatch了。StackOverruns当线程随意的改写用于为其他目的保留的部分时,就会发生堆栈越界。包括但不限于覆盖某个帧的返回地址,覆盖整个帧,甚至将内存耗尽。堆栈越界的最终结果小到程序崩溃,大到出现不可预期的行为,甚至严重的安全漏洞。堆栈越界已经成为恶意软件最常见的攻击方式之一。这会潜在的允许攻击者获得对运行着有缺陷软件的系统的完全控制。我们会看一个堆
/
本文档为【认识到堆栈破坏的原因以及分析的方法】,请使用软件OFFICE或WPS软件打开。作品中的文字与图均可以修改和编辑, 图片更改请在作品中右键图片并更换,文字修改请直接点击文字进行修改,也可以新增和删除文档中的内容。
[版权声明] 本站所有资料为用户分享产生,若发现您的权利被侵害,请联系客服邮件isharekefu@iask.cn,我们尽快处理。 本作品所展示的图片、画像、字体、音乐的版权可能需版权方额外授权,请谨慎使用。 网站提供的党政主题相关内容(国旗、国徽、党徽..)目的在于配合国家政策宣传,仅限个人学习分享使用,禁止用于任何广告和商用目的。

历史搜索

    清空历史搜索