调试教程第二部分:堆栈






4.95/5 (95投票s)
2004年3月20日
24分钟阅读

263921
介绍对抗bug最重要的盟友——堆栈。
引言
欢迎来到本调试教程的第二部分。在本文中,我将探讨堆栈以及它在调试中扮演的关键角色。每当你问“当程序陷入困境时该怎么办?”时,最常见的答案就是“获取堆栈跟踪”。这绝对是真的,这可能是你在调查任何崩溃转储时应该做的第一件事。
抱歉,如果本教程过于笼统和初级!我应该将级别设置为初级而不是中级。我之所以将其设置为中级,只是因为本文需要汇编知识。
什么是堆栈?
这是第一个也是最明显的问题。不幸的是,我在第一个教程中没有涵盖或真正回答这个问题,因为我理所当然地认为每个人都熟悉它。为了解释堆栈是什么,让我从我们开始的地方——什么是进程——开始。
什么是进程?
进程基本上是内存中应用程序的一个实例。可执行文件和支持库被映射到这个地址空间中。进程不执行,它只是定义了内存边界、资源以及任何在该进程中操作的人都可以访问的内容。
什么是线程?
线程是在进程边界内操作的执行实例。进程不被调度执行,进程内的线程才被调度执行。在一个进程的上下文中可以有多个线程执行。尽管线程可能拥有“线程特定存储”,但通常在进程上下文中创建的所有内存和资源都可以被任何执行线程使用。
全局和局部资源
这里不要混淆,但也有例外。有些资源是全局创建的而不是局部创建的。这意味着这些资源可以在创建它们的进程上下文之外使用。一个这样的例子是窗口句柄。这些资源有自己超出进程的边界。有些资源可能是系统范围的,有些是桌面或会话范围的。还有“共享”资源,进程可以通过其他方式和机制协商共享资源。
什么是虚拟内存?
一般来说,“虚拟内存”通常被认为是欺骗系统,让它以为有比实际更多的物理内存。这既是真的也是假的。这取决于“系统”是谁,而且远不止于此。
系统并没有被欺骗以为有比实际更多的内存。硬件已经知道内存较少,并且实际上是它实现了支持“虚拟内存”所需的机制。操作系统是利用这些功能来执行“虚拟内存”的,所以它也没有被欺骗。那么,谁被欺骗了呢?如果有人被欺骗了,那就是在系统上运行的进程。
我也不认为情况是这样。应用程序程序员通常已经知道他正在为其编程的系统。这意味着,他知道操作系统是否使用“虚拟内存”,例如DOS,并且他为该平台编程。一般来说,这没有任何意义。一个简单的应用程序只要能执行就不在乎。你真正遇到麻烦的唯一时候是“协作式多任务”系统与“抢占式多任务”系统。但是,程序员知道他的目标平台并适当编程。这两种操作系统的区别超出了本文的范围,不适用。
所以,回到回答这个问题。“虚拟内存”做的第一件事是抽象机器的物理地址空间。这意味着应用程序不看到或不知道物理地址。它们知道“虚拟”地址。CPU然后能够根据操作系统设置的某些特性将“虚拟”地址转换为“物理”地址。该机制的细节超出了本文档的范围。只需理解应用程序接收“虚拟”地址,处理器将其映射到物理地址。
等式中的下一部分是“虚拟”地址不需要指向“物理”地址。操作系统可以使用交换文件将内存保存在磁盘上,这样,整个程序就不必同时都在物理内存中。这允许许多程序在内存中并执行。如果程序尝试访问不在物理内存中的内存位置,CPU会知道这一点。CPU将发生页面错误并知道正在访问的内存位于磁盘上。然后操作系统会得到通知,并将该内存从磁盘拉到物理内存中。完成此操作后,程序会恢复执行,并从它中断的地方继续。
有许多算法可以决定如何从磁盘中拉取内存。除非你计划在物理内存中扩大进程的足迹,否则你通常会交换出一个页面以换入一个页面。操作系统可以使用许多算法来扩大进程的物理足迹并交换页面进出。一个简单的算法是,基本上,内存中使用频率最低的页面。你通常希望避免编写频繁跨越页面边界的程序,这将消除“抖动”,即频繁地在内存和磁盘之间交换页面。这些主题超出了本教程的范围。
“虚拟内存”的下一个优势是保护。一个进程不能直接访问另一个进程的内存。这意味着在任何时候,CPU只拥有该进程的虚拟地址映射。这意味着,它无法解析另一个进程中的虚拟地址。这是有道理的,因为由于它们是单独的映射,所以进程可能会并且将会有指向不同位置的相同内存地址!
这并不意味着无法读取另一个进程的内存。如果操作系统具有内置支持(例如Windows),则可以访问另一个进程的内存。如果你能够获得内存位置的访问权限并操作与虚拟内存映射相关的CPU寄存器,你也可以这样做。幸运的是,你不能,因为CPU可以在你尝试执行敏感汇编指令之前检查你的特权级别,“虚拟内存”将阻止你成为用户模式进程并操作页面或描述符表(尽管,在Windows 9x中有一种方法可以在用户模式下获取LDT)。
什么是堆栈?
现在我已经描述了系统的基础知识,我可以回到“什么是堆栈?”这个问题。一般来说,堆栈是一种通用数据结构,允许将项目推入其中和从中弹出。把它想象成一堆盘子。你可以把物品放在顶部,并且只能从顶部取下物品(不作弊)。如果你遵循这个严格的规则,你就有一个堆栈。堆栈通常被称为“LIFO”或“后进先出”。
程序通常使用堆栈作为临时存储的一种方式。这对于非汇编程序员来说通常是未知的,因为语言隐藏了这些细节。然而,你的程序生成的代码将使用堆栈,并且CPU具有内置的堆栈支持!
在Intel处理器上,将数据放入堆栈和从堆栈取出数据的汇编指令分别是 PUSH
和 POP
。请注意,有些处理器使用 PUSH
/PULL
,但在Intel世界中,我们使用 PUSH
/POP
。这只是一个“助记符”而已,它基本上是一个机器操作的英文单词。所有的汇编指令最终都归结为一个由CPU处理的操作码或数字。这意味着,你可以在英文中将该指令称为任何你想要的名称,只要你正确使用它并生成正确的“操作码”。
言归正传,进程中执行的每个“线程”都有自己的堆栈。这是因为我们不能让多个线程试图使用同一个临时存储位置,我们稍后会看到。
函数调用是如何进行的?
函数调用取决于“调用约定”。“调用约定”是调用者(发出调用的函数)和被调用者(被调用的函数)达成的一种基本方法,用于向函数传递参数并在之后清理参数。在Windows中,我们通常支持三种不同的调用约定。它们是“this call”、“standard call”和“CDECL 或 C Calling convention”。
This Call
这是一种C++调用约定。如果你熟悉C++内部结构,对象的成员函数需要将this
指针传递给函数。一般来说,this
指针是堆栈上的第一个参数。但“this call”并非如此。在“this call”中,this
指针在寄存器中,确切地说是ECX
。参数以相反的顺序压入堆栈,并由被调用者清理堆栈。
标准调用
“标准调用”是指参数逆序压入堆栈,并且由被调用者清理堆栈。
CDECL 或 C 调用约定
“C调用”约定基本上意味着参数逆序压入堆栈,并由调用者清理堆栈。
Pascal 调用约定
如果你看过旧程序,你会看到“PASCAL”作为它们的调用约定。在WIN32中,你实际上不能再使用__pascal
了。PASCAL
宏实际上已被重新定义为“Standard Call”。然而,Pascal调用约定参数是**正序**压入堆栈的。被调用者清理堆栈。
清理堆栈?
谁来清理堆栈的区别是一个大问题。首先是节省字节。如果被调用者清理堆栈,这意味着每次函数调用时都不需要生成额外的指令来清理堆栈。这样做的缺点是不能使用可变参数。可变参数被像printf
这样的函数使用。实际的被调用者并**不真正**知道堆栈上压入了多少参数。它只能通过提供给它的信息来**猜测**,比如说,它的格式字符串。如果你告诉printf "%i %i %i"
,它会尝试使用堆栈上的另外3个值来填充这些,无论你是否压入了它们!这可能会也可能不会导致陷阱。如果你压入的参数多于你告诉printf
的,也没有问题,因为调用者无论如何都在清理堆栈。它们只是在那里没有原因,但printf
不知道它们在那里。记住,可变参数函数不会神奇地知道有多少参数,它们必须实现某种方法让调用者通过它们的参数列表告诉它们。Printf
的只是格式字符串,你甚至可以传递一个数字,如果你愿意,但编译器不会为你做这件事。
此外,尽管有可能清理堆栈,但并非完全可行。由于函数在编译时不知道传入了多少参数,这意味着它必须操作堆栈并移动返回值以进行清理。在这种情况下,让调用者清理堆栈会更容易。
Intel支持被调用者清理堆栈的指令。它是 RET
<字节计数>
,其中 Byte Count
是堆栈上参数的字节大小。一个2字节的指令。
那么,堆栈是什么?
堆栈是用于临时存储的位置。参数被推入堆栈,然后返回地址被推入堆栈。执行流必须知道返回到哪里。CPU是愚蠢的,它只是一个接一个地执行指令。你必须告诉它去哪里。为了告诉它如何返回,我们需要保存返回地址,即函数调用之后的下一个位置。有一个汇编指令为我们完成了这个任务:CALL
。有一个汇编指令使用堆栈上的当前值和返回地址并将执行转移到该位置。它被称为RET
。除此之外,我们还有局部变量,甚至可能还有其他值被推入堆栈以进行临时存储。这是你永远不能返回数组或任何局部变量地址的原因之一,当函数返回时它们就会消失!
堆栈的布局如下
[Parameter n ]
...
[Parameter 2 ]
[Parameter 1 ]
[Return Address ]
[Previous Base Pointer]
[Local Variables ]
返回前,堆栈被清理到返回地址,然后发出“返回”指令。如果堆栈未保持正确的顺序,我们可能会失同步并返回到错误的地址!这显然会导致陷阱!
什么是“基指针”?
Intel中的“基指针”通常是EBP
。发生的情况是,由于ESP
是动态且不断变化的,你保存了前一个函数的旧EBP
,然后将EBP
设置为当前堆栈位置。现在你可以从标准偏移量直接引用堆栈上的变量。这意味着第一个参数将始终是EBP
+ xx
,等等。如果你不保存ESP
并始终引用ESP
,你将不得不跟踪堆栈上有多少数据。如果你在堆栈上放置越来越多的数据,第一个参数的偏移量会改变。汇编器**确实**在适当的时候生成不设置EBP
的函数,所以并非总是EBP
是基指针,而是函数可以直接使用ESP
。
通常,它是EBP
+ 值
来获取函数参数,EBP
- 值
来获取局部变量。
整合所有内容
所以,你现在可以看到每个线程都有自己堆栈的原因。如果它们共享同一个堆栈,它们就会覆盖彼此的返回值和数据!或者,如果它们耗尽了堆栈空间,最终可能会发生这种情况。这是我们将要讨论的下一个问题。
堆栈溢出
堆栈溢出是指你达到了堆栈的末尾。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:
00401029 53 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 或“线程环境块”的所有元素(如前一个教程中提到的,位于 FS:0 处)。如果你从堆栈基地址减去堆栈限制,你就会得到大小。Windows给了我们 1,044,480 字节的堆栈空间。
堆栈下溢
一般来说,堆栈下溢是溢出的反面。你不知何故认为你比实际放了更多的东西在堆栈上,你弹出得太多了。你已经到达了堆栈的开头,它已经空了,但你认为还有更多数据并继续尝试弹出数据。
溢出和下溢
当你的程序失同步并崩溃,认为堆栈位于不同的位置时,也可能发生溢出和下溢。如果你在函数中清理了太多,然后尝试返回,堆栈可能会下溢。你的堆栈失同步,你返回到错误的地址。你的堆栈失同步的原因是你认为它上面有更多数据。你可以将其视为下溢。
相反的情况也可能发生。你清理得太少,因为你认为堆栈上没有那么多数据,然后你返回。当你返回时你会陷入困境,因为你去了错误的地址。你可以将此视为溢出,因为你失同步,认为堆栈上的数据比实际少。
调试器如何获取堆栈跟踪?
这引出了我的下一个话题,调试器如何获取堆栈跟踪?第一个答案很简单,就是通过使用“符号”。符号可以告诉调试器堆栈上有多少参数、多少局部变量等等,这样调试器就可以使用符号来确定如何遍历堆栈并显示信息。
如果没有符号,它会使用基指针。每个基指针都指向前一个基指针。EBP
+ 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
将指向局部变量等,我们将转储EBP
。我将使用“DDS”命令,它表示“带符号转储双字”。调试器将尝试将堆栈上的值与最接近的符号匹配。
我们当前的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!⌂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个值。它不知道这些参数是否正确,它只是一个预览。如果你想知道其余的,你只需找到堆栈上的位置并转储。
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);
如你所见,如果出现任何偏差,此信息都是错误的。这就是为什么你在解释这些值时必须运用自己的判断力。
下一个EBP
是:0012ffc0。它是0012ff38处的内存位置。前一个返回值是
0012ff3c 00401044 temp!main+0x44
先前的参数位于0012ffc0 + 8。记住,这还假设EBP
是第一个被压入堆栈的值。如果调试器足够智能,它可能会尝试只遍历堆栈直到找到第一个可识别的符号并将其用作返回值!这是在EBP
被保存和设置之前有东西被压入堆栈的情况。
以下是其参数:
0012ffc8 77f944a8 ntdll!RtlpAllocateFromHeapLookaside+0x42
0012ffcc 00000007
0012ffd0 7ffdf000
temp!main+0x44(77f944a8, 00000007, 7ffdf000)
我们的下一个EBP
是0012ffc0,所以+4是返回值。那现在就是我们的函数。
0012ffc4 77e814c7 kernel32!BaseProcessStart+0x23
所以,EBP
= 0012ffc0,指向前一个EBP
0012fff0,我们知道前一个EBP
+ 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
。其次,我们没有使用符号信息来遍历堆栈。如果我删除*temp.exe*的符号,我得到以下堆栈跟踪
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 dword ptr [ebp-0x1c]
列表内容如下:
<;address> <opcode> <assembly instruction in english or mnemonic>
77c3e691 ff75e4 push dword ptr [ebp-0x1c]
77c3e691 == Address
ff75e4 == Opcode or machine code. This is what the CPU understands
push dword ptr [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 dword ptr [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 dword ptr [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:
00401024 2408 and al,0x8
00401026 57 push edi
00401027 50 push eax
00401028 6a04 push 0x4
0040102a 6820304000 push 0x403020
0040102f 56 push esi
00401030 ff1500204000 call dword ptr [temp+0x2000 (00402000)]
00401036 56 push esi
0:000>; u
temp+0x1037:
00401037 ff1508204000 call dword ptr [temp+0x2008 (00402008)]
0040103d 57 push edi
0040103e ff1510204000 call dword ptr [temp+0x2010 (00402010)]
00401044 59 pop ecx
不幸的是,是的,这是汇编。这基本上是一个函数指针。它表示调用地址为00402010的函数。
使用“DD”获取该地址处的值。
0:000> dd 00402010
00402010 77c3e658
现在我们知道这是一个函数调用,所以我们将反汇编这个地址。
0:000>; u 77c3e658
MSVCRT!printf:
77c3e658 6a10 push 0x10
所以,是的,我们这里调用的是printf
。
下一个返回值是77e814c7。让我们看看我们是否正在调用temp
。
0:000>; u 77e814c7 - 20
kernel32!BaseProcessStart+0x3:
77e814a7 1012 adc [edx],dl
77e814a9 e977e8288e jmp 0610fd25
77e814ae ffff ???
77e814b0 8365fc00 and dword ptr [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 call dword ptr [kernel32!_imp__NtSetInformationThread (77e
6139c)]
77e814c4 ff5508 call dword ptr [ebp+0x8]
77e814c7 50 push eax
它正在调用第一个参数。这是第一个参数
0012fff0 00000000 00401064 00000000 78746341 kernel32!BaseProcessStart+0x23
0:000>; u 00401064
temp+0x1064:
00401064 55 push ebp
00401065 8bec mov ebp,esp
00401067 6aff push 0xff
所以,它正在调用temp
内部的某个东西。不幸的是,我们无法确定它是否是同一个函数。为了找出答案,我们需要反汇编这个函数并逐行查看。记住,对printf
的调用在
0040103e ff1510204000 call dword ptr [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
+ xxx
值!粗体的是我们知道它是printf()
的返回值。00401064,我们知道它是从BaseProcessStart()
调用的函数的起始地址。哪些值与这个最接近?
这里就要靠猜测了。如果你认为该函数不会向后跳转,你可以尝试只查看大于这个值的值。你可以尝试反汇编每一个引用,但你必须从某个地方开始。我会说,首先查看最接近这个的符号。这里有一个
0012ff50 00401147 temp+0x1147
0:000>; u 00401147 - 20
temp+0x1127:
00401127 40 inc eax
00401128 00e8 add al,ch
0040112a 640000 add fs:[eax],al
0040112d 00ff add bh,bh
0040112f 1520204000 adc eax,0x402020
00401134 8b4de0 mov ecx,[ebp-0x20]
00401137 8908 mov [eax],ecx
00401139 ff75e0 push dword ptr [ebp-0x20]
0:000>; u
temp+0x113c:
0040113c ff75d4 push dword ptr [ebp-0x2c]
0040113f ff75e4 push dword ptr [ebp-0x1c]
00401142 e8b9feffff call temp+0x1000 (00401000)
00401147 83c430 add esp,0x30
我们找到了这个函数调用,它看起来是一个有效的地址。区分堆栈上无效返回值的方法是,前一条指令**不是**CALL
指令。这是区分返回值和堆栈上可能是一个符号但不是返回值的唯一方法。
我们来反汇编这个
0:000>; u 00401000
temp+0x1000:
00401000 51 push ecx
00401001 56 push esi
00401002 57 push edi
00401003 33ff xor edi,edi
00401005 57 push edi
00401006 57 push edi
00401007 6a03 push 0x3
00401009 57 push edi
0:000>; u
temp+0x100a:
0040100a 57 push edi
0040100b 6800000080 push 0x80000000
00401010 6810304000 push 0x403010
00401015 ff1504204000 call dword ptr [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:
00401026 57 push edi
00401027 50 push eax
00401028 6a04 push 0x4
0040102a 6820304000 push 0x403020
0040102f 56 push esi
00401030 ff1500204000 call dword ptr [temp+0x2000 (00402000)]
00401036 56 push esi
00401037 ff1508204000 call dword ptr [temp+0x2008 (00402008)]
0:000>;
temp+0x103d:
0040103d 57 push edi
0040103e ff1510204000 call dword ptr [temp+0x2010 (00402010)]
00401044 59 pop ecx
00401045 5f pop edi
00401046 33c0 xor eax,eax
00401048 5e pop esi
00401049 59 pop ecx
0040104a c3 ret
0:000>;
这看起来像一个有效的函数调用,并且看起来它调用了printf
。所以,我们可以反汇编原始函数调用直到我们达到这个调用,看看它是否调用了它,或者它之间是否还有另一个函数调用。
0:000>; u 0401064
temp+0x1064:
00401064 55 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]
00401079 50 push eax
0040107a 64892500000000 mov fs:[00000000],esp
0:000>; u
temp+0x1081:
00401081 83ec20 sub esp,0x20
00401084 53 push ebx
00401085 56 push esi
00401086 57 push edi
00401087 8965e8 mov [ebp-0x18],esp
0040108a 8365fc00 and dword ptr [ebp-0x4],0x0
0040108e 6a01 push 0x1
00401090 ff153c204000 call dword ptr [temp+0x203c (0040203c)]
0:000>;
temp+0x1096:
00401096 59 pop ecx
00401097 830d40304000ff or dword ptr [temp+0x3040 (00403040)],0xffffffff
0040109e 830d44304000ff or dword ptr [temp+0x3044 (00403044)],0xffffffff
004010a5 ff1538204000 call dword ptr [temp+0x2038 (00402038)]
004010ab 8b0d3c304000 mov ecx,[temp+0x303c (0040303c)]
004010b1 8908 mov [eax],ecx
004010b3 ff1534204000 call dword ptr [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 dword ptr [temp+0x3028 (00403028)],0x0
004010d9 750c jnz temp+0x10e7 (004010e7)
004010db 68b0114000 push 0x4011b0
0:000>;
temp+0x10e0:
004010e0 ff152c204000 call dword ptr [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]
00401106 50 push eax
00401107 ff3530304000 push dword ptr [temp+0x3030 (00403030)]
0040110d 8d45e0 lea eax,[ebp-0x20]
00401110 50 push eax
00401111 8d45d4 lea eax,[ebp-0x2c]
00401114 50 push eax
00401115 8d45e4 lea eax,[ebp-0x1c]
0:000>;
temp+0x1118:
00401118 50 push eax
00401119 ff1524204000 call dword ptr [temp+0x2024 (00402024)]
0040111f 6804304000 push 0x403004
00401124 6800304000 push 0x403000
00401129 e864000000 call temp+0x1192 (00401192)
0040112e ff1520204000 call dword ptr [temp+0x2020 (00402020)]
00401134 8b4de0 mov ecx,[ebp-0x20]
00401137 8908 mov [eax],ecx
0:000>;
temp+0x1139:
00401139 ff75e0 push dword ptr [ebp-0x20]
0040113c ff75d4 push dword ptr [ebp-0x2c]
0040113f ff75e4 push dword ptr [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()
的temp
是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"
转储数组,其中只包含一个字符串,如argc
所示,然后我们可以使用“da”命令来查看该字符串,如上所示。
堆栈上为什么有多个返回地址?
堆栈上为什么有多个返回地址?堆栈通常可能被初始化为零,但随着它的使用,它会变得“脏”。你知道局部变量并不总是被初始化,所以如果你进行函数调用,当堆栈向上移动时,这些值不会被重置为零。如果你从堆栈中弹出一个值,堆栈可能会递减,但这些值会保留,除非它们被物理清理。有时,堆栈会优化事情,并且不会很好地清理变量。所以,在堆栈上看到“幽灵”值是非常常见的。
将值留在堆栈上并不总是可取的。例如,如果你的函数将密码放在堆栈上,并在稍后某个时候陷入困境。堆栈转储可能仍然会在堆栈上显示密码!所以,有时当你有敏感信息时,你可能希望在返回之前清理堆栈上的值。一种方法是使用SecureZeroMemory()
API。这可以用来安全地清除内存,因为调用其他API可能会被“优化”出代码,例如,如果你在返回之前调用它们。编译器知道你不再使用该变量,可能不会执行清除操作。
缓冲区溢出
缓冲区溢出是堆栈上常见的现象。堆栈在内存中向下增长,但数组在内存中向上增长。这是因为你通常在使用指针或数组时“递增”它以到达下一个索引,而不是递减它。因此,假设这是你的C函数
{ 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]
,你就会覆盖可能导致你陷入困境的关键值!覆盖前一个基指针可能不会造成伤害,如果调用函数不再使用它的话。但是,覆盖返回地址肯定会导致陷入困境!这就是为什么在处理局部变量时,你必须始终小心保持在数组边界内。你可能会覆盖其他局部变量、返回地址、参数,或者几乎任何东西!
Windows 2003
Windows 2003 有一种新的方法来尝试防止缓冲区溢出。这可以在 VS.NET 中使用 GS 标志编译。应用程序启动时会生成一个随机值作为 cookie。然后 cookie 与函数的返回地址进行异或运算,并放置在基指针之后的堆栈上。这是一个简单的例子
[Return Address ]
[Previous Base Pointer ]
[Cookie XOR Return Address ]
返回时,会检查 cookie 是否与返回值一致。如果它们未改变,则执行返回;否则,我们就会有问题。这种安全措施的目的不是为了防止代码在没有正确处理的情况下陷入困境,而是为了保护代码免受执行注入代码的攻击。当有人发现如何用实际代码和该代码的地址来溢出缓冲区时,就会产生安全风险。这将导致程序返回并执行该代码。
此URL提供了详细信息
结论
我可能让初学者感到困惑,也可能让高级程序员感到厌倦,但是,以简单的方式描绘高级概念确实很难。不过,我正在尽力。如果你喜欢或不喜欢这些教程,请给我留言。如果你希望这些教程结束,也请告诉我!
我可能开始得过于简单,然后又过快地变得过于高级。但我没办法,程序员应该学习这些信息并辅以其他来源,以获得对该主题的全面了解。不要将你在论坛上阅读或发布的内容视为确凿事实。人无完人,每个人都会犯错,没有人无所不知。这些网站允许几乎任何人发布信息,所以请始终保持怀疑态度。如果你发现错误,请告诉我。谢谢。