65.9K
CodeProject 正在变化。 阅读更多。
Home

调试教程第二部分:堆栈

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (95投票s)

2004年3月20日

24分钟阅读

viewsIcon

263921

介绍对抗bug最重要的盟友——堆栈。

引言

欢迎来到本调试教程的第二部分。在本文中,我将探讨堆栈以及它在调试中扮演的关键角色。每当你问“当程序陷入困境时该怎么办?”时,最常见的答案就是“获取堆栈跟踪”。这绝对是真的,这可能是你在调查任何崩溃转储时应该做的第一件事。

抱歉,如果本教程过于笼统和初级!我应该将级别设置为初级而不是中级。我之所以将其设置为中级,只是因为本文需要汇编知识。

什么是堆栈?

这是第一个也是最明显的问题。不幸的是,我在第一个教程中没有涵盖或真正回答这个问题,因为我理所当然地认为每个人都熟悉它。为了解释堆栈是什么,让我从我们开始的地方——什么是进程——开始。

什么是进程?

进程基本上是内存中应用程序的一个实例。可执行文件和支持库被映射到这个地址空间中。进程不执行,它只是定义了内存边界、资源以及任何在该进程中操作的人都可以访问的内容。

什么是线程?

线程是在进程边界内操作的执行实例。进程不被调度执行,进程内的线程才被调度执行。在一个进程的上下文中可以有多个线程执行。尽管线程可能拥有“线程特定存储”,但通常在进程上下文中创建的所有内存和资源都可以被任何执行线程使用。

全局和局部资源

这里不要混淆,但也有例外。有些资源是全局创建的而不是局部创建的。这意味着这些资源可以在创建它们的进程上下文之外使用。一个这样的例子是窗口句柄。这些资源有自己超出进程的边界。有些资源可能是系统范围的,有些是桌面或会话范围的。还有“共享”资源,进程可以通过其他方式和机制协商共享资源。

什么是虚拟内存?

一般来说,“虚拟内存”通常被认为是欺骗系统,让它以为有比实际更多的物理内存。这既是真的也是假的。这取决于“系统”是谁,而且远不止于此。

系统并没有被欺骗以为有比实际更多的内存。硬件已经知道内存较少,并且实际上是它实现了支持“虚拟内存”所需的机制。操作系统是利用这些功能来执行“虚拟内存”的,所以它也没有被欺骗。那么,谁被欺骗了呢?如果有人被欺骗了,那就是在系统上运行的进程。

我也不认为情况是这样。应用程序程序员通常已经知道他正在为其编程的系统。这意味着,他知道操作系统是否使用“虚拟内存”,例如DOS,并且他为该平台编程。一般来说,这没有任何意义。一个简单的应用程序只要能执行就不在乎。你真正遇到麻烦的唯一时候是“协作式多任务”系统与“抢占式多任务”系统。但是,程序员知道他的目标平台并适当编程。这两种操作系统的区别超出了本文的范围,不适用。

所以,回到回答这个问题。“虚拟内存”做的第一件事是抽象机器的物理地址空间。这意味着应用程序不看到或不知道物理地址。它们知道“虚拟”地址。CPU然后能够根据操作系统设置的某些特性将“虚拟”地址转换为“物理”地址。该机制的细节超出了本文档的范围。只需理解应用程序接收“虚拟”地址,处理器将其映射到物理地址。

等式中的下一部分是“虚拟”地址不需要指向“物理”地址。操作系统可以使用交换文件将内存保存在磁盘上,这样,整个程序就不必同时都在物理内存中。这允许许多程序在内存中并执行。如果程序尝试访问不在物理内存中的内存位置,CPU会知道这一点。CPU将发生页面错误并知道正在访问的内存位于磁盘上。然后操作系统会得到通知,并将该内存从磁盘拉到物理内存中。完成此操作后,程序会恢复执行,并从它中断的地方继续。

有许多算法可以决定如何从磁盘中拉取内存。除非你计划在物理内存中扩大进程的足迹,否则你通常会交换出一个页面以换入一个页面。操作系统可以使用许多算法来扩大进程的物理足迹并交换页面进出。一个简单的算法是,基本上,内存中使用频率最低的页面。你通常希望避免编写频繁跨越页面边界的程序,这将消除“抖动”,即频繁地在内存和磁盘之间交换页面。这些主题超出了本教程的范围。

“虚拟内存”的下一个优势是保护。一个进程不能直接访问另一个进程的内存。这意味着在任何时候,CPU只拥有该进程的虚拟地址映射。这意味着,它无法解析另一个进程中的虚拟地址。这是有道理的,因为由于它们是单独的映射,所以进程可能会并且将会有指向不同位置的相同内存地址!

这并不意味着无法读取另一个进程的内存。如果操作系统具有内置支持(例如Windows),则可以访问另一个进程的内存。如果你能够获得内存位置的访问权限并操作与虚拟内存映射相关的CPU寄存器,你也可以这样做。幸运的是,你不能,因为CPU可以在你尝试执行敏感汇编指令之前检查你的特权级别,“虚拟内存”将阻止你成为用户模式进程并操作页面或描述符表(尽管,在Windows 9x中有一种方法可以在用户模式下获取LDT)。

什么是堆栈?

现在我已经描述了系统的基础知识,我可以回到“什么是堆栈?”这个问题。一般来说,堆栈是一种通用数据结构,允许将项目推入其中和从中弹出。把它想象成一堆盘子。你可以把物品放在顶部,并且只能从顶部取下物品(不作弊)。如果你遵循这个严格的规则,你就有一个堆栈。堆栈通常被称为“LIFO”或“后进先出”。

程序通常使用堆栈作为临时存储的一种方式。这对于非汇编程序员来说通常是未知的,因为语言隐藏了这些细节。然而,你的程序生成的代码将使用堆栈,并且CPU具有内置的堆栈支持!

在Intel处理器上,将数据放入堆栈和从堆栈取出数据的汇编指令分别是 PUSHPOP。请注意,有些处理器使用 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()tempmain()。所以,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提供了详细信息

结论

我可能让初学者感到困惑,也可能让高级程序员感到厌倦,但是,以简单的方式描绘高级概念确实很难。不过,我正在尽力。如果你喜欢或不喜欢这些教程,请给我留言。如果你希望这些教程结束,也请告诉我!

我可能开始得过于简单,然后又过快地变得过于高级。但我没办法,程序员应该学习这些信息并辅以其他来源,以获得对该主题的全面了解。不要将你在论坛上阅读或发布的内容视为确凿事实。人无完人,每个人都会犯错,没有人无所不知。这些网站允许几乎任何人发布信息,所以请始终保持怀疑态度。如果你发现错误,请告诉我。谢谢。

© . All rights reserved.