使用控制台调试器检查函数调用






4.13/5 (5投票s)
一篇通过检查线程调用的函数来学习故障排除的文章
引言
检查线程调用栈以进行故障排除 - 栈及其结构理解
栈是一种抽象数据结构,它递归地跟踪函数调用,并从高地址内存向低地址内存增长。栈的增长特性导致了缓冲区溢出问题的存在。缓冲区是一个数组,指的是一个存储位置,用于接收和保存数据,直到进程可以使用它。由于每个进程可能都有自己的缓冲区集,因此保持它们的位置至关重要。就像一叠盘子一样,你放到栈上(或推入)的每个盘子都会从栈顶取出(弹出)。在栈上执行的这两个基本操作(推入和弹出)总是从栈顶开始,这种内存算法被称为“后进先出”(LIFO)。在Windows操作系统上,就代码执行而言,栈是由操作系统分配给正在运行的线程的一块内存。如前所述,栈的作用是跟踪函数调用链。例如,当你看到一个请求用户输入以进行验证的对话框时,会调用一个函数来输出该数据框,然后调用另一个函数来获取用户输入,再调用另一个函数来将用户输入与系统预期进行比较,依此类推。因此,跟踪函数调用涉及局部变量的分配、参数传递等。想想代数函数y = f(x)
。插入到x
中的值决定了y
的值,从而决定了操作的输出。但每当进行函数调用时,就会创建一个新的帧并将其推入栈中。随着线程(在进程中执行)进行更多函数调用,栈会变得越来越大。举例来说,请看这段代码,它展示了一个新线程的起点,该线程进行了一系列嵌套函数调用,并在每个函数中声明了局部变量。
#include <windows.h>
#include <stdio.h>
#include <conio.h>
DWORD WINAPI ThreadProcedure(LPVOID lpParameter);
VOID ProcA();
VOID Sum(int* numArray, int iCount, int* sum);
void __cdecl wmain ()
{
HANDLE hThread = NULL ;
wprintf(L"Starting new thread...");
hThread = CreateThread(NULL, 0, ThreadProcedure, NULL, 0, NULL);
if(hThread!=NULL)
{
wprintf(L"Successfully created thread\n");
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
}
}
DWORD WINAPI ThreadProcedure(LPVOID lpParameter)
{
ProcA();
wprintf(L"Press any key to exit thread\n");
_getch();
return 0;
}
VOID ProcA()
{
int iCount = 3;
int iNums[] = {1,2,3};
int iSum = 0;
Sum(iNums, iCount, &iSum);
wprintf(L"Sum is: %d\n", iSum);
}
VOID Sum(int* numArray, int iCount, int* sum)
{
for(int i=0; i<icount;i++) *sum+=""numArray[i];""
为了更好地理解栈的工作原理以及它如何被破坏,我们将使用Microsoft Visual Studio附带的`cl.exe`编译器。当我们编译这段代码时,我们使用`/Zi`开关来获取额外的调试信息,并禁用`/GS`开关标志以避免栈保护信息:`c:\...\VC\bin> cl.exe /Zi /GS- stackdesc.cpp`。现在我们有了一个带有调试信息的可执行文件、一个目标文件和一个增量链接文件。我们将这些文件复制并粘贴到“Windows 调试工具”目录中,以便使用控制台调试器`cdb.exe`。当我写到栈操作可能导致缓冲区溢出时,请考虑这个基本的C程序:
#include <stdio.h>
#include <string.h>
int main (int argc, char *argv[])
{
char buffer[500];
strcpy (buffer, argv[1]);
return 0;
}
这段代码分配了一个特定大小的缓冲区,然后使用字符串复制函数将一个很长的字符串复制到已分配的缓冲区中(请注意,`argv[0]`是程序文件的名称)。运行这段代码会导致分段错误,建议您不要编译和运行它。
请看这张图

上面引用的代码显示了主函数使用 `CreateThread` API 创建了一个线程,并将线程的起始函数设置为 `ThreadProcedure` 函数。因此,`ThreadProcedure()` 函数是使用 `cdb.exe` 调试器调查此代码的起点。使用调试器时,请注意我们已将调试信息复制并粘贴到该目录中。但良好的实践意味着设置符号路径。
所以我们将开始调试,注意“x”命令需要编译器/Zi开关生成的符号中准确的调试信息。首先,我们从cdb.exe调试器开始:C:\Program Files\Debugging Tools for Windows> cdb.exe StackDesc.exe
c:\Program Files\Debugging Tools for Windows>cdb.exe StackDesc.exe
Microsoft (R) Windows Debugger Version 6.8.0004.0 X86
Copyright (c) Microsoft Corporation. All rights reserved.
CommandLine: StackDesc.exe
Symbol search path is: c:\symbols
Executable search path is:
ModLoad: 00400000 0042c000 StackDesc.exe
ModLoad: 77860000 77987000 ntdll.dll
ModLoad: 77320000 773fb000 C:\Windows\system32\kernel32.dll
(1720.120c): Break instruction exception - code 80000003 (first chance)
eax=00000000 ebx=00000000 ecx=0012fb08 edx=778b9a94 esi=fffffffe edi=778bb6f8
eip=778a7dfe esp=0012fb20 ebp=0012fb50 iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000246
ntdll!DbgBreakPoint:
778a7dfe cc int 3
我们注意到 Windows 调试器在初始化进程后、执行开始前会自动中断。(您可以通过在命令行上向 cdb 传递 -g 来禁用此断点。) 这很方便,因为在此初始断点处,您的程序已经加载,您可以在执行开始前在程序上设置任何您想要的断点。
0:000> x stackdesc!*threadprocedure*
*** WARNING: Unable to verify checksum for StackDesc.exe
00401090 StackDesc!ThreadProcedure (void *)
0:000> bp stackdesc!threadprocedure
0:000> g
Starting new thread...Successfully created thread
Breakpoint 0 hit
eax=773648ff ebx=00000000 ecx=00000000 edx=00401005 esi=00000000 edi=00000000
eip=00401090 esp=008aff8c ebp=008aff94 iopl=0 nv up ei pl zr na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000246
StackDesc!ThreadProcedure:
00401090 55 push ebp
0:001> kb
ChildEBP RetAddr Args to Child
008aff88 77364911 00000000 008affd4 7789e4b6 StackDesc ThreadProcedure!
008aff94 7789e4b6 00000000 77e175a8 00000000 kernel32!BaseThreadInitThunk+0xe
008affd4 7789e489 00401005 00000000 00000000 ntdll!__RtlUserThreadStart+0x23
008affec 00000000 00401005 00000000 00000000 ntdll!_RtlUserThreadStart+0x1b
线程过程并非第一个执行的函数。相反,它是一个定义在 `kernel32.dll` 中的名为 `BaseThreadInitThunk` 的函数,随后是对我们第一个函数的调用。现在是棘手的部分。我们已经到达了线程的起点。我们想仔细查看栈,看看它是如何设置的。下一个指令是反汇编代码,但我们将看到典型的程序入口点 55 和 `push ebp` 指令。那么这有什么问题呢?我们应该执行 `mov edi, edi` 指令。随着我们继续,这一点将变得更加清晰。现在回想一下,栈操作是递归进行的——`pop` 和 `push` 操作从顶部开始,而当我们分析线程调用栈时,栈是从底部读取的。
0:001> u stackdesc!threadprocedure
StackDesc!ThreadProcedure:
00401090 55 push ebp
00401091 8bec mov ebp,esp
00401093 e87cffffff call StackDesc!ProcA(00401014)
00401098 68c82c4200 push offset StackDesc!’string’ (00422cc8)
0040109d e8ea000000 call StackDesc!wprintf (0040118c)
004010a2 83c404 add esp,4
004010a5 e886050000 call StackDesc!_getch (00401630)
004010aa 33c0 xor eax,eax
`push ebp` 函数上方没有 `mov edi, edi` 指令。此时此刻,任何读者一定都在想那条指令与任何事情有什么关系。现在只要知道 `ebp` 寄存器存储任何给定帧的基指针就足够了。因此,如果它被保存到栈上,它就是新栈帧创建之前存在的栈帧(即调用指令)的帧指针。由于基指针 (`ebp`) 需要为每个帧保留,所以它被推入栈中。下一条指令将栈指针(不是基指针)移动到 `ebp` 寄存器,以建立新栈帧的开始(因为调用了一个函数)。现在我们已经准备好通过调用函数调用 `ProcA` 过程。`uf` 指令用于反汇编整个 `ProcA` 函数,该函数在栈指针从 `008aff8c` 开始,然后递减 4(32 位,4 字节,一个 `DWORD`)之后被调用。`esp` 的结果值为 `008AFF88`。请注意以前的内核栈转储中的值。
008aff88 77364911 00000000 008affd4 7789e4b6 StackDesc ThreadProcedure!
这就是为什么我们现在可以反汇编 `ProcA` 函数的原因。
0:001> uf stackdesc!ProcA
StackDesc!ProcA:
004010b0 55 push ebp
004010b1 8bec mov ebp,esp
004010b3 83ec14 sub esp,14h
004010b6 c745ec03000000 mov dword ptr [ebp-14h],3
004010bd c745f401000000 mov dword ptr [ebp-0Ch],1
004010c4 c745f802000000 mov dword ptr [ebp-8],2
004010cb c745fc03000000 mov dword ptr [ebp-4],3
004010d2 c745f000000000 mov dword ptr [ebp-10h],0
004010d9 8d45f0 lea eax,[ebp-10h]
004010dc 50 push eax
004010dd 8b4dec mov ecx,dword ptr [ebp-14h]
004010e0 51 push ecx
004010e1 8d55f4 lea edx,[ebp-0Ch]
004010e4 52 push edx
004010e5 e825ffffff call StackDesc!Sum (0040100f)
004010ea 83c40c add esp,0Ch
004010ed 8b45f0 mov eax,dword ptr [ebp-10h]
004010f0 50 push eax
004010f1 68042d4200 push offset StackDesc!__xt_z+0x1c8 (00422d04)
004010f6 e891000000 call StackDesc!wprintf (0040118c)
004010fb 83c408 add esp,8
004010fe 8be5 mov esp,ebp
00401100 5d pop ebp
00401101 c3 ret
指令 `sub esp, 0x14`(或十进制 20)表示从栈指针中减去 0x14 字节。为什么?它正在为局部变量腾出空间。回想 `ProcA` 的源代码。它在栈上分配了以下局部变量:
int iCount = 3;
int iNums[] = {1,2,3};
int iSum = 0;
有三个“int”变量声明和赋值:一个值为4字节,三个值为12字节,一个值为4字节,总共20字节。因此,当我们从栈指针中减去20字节时,栈中的空白处就为函数中声明的局部变量保留了。在调整栈指针为局部变量腾出空间后,执行的下一组指令将基于栈的局部变量初始化为源代码中指定的值。
004010b6 c745ec03000000 mov dword ptr [ebp-14h],3
004010bd c745f401000000 mov dword ptr [ebp-0Ch],1
004010c4 c745f802000000 mov dword ptr [ebp-8],2
004010cb c745fc03000000 mov dword ptr [ebp-4],3
004010d2 c745f000000000 mov dword ptr [ebp-10h],0
在局部变量初始化(数据类型被赋值)之后,我们有一系列指令,使应用程序进行另一次函数调用。
004010d9 8d45f0 lea eax,[ebp-10h]
004010dc 50 push eax
004010dd 8b4dec mov ecx,dword ptr [ebp-14h]
004010e0 51 push ecx
004010e1 8d55f4 lea edx,[ebp-0Ch]
004010e4 52 push edx
004010e5 e825ffffff call StackDesc!Sum (0040100f)
三个参数被传递给函数
- 指向整数数组的指针,该数组包含要添加的数字。
- 一个整数,表示数组中整数的数量。
- 一个指向整数的指针,它(成功时)将包含该数组中所有整数的和。
每当调用指令导致调用带有参数的函数时,调用函数负责将参数从右向左推入栈中。这就是上面列表(参数)从 `ThreadProc` 函数传递到 `Sum` 函数的方式。现在请注意对 `printf` 函数和 `string` 的调用。
0:001> du 00422d04
00422d04 "Sum is: %d."
“du”指令旨在转储Unicode文本,如源代码中语句前面的“L”所证明的那样。许多(如果不是所有)函数序言都以`mov edi, edi`指令开头。虽然它只是一个NOP指令,但《Windows高级调试》中提到它可以用于启用热补丁。热补丁是指能够在不先停止被补丁组件的情况下修补正在运行的代码。这很重要,因为它避免了系统可用性的停机时间。这种机制背后的概念是,2字节的`mov edi, edi`指令可以被一个“jmp”指令替换,该指令可以执行任何所需的新代码。操作码包含7个跳转指令,其中6个是条件跳转,一个通过直接跳转到目标地址来控制。检查以下指令,并回想第一个函数包含在kernel32.dll中:
0:001> u kernel32!FindFirstFileExW
kernel32!FindFirstFileExW:
77360a33 8bff mov edi,edi
77360a35 55 push ebp
77360a36 8bec mov ebp,esp
77360a38 81eccc020000 sub esp,2CCh
77360a3e a1acd43e77 mov eax,dword ptr [kernel32!__security_cookie (773e
d4ac)]
77360a43 33c5 xor eax,ebp
77360a45 8945fc mov dword ptr [ebp-4],eax
77360a48 837d0c01 cmp dword ptr [ebp+0Ch],1
参考文献
- 《Windows 高级调试》 作者:Mario Hewardt 和 Daniel Pravat
- 《Windows Internals》 作者:Mark Russinovich 和 David Solomon
- 《Windows 系统编程》 作者:Johnson M. Hart
历史
- 2009年3月10日:首次发布