调试器揭密
Microsoft 的Windows 调试工具集包含了许多功能强大的工具,这些工具在提供对调试目标总体控制的同时,还能够将调试工作的开销保持在最低的水平。在命令窗口中执行每个命令时不需要进行确认,这就要求用户自己必须对所键入的命令负责。与任何其他的工具一样,如果对这个工具了解得越透彻,那么就越能深入地知道命令的一些副作用,并且也能越准确地预测命令的执行结果。我们曾经遇到过多次这样的情况:调试器中的应用程序停止在某个关键的位置,并且之后程序的任何执行都将不可逆转地改变调试目标的状态。我们不希望在这些情况中丢失调试会话,尤其当程序的故障非常难以重现时。在其他的一些情况中,被调试进程属于某个运行中的系统,此时你必须了解调试器对这个进程将产生怎样的影响;否则,你很可能需要重新启动系统,或者最糟糕的情况是,程序由于一些内部结构被破坏而导致了不可预测的行为。
本章将介绍调试器的一些特殊功能,并且阐述支持这些特殊功能的底层机制。本章将详细介绍调试器与操作系统之间的交互,以及调试器与调试目标之间的交互。在本章中,我们将分析:
? 调试器的工作原理及其与代码执行的关系。
? 操作系统和调试目标如何产生调试事件,尤其在发生软件异常的情况下。
? 操作系统如何与应用程序中的异常处理代码交互。
? 调试器如何控制调试目标,以及用户的调试行为将产生怎样的结果。这些内容使你能够根据调试任务的具体情况来优化调试技术。
本章将使用03sample.exe 程序,调试器将以全自动模式对这个程序执行一些基本的操作。调试器将不会要求用户输入命令以进入到下一个步骤,这个伪调试器(Pseudo Debugger )将显示关于当前状态的一些信息,并且按照预先配置的模式执行。调试目标将作为命令参数传递给调试器。03sample.exe 的源代码文件和二进制文件分别位于以下文件夹中:
源代码文件:C:/AWD/Chapter3
二进制文件:C:/AWDBIN/WinXP.x86.chk/03sample.exe
这个示例程序将使用第2 章中的02sample.exe 作为调试目标。
3.1 用户态调试器的内幕
第2 章已经指出,Windows 调试工具集包含了用户态调试器和内核态调试器,这两种调试器都需要使用操作系统中的一些功能。用户态调试器是软件工程师们使用的主要工具,通常用来验证他们对代码的假设、算法的正确性,以及对程序中一些未预期的故障进行分析,本章将重点揭示用户态调试器的内幕。
在本节以及本章的大部分内容中,我们将介绍用户态调试器的工作原理,并且重点指出如何以最有效的方式来使用调试器的每个功能。
3.1.1 操作系统对用户态调试器的支持
Windows 操作系统通过一小组Win32 API 来提供对调试器的支持。用户态调试器将这些API 与其他普通的Win32 API 结合起来,从而提供强大的功能。
根据功能的不同,这些Win32API 可以被分为若干组,如下所示:
? 创建调试目标的API 。
? 在调试循环(Debugger Loop )中处理调试事件的API 。
? 查看和修改调试目标的API ,这些API 将用于调试事件的处理过程中。
本节将讨论每一组API 的用法。
创建调试目标
在启动调试会话时,首先要创建调试目标。用户态调试器可以启动一个新进程,或者被附加到一个运行中的进程。之后,这个进程就成为新的调试目标,而调试器的所有行为都将作用于这个进程。操作系统将调试目标与当前的调试器关联起来,直到调试目标停止运行并退出,或者调试器主动中断与进程的关联。
调试器是通过CreateProcess 和DEBUG_PROCESS 标志来启动一个新进程以作为调试目标。03sample.exe 示例使用了清单3-1 中的代码来创建调试目标。进程的名字被作为CreateProcess API 的第2 个参数,同时也是03sample.exe 的第一个命令行参数argv[1] 。
清单3-1 在用户态调试器下启动一个进程
STARTUPINFOA startupInfo={0};
startupInfo.cb = sizeof(startupInfo);
PROCESS_INFORMATION processInfo = {0};
BOOL res = CreateProcess(NULL, argv[1], NULL, NULL, FALSE,
DEBUG_PROCESS , NULL, NULL, &startupInfo, &processInfo);
如果调试器向操作系统请求调试某个运行中的进程,并且通过DebugActiveProcess 附加到进程时,那么这个进程也将进入到调试状态。无论是通过附加到运行中的进程来创建调试目标,还是通过启动新的进程来创建调试目标,在调试器与操作系统之间的交互方式都是相同的。按照这种方式连接到调试目标的调试器称为活动调试器(Active Debugger )。每个调试目标只能有一个活动调试器。
调试循环
当调试进程时,在这个进程执行的一些操作将会被通知给调试器。例如动态库的加载与卸载、新线程的创建、线程退出,以及在代码或处理器中抛出的异常等,这些都被认为是调试器需要知道的特殊事件。当这些事件需要被发送到调试器时,Windows 内核将首先挂起进程中的所有线程,然后把发生的事件通知给活动调试器,并且等待来自调试器的“恢复执行”命令。
在大多数时候,调试器会等待内核在执行WaitForDebugEvent 函数时返回的数据,只有当调试目标遇到了在前面介绍的特殊事件时才会生成这些数据。WaitForDebugEvent 将返回的事件信息封装在DEBUG_EVENT 结构,这个结构包含了所有可能的事件类型,调试器将通过这些类型来解析事件信息。当调试器分析DEBUG_EVENT 结构时,进程的状态不会发生改变,因为此时每个线程都被挂起了。
在解析和处理完事件后,调试器将调用ContinueDebugEvent 来继续调试目标的执行。然后,Windows 内核将根据在ContinueDebugEvent 中指定的参数来恢复调试目标的执行。根据事件类型的不同,内核可能会立即放弃事件并且取消对当前事件的处理。当产生的事件不是一个异常时,将从产生事件的位置开始恢复所有线程的执行。
这一系列的操作称为调试循环,调试循环将不断重复直到调试会话结束,例如由于调试目标不再存在或者调试器脱离(Detach )调试目标而使得调试会话结束。清单3-2 给出了一个调试循环。
清单3-2 标准的用户态调试循环
for(DWORD endDisposition = DBG_CONTINUE;endDisposition != 0;)
{
DEBUG_EVENT debugEvent = { 0 } ;
WaitForDebugEvent(&debugEvent, INFINITE);
endDisposition = ProcessEvent(debugEvent);
ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, endDisposi-tion);
}
调试事件的处理
如果在调试循环中获得一个新的事件,那么调试器将对DEBUG_EVENT 结构中的信息进行解析,并且可能在返回调试循环之前将调试目标的控制权转交给调试器的使用者。清单3-3 给出了一个简单的事件处理函数,这个示例忽略了DEBUG_EVENT 结构中的任何信息,对于除EXIT_PROCESS_DEBUG_EVENT 之外的每种类型事件都将返回DBG_CONTINUE ,当事件为EXIT_PROCESS_DEBUG_EVENT 时函数将返回零。为了简单起见,函数的返回值不仅被用于结束循环,而且还被用作为ContinueDebugEvent 的参数。
清单3-3 简单的调试事件处理函数
DWORD ProcessEvent(DEBUG_EVENT& dbgEvent)
{
switch (dbgEvent.dwDebugEventCode)
{
case EXCEPTION_DEBUG_EVENT:
break;
case CREATE_THREAD_DEBUG_EVENT:
break;
case CREATE_PROCESS_DEBUG_EVENT:
break;
case EXIT_THREAD_DEBUG_EVENT:
break;
case EXIT_PROCESS_DEBUG_EVENT:
break;
case LOAD_DLL_DEBUG_EVENT:
break;
case UNLOAD_DLL_DEBUG_EVENT:
break;
case OUTPUT_DEBUG_STRING_EVENT:
break;
case RIP_EVENT:
break;
}
return DBG_CONTINUE ;
}
在接下来的内容中,我们将进一步讨论清单3-3 中的一些调试事件,并且为它们提供合理的默认行为。03sample.exe 没有包含其他调试事件,读者可以作为一个练习来理解和掌握这些调试事件。请注意,功能完备的调试器通常可以使用户在调用ContinueDebugEvent 之前首先查看和修改调试目标的状态。
处理OUTPUT_DEBUG_STRING_EVENT
软件工程师们经常会在代码中使用调试输出命令,这是为了通过调试输出命令这种较为简单的手段来找出代码中的问题。在不同语言中,调试输出命令的语法是不同的,但大多数语法最终都会调用Windows 提供的某个调试系统函数,例如OutputDebugStringA 或者OutputDebugStringW 。调试器将把调试目标输出的字符串通过事件处理代码显示出来,如清单3-4 所示。DEBUG_EVENT 结构包含了一个OUTPUT_DEBUG_STRING_INFO 结构,这个结构又包含了特定事件的信息。成员lpDebugStringData 包含了将要被显示的字符串的地址,这个地址位于调试目标的地址空间中, nDebugStringLength 包含的是字符串的长度,fUnicode 则表示字符是Unicode 还是ANSI 。代码通过产生事件的进程的句柄来从调试目标的地址空间中读取信息。
清单3-4 处理输出调试字符串的事件
case OUTPUT_DEBUG_STRING_EVENT:
//typedef struct _OUTPUT_DEBUG_STRING_INFO {
// LPSTR lpDebugStringData;
// WORD fUnicode;
// WORD nDebugStringLength;
//} OUTPUT_DEBUG_STRING_INFO, *LPOUTPUT_DEBUG_STRING_INFO;
{
OUTPUT_DEBUG_STRING_INFO& OutputDebug = dbgEvent.u.DebugString;
WCHAR * msg = ReadRemoteString(hTargetProcessHandle, OutputDebug.lpDebugString-Data,
OutputDebug.nDebugStringLength, OutputDebug.fUnicode);
std::wcout << L"OutputDebugStringEvent/nMessage:/t";
std::wcout <<<< msg << std::endl;
delete[] msg;
break;
}
清单3-4 使用的ReadRemoteString 函数是一个辅助函数,它抽象了OUTPUT_ DEBUG_ STRING_INFO 结构中的字符大小以及字符串的长度。函数调用了kernel32!ReadProcessMemory 。ReadRemoteString 将从调试目标的地址空间中读取字符串,并将其转换为03sample.exe 所需的空字符结尾字符串。清单3-5 给出了ReadRemoteString 的实现。
清单3-5 从调试目标的地址空间中读取指定长度的字符串
WCHAR *
ReadRemoteString(HANDLE process,LPVOID address,WORD length,BOOL unicode)
{
WCHAR * msg = new WCHAR[length];
if (!msg) return NULL;
memset(msg, 0, sizeof(WCHAR)*(length));
if ( unicode )
{
ReadProcessMemory(process, address ,msg, length*sizeof(WCHAR),NULL);
return msg;
}
else
{
CHAR * originalMsg = new CHAR[length];
if (!originalMsg)
{
delete[] msg;
return NULL;
}
memset(originalMsg, 0, sizeof(BYTE)*(length));
ReadProcessMemory(process, address ,originalMsg, length,NULL);
for (WORD i = 0; i < length; i++)
{
msg[i] = originalMsg[i];
}
delete[] originalMsg;
return msg;
}
}
当在调试器控制台中输出字符串后,调试循环将继续执行。当调试器重新进入循环后,调试目标将继续执行。调试目标执行的这种行为将改变程序的执行时间,因此可能隐藏或者暴露出程序中的竞态条件问题。
处理EXCEPTION_DEBUG_EVENT
调试目标在其生命周期中可以产生多种类型的异常—对于每种不同的异常类型,调试器将采取不同的处理方式。有些异常对调试器本身有着特殊的含义,而其他异常则是对调试目标有着运行时的含义。调试器的异常处理器可能是非常复杂的。本节将只介绍一些有助于理解调试器异常处理过程的基本知识。
对于EXCEPTION_DEBUG_EVENT 调试事件来说, DEBUG_EVENT 结构包含的是一个EXCEPTION_ DEBUG_INFO 结构,在这个结构中又包含了一个成员ExceptionRecord ,类型为EXCEPTION_RECORD ,这个成员包含了异常信息的一个副本,如清单3-6 所示。调试器将从EXCEPTION_RECORD 结构中获得异常码、引发异常的地址以及异常参数等。EXCEPTION_DEBUG_EVENT 中的第2 个成员dwFirstChance 将告诉调试器是否是第一轮通知这个异常。本章在后面将对第一轮(First Chance )异常通知与第二轮(Second Chance )异常通知进行详细讨论。
从Windows 操作系统的角度来看,调试器必须对异常进行解析,并且将DBG_CONTINUE 或者DBG_EXCEPTION_NOT_HANDLED 作为CotinueDebugEvent 函数的参数。如果指定的是DBG_CONTINUE 参数,那么Windows 假设异常已经被正确地处理了,导致异常发生的条件也已经不复存在了,因此将从产生异常的地址开始恢复程序的执行。如果指定的是DBG_ EXCEPTION_ NOT_HANDLED 参数,那么Windows 表现出来的行为就好像是调试器并不存在,并且将继续它的异常分发流程。
清单3-6 给出了在03sample.exe 中的异常处理器,对于大多数异常来说,这个函数并不会影响Windows 的异常机制。由于Windows 操作系统通过STATUS_BREAKPOINT 异常将其他一些特殊的操作通知给调试器,因此对于这种异常,我们的异常处理器将返回DBG_CONTINUE 。
清单3-6 处理异常调试事件
case EXCEPTION_DEBUG_EVENT:
//typedef struct _EXCEPTION_DEBUG_INFO {
// EXCEPTION_RECORD ExceptionRecord;
// DWORD dwFirstChance;
//} EXCEPTION_DEBUG_INFO;
std::cout << "ExceptiondebugEvent/nException Code:/t " << std::hex <<
dbgEvent.u.Exception.ExceptionRecord.ExceptionCode;
std::cout << "/tFirstChance:/t" << dbgEvent.u.Exception.dwFirstChance
<<std::endl;
switch (dbgEvent.u.Exception.ExceptionRecord.ExceptionCode)
{
case EXCEPTION_BREAKPOINT:
case EXCEPTION_SINGLE_STEP:
return DBG_CONTINUE;
}
return DBG_EXCEPTION_NOT_HANDLED;
处理函数的返回值将被返回到Windows 中,并被作为ContinueDebugEvent 的最后一个参数,参数的名字为dwContinueStatus 。
3.1.2 调试事件的顺序
在调试循环从WaitForDebugEvent 中返回以及调用ContinueDebugEvent 之间的这段时间里,调试目标不会运行,并且它的状态也将保持不变。当挂起调试目标时,调试器将进入交互模式,接受并且执行用户键入的命令。在程序执行中,调试器可以通过API 来找出更多关于调试目标以及调试事件的信息,此外调试器还可以调用其他的Win32 API 来满足用户的需求。如果在调试器中键入一个执行命令,那么调试器将调用ContinueDebugEvent 并且等待下一个事件。
在有了所有这些信息和代码之后,就可以通过03sample.exe 来获得调试目标产生的所有事件。清单3-7 包含了在运行示例程序时产生的控制台输出,其中xcopy.exe 用作参数和调试目标。
清单3-7 在简单的进程执行(xcopy.exe )中产生的调试事件
C:/>C:/AWDBIN /WinXP.x86.chk /03sample.exe xcopy.exe
DebugEvent from PID.TID=33308.32256
EventType: CreateProcessDebugEvent
PID: 33308
DebugEvent from PID.TID=33308.32256
EventType: LoadDllDebugEvent
Mapped address: 7C900000
ImageName: ntdll.dll
DebugEvent from PID.TID=33308.32256
EventType: LoadDllDebugEvent
Mapped address: 7C800000
ImageName: C:/WINDOWS/system32/kernel32.dll
更多的LoadDllDebugEvent 事件
DebugEvent from PID.TID=33308.32256
EventType: LoadDllDebugEvent
Mapped address: 77920000
ImageName: C:/WINDOWS/system32/setupapi.DLL
DebugEvent from PID.TID=33308.32256
EventType: ExceptiondebugEvent
Exception Code: 80000003 FirstChance: 1
DebugEvent from PID.TID=33308.32256
EventType: LoadDllDebugEvent
Mapped address: 5CB70000
ImageName: C:/WINDOWS/system32/ShimEng.dll
更多的LoadDllDebugEvent 事件
DebugEvent from PID.TID=33308.32256
EventType: LoadDllDebugEvent
Mapped address: 5D090000
ImageName: C:/WINDOWS/system32/comctl32.dll
Invalid number of parameters
0 File(s) copied
DebugEvent from PID.TID=33308.32256
EventType: ExitProcessDebugEvent
ExitCode: 4
清单3-7 给出了调试事件的先后顺序,接下来我们将给出一些解释。在启动调试目标时,调试器接收到的第一个事件是CreateProcessDebugEvent ,接下来是一系列的LoadDllDebugEvent 事件,每加载一个进程依赖的动态库都会产生一个这样的事件。由于LoadDllDebugEvent 事件不是由进程映像中产生的,因此在CreateProcessEvent 中包含了在LoadDllDebugEvent 中需要的一些信息,例如可执行文件的句柄、映像的启动地址、调试信息指针,以及可执行的映像名字等。此外还有一些特定事件的信息,例如进程句柄、第一个线程的句柄或者起始地址等。这个事件是在模块已经被映射到进程空间中后产生的,因此可以用来在进程代码中设置断点或者查看全局变量。
在所有的模块都被映射到调试目标的地址空间后,调试目标就准备好运行了,并且调试器将收到进程准备好运行的通知。这是在进程启动之前设置断点的最佳时机。内核将通过STATUS_BREAKPOINT 异常(异常码为0x80000003 )来通知调试器。
此时,03sample.exe 程序将返回DBG_CONTINUE ,这使得调试目标进程开始执行。这个进程将把一些它依赖的动态库加载到进程空间中,并产生相应的LoadDllDebugEvent 事件。
最后,进程将执行相应的任务,并将信息输出到调试器控制台中。在进程执行完后,调试目标在退出之前将产生最后一个调试事件ExitDebugProcessEvent 。
我们需要了解调试事件的先后顺序,或者找出在哪些情况下调试器将无法收到某个事件。例如,当进程结束时,调试器将不会收到进程中所有已加载动态库发出的UnloadDllDebugEvent 事件。同样还要理解每个异常的含义以及在哪些情况下Windows 操作系统将通过STATUS_BREAKPOINT 异常将特殊的调试事件通知到调试器。在知道了这些调试事件以及它们在调试目标生命周期中的发生顺序后,我们接下来将在本章的剩余内容中使用windbg.exe 调试器,并将02sample.exe 作为调试目标。
3.1.3 控制来自调试器的异常和事件
所有事件的创建方式和处理方式并非都是一致的。Windows 调试器将截获所有的调试事件,但在不同类型的事件之间,甚至在同一类型的不同事件之间,调试器处理事件的方式或者用户控制这些事件的方式都可能是不同的。大多数调试事件只是一些通知事件,调试器可以选择忽略这些事件并且继续执行,有时候也会输出对这些事件的简单描述,然后继续执行。用户也可以要求调试器在发生某个事件时停止执行,从而使用户能够与系统交互。
虽然之前给出的大多数调试事件都是由Windows 操作系统产生的,而与调试目标的执行无关,但调试目标在执行中也会产生一些调试异常事件。在异常处理代码与调试器之间的交互操作应该对运行时执行流产生最小的影响,并且同时为调试器提供最大的灵活性。调试器在处理异常时可以选择与处理其他调试事件一样的方式;调试器可以选择忽略这些异常,或者在屏幕上输出异常信息,或者在发生这些异常时中断到调试器中。对于相同的异常,调试事件EXCEPTION_DEBUG_EVENT 可以生成多次,在本章的后面将进行介绍。异常产生的第一个事件,也称为第一轮异常(First-Chance Exception ),将被发送出去,而同一个异常产生的第二个事件,也称为第二轮异常(Second-Chance Exception ),意味着操作系统或者应用程序不能处理这个异常。由于第二轮异常将变成终止进程的未处理异常,因此分析并理解每个异常的合法性以及重新评估应用程序在这种情况下的行为就显得非常重要。
Windows 操作系统通过结构化异常处理(Structured Exception Handling ,SEH )机制将处理器引发的异常传递给内核以及用户态应用程序。每个SEH 异常都通过一个无符号整数的异常码(Exception Code )来惟一标识,这个异常码是在系统引发异常时指定的。在操作系统引发的异常中使用了操作系统开发人员定义的公开异常码(例如访问违例异常或者断点异常等)。还有些其他的异常,例如C++ 异常,在系统中也被表示为一个使用了指定异常码的结构化异常。C++ 异常信息是由编译器提供的运行时来管理的。
例如,C++ 异常码为0xE06D7363 ,访问违例异常码为0xC0000005 ,断点异常码为0x80000003 。在WDK 的头文件<ntstatus.h> 中可以找到一些异常码,这些异常码都被定义为常量,并且常量的名字都是形如STATUS_<NAME> ,软件开发人员在Windows 平台上开发代码时将用到这些异常码,例如
#define STATUS_BREAKPOINT ((NTSTATUS)0x80000003L)
你可能会问,为什么这些内容与调试Windows 代码的工程师有关。答案是为了最大限度地发挥调试工具的功能。软件开发人员可以只使用符号名字,而忽略这个名字所表示的值。这样,在他们的代码和操作系统之间就存在一个间接层,这使得他们编写的应用程序不受操作系统变化的影响,并且也更容易阅读和理解。由于在符号文件中没有包含对原始符号名字的引用,因此调试器将显示这些符号表示的数值。这种情况在将来一段时间内不太可能发生变化,并且对于目前的一些系统来说也不会发生变化。因此,我们要熟悉在本书中反复出现的一些“魔法(Magic )”数值。更重要的是,你需要知道如何来找出这些数值的含义。在调试器的帮助文档中可以找到大多数异常符号(Exception-Symbolic )的名字,包括这些名字所在的头文件或者它们的值(帮助主题为“Specific Exception ”)。
事件的别名
异常码是很难记住的,因此在Windows 调试器中包含了一些更容易记住的别名来控制调试器的行为。别名类似于异常类型,可以用来在处理调试事件的命令中替代异常码。例如,C++ 异常码0xE06D7363 是很难记住的,因此调试器包含了一个别名eh 来表示这个异常,而断点异常码0x80000003 的别名是bpe 。
作为异常的调试事件 有些调试事件实际上是为了实现某种行为而引发的异常,例如断点异常或者输出调试字符串的事件等。在这些情况中,我们应该通过其他的线索来找出中断的原因,例如栈。
查看事件的中断与处理
在使用内置的事件处理命令sx 时不需要参数,用户可以通过这个命令来查看各个调试会话中的事件处理设置(参见清单3-8 )。这个命令的输出信息可以分为三个部分:第一部分是事件处理与相应处理模式的交互,第二部分是标准的异常交互和处理行为,最后一部分是用户自定义的异常交互和处理行为。
清单3-8 显示当前的事件处理状态
0:000> sx
ct - Create thread - ignore
et - Exit thread - ignore
cpr - Create process - ignore
epr - Exit process - break
ld - Load module - output
ud - Unload module - ignore
ser - System error - ignore
ibp - Initial breakpoint - break
iml - Initial module load - ignore
out - Debuggee output - output
av - Access violation - break - not handled
asrt - Assertion failure - break - not handled
aph - Application hang - break - not handled
bpe - Break instruction exception - break
bpec - Break instruction exception continue - handled
eh - C++ EH exception - break - not handled
clr - CLR exception - second-chance break - not handled
clrn - CLR notification exception - second-chance break - handled
cce - Control-Break exception - break
cc - Control-Break exception continue - handled
cce - Control-C exception - break
cc - Control-C exception continue - handled
dm - Data misaligned - break - not handled
dbce - Debugger command exception - ignore - handled
gp - Guard page violation - break - not handled
ii - Illegal instruction - second-chance break - not handled
ip - In-page I/O error - break - not handled
dz - Integer divide-by-zero - break - not handled
iov - Integer overflow - break - not handled
ch - Invalid handle - break
hc - Invalid handle continue - not handled
lsq - Invalid lock sequence - break - not handled
isc - Invalid system call - break - not handled
3c - Port disconnected - second-chance break - not handled
sse - Single step exception - break
ssec - Single step exception continue - handled
sbo - Stack buffer overflow - break - not handled
sov - Stack overflow - break - not handled
vs - Verifier stop - break - not handled
vcpp - Visual C++ exception - ignore - handled
wkd - Wake debugger - break - not handled
wob - WOW64 breakpoint - break - handled
wos - WOW64 single step exception - break - handled
* - Other exception - second-chance break - not handled
Exception option for:
12345678 - break - not handled
调整事件的中断和处理
当发生某个事件时,如果我们可以中断程序的执行,那么异常将是非常有用的,本节将介绍如何在交互式的命令提示符中控制调试器的行为。命令sx 的最常见语法形式如下所示:
sx{e|d|i|n} [-c "Cmd1"] [-c2 "Cmd2"] [-h] {Exception|Event|*} [parameter]
其中
?sxe (set exceptions enable )用于启用事件上的调试中断。
?sxd (set exceptions disable )用于禁用事件上的调试中断。虽然第一轮异常不会造成中断,但第二轮异常产生的中断和信息都将显示在屏幕上。
?sxn (set exceptions notify )用于禁用调试中断(无论是第一轮异常还是第二轮异常),不过它仍然会将这个消息输出到屏幕上。这个命令的副作用之一就是调试器将进入持续的循环中。操作系统把第一轮异常通知给调试器,而调试器将输出一条信息并且调试目标将继续执行。如果没有找到异常处理器,调试器将收到第二轮通知。在继续执行后,调试器将再次收到第一轮异常,这个过程将反复进行,直到调试器收到另一个事件。
?sxi (set exceptions ignore )将完全“忽略”异常(无论是第一轮异常还是第二轮异常);对异常的处理方式与sxn 中的处理方式是完全一样的。
?-c 是一个参数,它包含的是当调试器接收到一个新的调试事件时需要执行的命令。当这个事件是一个异常事件时,这个参数只会影响第一轮异常。由于这个命令是在调试器处理事件之前执行的,因此它一定不能包含‘g ’(go )语句。
?-c2 也是一个参数,它包含的是在第二轮异常被分发到调试器时需要执行的命令。由于这个命令是在调试器处理事件之前执行的,因此它一定不能包含‘g ’(go )语句。
?Exception|Event|* 表示事件的别名、异常的别名或者异常码,例如创建线程事件的别名是ct ,访问违例异常的别名是av (异常码为0xC0000005 )。星号字符(* )表示通过异常码而不是别名来标识的异常。
?parameter 包含的是特定事件的参数。例如,在这个参数中可以指定在加载一个或多个动态库时才引发DllLoadEvent 事件。如果需要在加载ole32.dll 时中断应用程序,那么必须通过以下的命令来配置DllLoadEvent 事件。
0:000> sxe ld:ole32.dll
?-h 是一个参数,它将告诉调试器修改处理行为而不是中断行为。在本章开头已经介绍了,在收到一个异常事件后,调试器必须把处理异常的状态返回给操作系统。由于没有一种显式的方式可以用来指定处理状态,因此必须根据命令来进行推断:sxe 意味着异常已经被调试器处理了;而其他的命令则意味着异常没有被调试器处理。
另一个交互式命令sxr 可以用来将所有的事件中断和事件处理都重置为默认值。
SSE 与SSEC 的区别是什么 在仔细观察了所有的事件后,我们可以看到一些异常是成对出现的,例如在sse (single step exception )后面紧跟着ssec (single-step exception continuation )。操作系统并不支持这种区分,而只是由调试器引擎来解析。这两个异常的目的只是为了更容易地在命令行上将中断状态和处理状态区分为两个不同的事件。
在Windbg 的GUI 中调整事件中断和处理
虽然命令窗口提供了非常强大的灵活性,但大多数人更喜欢通过WinDbg 的图形界面来修改事件中断和处理的状态。在使用Windbg 的调试会话中,选择“Debug ”菜单中的“Event Filters ”菜单项,如图3-1 所示:
在命令行中的所有选项同样会出现在“Event Filters ”窗口中。事件命令字符串(-c 和-c2 )可以通过点击“Commands ”按钮来修改,中断状态可以通过单选按钮“Execution ”来修改,处理状态可以通过单选按钮“Continue ”来修改,而事件的参数则可以通过“Argument ”按钮来添加。修改事件中断和处理状态的一些命令将对事件列表中选中的事件产生影响。如果在调试目标中使用的异常码没有出现在列表窗口中,那么可以通过“Add ”按钮和“Remove ”按钮来增加或者删除异常码。
调整事件中断和处理的默认值
在知道了如何在交互式模式中控制事件中断和处理状态后,我们就可以对调试环境进行调整以满足实际的调试需要。在某些情况中,默认的事件处理设置并不足以应对实际的调试情况。例如,在数字版权管理(Digital Rights Management ,DRM )系统中,用来管理媒体许可的模块就无法在常规的调试器设置下进行调试,因为在这个模块中使用了各种反调试技巧,例如已处理的访问违例、已处理的调试断点等,因此在常规的调试器设置下无法进行调试。显然,这些反调试技巧利用的是调试器在进程行为中引入的副作用。
在这种情况下,软件工程师必须通过其他的方式来调整事件中断和处理的默认值以满足特定调试的需要。调整默认值的最常见方式是使用在表3-1 中给出的命令参数。在表中给出了各个命令行选项以及相应的交互命令和命令描述信息。
表3-1 命令行参数和交互式命令之间的映射
参数 交互式命令 描述
-g sxd ibp 在进程启动时不发生中断
-G sxd epr 在进程结束时不发生中断
-xe <event> sxe <event> 在<event> 出现时发生中断
-xd <event> sxd <event> 在<event> 出现时不发生中断
-xi <event> sxi <event> 忽略所有的<event>
-xn <event> sxn <event> 当<event> 出现时通知调试器
-x sxd av 在发生访问违例时不中断
为了说明在命令行参数和交互式命令之间的映射,我们将解释下面的命令产生的作用:
C:/> windbg 術 -xe ld:kernel32*-xd av <debugger target>
?-g 将禁止初始断点。
?-xd av 将禁止访问违例断点。
?-xe ld:kernel32 将在kernel32.exe 被映射到地址空间之后发生中断。在指定库的名字时可以包含通配符。例如,字符串ld:msvc* 将匹配所有版本的C 运行时库。
在命令行调试器中设置初始调试环境的另一个方式是,通过调试器在启动时读取的初始化文件来设置的。这个初始化文件的名字叫作tools.ini ,在环境变量INIT 中包含了这个文件的位置。例如,要想获得与前面ntsd.exe 的命令行一样的行为,那么在tools.ini 中必须包含清单3-9 中的几行内容。
清单3-9 Tools.ini 中的内容
[NTSD]
sxd: av
sxd: ibp
sxe: ld kernel32.dll
WinDbg 调试器将从工作区文件中加载这些默认设置以及其他的运行时参数,这个工作区文件是由用户在调试会话结束时显式地或者隐式地创建的。在调试器的帮助文档中详细地介绍了工作区(帮助主题为“Workspace ”)。
保存环境设置 WinDbg 将保存上一次的调试器设置,并且在启动新的调试会话时将重新加载它们。虽然这不是一种控制环境的方式,但却为一些不常使用调试器的用户提供了非常友好的体验。
调试事件
本节将讨论清单3-8 中的一些事件,我们在调试器控制台中分析这些事件,指出它们的特点以及使用方式。由于下一节将讨论异常,因此本节将讨论与进程动作相关的调试事件:创建进程的调试事件、退出进程的调试事件、加载DLL 的调试事件、卸载DLL 的调试事件,创建线程的调试事件,以及退出线程的调试事件。
创建进程的事件(cpr )
Windows 调试器将自动处理cpr 事件,注意不要将这个事件与初始断点事件相混淆。在必要的情况下,可以在调试器的命令行中禁止对这个事件进行自动处理。这个事件是发生在动态库被加载到进程地址空间之前。此时,所有需要初始化的全局变量还没有被初始化,而在一些数据变量中也只是它们的默认值。这是调试器用户第一次能够执行各种命令的时刻,例如设置断点或者对进程映像中的函数反编译。如果用户希望在加载进程的某个动态库时得到通知,那么这是最佳的时刻。
初始断点事件(ibp )
在进程所依赖的动态库都已经被加载后,系统将产生另一个异常来表示初始断点。初始断点是在进程开始执行之前触发的。此时,我们可以在初始化全局变量的构造函数中设置一个断点,或者在进程映像的任何函数中设置断点,例如main 函数。
如果我们不希望设置初始断点,那么可以通过命令行参数-g 来修改对这个事件的处理。在第2 章中已经给出了如何通过初始断点来自动化任务的示例。我们应该注意到,初始断点看上去与普通断点没有任何不同之处,这个事件必须通过检查当前断点处的栈来识别,如清单3-10 所示。在命令.lastevent 中显示的前两个数值分别是引发这个事件的进程标识和线程标识。
清单3-10 在调试器中启动进程时的初始断点栈回溯
0:000> .lastevent
Last event: 13b4.184: Break instruction exception - code 80000003 (first chance)
0:000> k
ChildEBP RetAddr
0007fb1c 7c93edc0 ntdll!DbgBreakPoint
0007fc94 7c921639 ntdll!LdrpInitializeProcess+0xffa
0007fd1c 7c90eac7 ntdll!_LdrpInitialize+0x183
00000000 ntdll!KiUserApcDispatcher+0x7
退出进程的事件(epr )
在调试目标结束之前,调试器将收到最后一个通知,即epr 事件。.lastevent 命令将根据这个事件的信息来显示进程退出码,如清单3-11 所示。在默认情况下,这个事件将不会被处理,但我们可以通过在启动调试器时使用命令行参数-G 来修改这个默认行为。
清单3-11 在调试器下进程的最后一个事件
0:000> .lastevent
Last event: 1674.c80: Exit process 0:1674, code 0
0:000> k
ChildEBP RetAddr
0007fde4 7c90e89a ntdll!KiFastSystemCallRet
0007fde8 7c81ca5e ntdll!NtTerminateProcess+0xc
0007fee4 7c81cab6 kernel32!_ExitProcess+0x62
0007fef8 77c39d45 kernel32!ExitProcess+0x14
0007ff04 77c39e78 msvcrt!__crtExitProcess+0x32
0007ff14 77c39e90 msvcrt!_cinit+0xee
0007ff28 01007522 msvcrt!exit+0x12
0007ffc0 7c816d4f notepad!WinMainCRTStartup+0x185
0007fff0 00000000 kernel32!BaseProcessStart+0x23
加载模块的事件(ld )
当动态库被映射到进程的内存地址空间中并且在执行库的初始化代码之前,Windows 操作系统将产生这个事件。这是在库的初始化代码中设置断点或者了解为什么将指定库加载到进程空间中的惟一机会。我们可以通过查看这个事件的调用栈来了解加载库原因,如清单3-12 所示。
清单3-12 在加载动态链接库之后的栈回溯
0:000> .lastevent
Last event: 43c.b18: Load module C:/WINDOWS/system32/ShimEng.dll at 5cb70000
0:000> k
ChildEBP RetAddr
0007f72c 7c90dc61 ntdll!KiFastSystemCallRet
0007f730 7c91c3da ntdll!NtMapViewOfSection+0xc
0007f824 7c916071 ntdll!LdrpMapDll+0x330
0007fae4 7c924a07 ntdll!LdrpLoadDll+0x1e9
0007fb10 7c9216b6 ntdll!LdrpLoadShimEngine+0x28
0007fc94 7c921639 ntdll!LdrpInitializeProcess+0x1079
0007fd1c 7c90eac7 ntdll!_LdrpInitialize+0x183
00000000 00000000 ntdll!KiUserApcDispatcher+0x7
卸载模块的事件(ud )
在调用FreeLibrary (参加清单3-13 )将动态库从地址空间中卸载之后,将会产生这个事件。这个事件在跟踪动态链接库的卸载情况时是非常有用的。
清单3-13 查看ud 事件
0:000> .lastevent
Last event: 138c.cbc: Unload module C:/WINDOWS/System32/MSXML3.DLL at 74980000
0:000> k
ChildEBP RetAddr
0007fc28 7c90e96c ntdll!KiFastSystemCallRet
0007fc2c 7c91e7d3 ntdll!NtUnmapViewOfSection+0xc
0007fd1c 7c80aa7f ntdll!LdrUnloadDll+0x31a
0007fd30 77513442 kernel32!FreeLibrary+0x3f
0007fd3c 77513456 ole32!CClassCache::CDllPathEntry::CFinishObject::Finish+0x2f
0007fd50 77530729 ole32!CClassCache::CFinishComposite::Finish+0x1d
0007fe10 7752fd6a ole32!CClassCache::CleanUpDllsForProcess+0x1b2
0007fe14 7752fee4 ole32!ProcessUninitialize+0x37
0007fe28 774fee88 ole32!wCoUninitialize+0x11b
0007fe44 01035966 ole32!CoUninitialize+0x5b
0007ff44 0103caab WMIC!wmain+0x8af
0007ffc0 7c816d4f WMIC!wmainCRTStartup+0x125
0007fff0 00000000 kernel32!BaseProcessStart+0x23
创建线程的事件(ct )
当创建一个新线程时,将会产生ct 事件(参见清单3-14 )。不幸的是,这个事件没有包含任何有用的信息,例如线程创建者的栈或者标识。然而,在调试与线程池中线程生命周期相关的问题时,这个事件是非常有用的。在调用Kernel32!CreateThread 的地方设置一个断点通常足以判断创建线程的执行路径。
清单3-14 查看ct 事件
0:001> .lastevent
Last event: 1494.1220: Create thread 1:1220
0:001> k
ChildEBP RetAddr
0007cea4 00090178 kernel32!BaseThreadStartThunk
WARNING: Frame IP not in any known module. Following frames may be wrong.
0007cea4 00000000 0x90178
退出线程的事件(et )
当线程结束运行时将产生这个事件。我们可以通过栈回溯来找出线程结束的原因。例如,在清单3-15 中的线程被ole32.dll 线程池的空闲检测机制终止时,将会正常退出。
清单3-15 分析et 事件
0:003> .lastevent
Last event: 1494.11ac: Exit thread 3:11ac, code 0
0:003> k
ChildEBP RetAddr
011eff50 7c90e8af ntdll!KiFastSystemCallRet
011eff54 7c80cd04 ntdll!NtTerminateThread+0xc
011eff94 7c80cebf kernel32!ExitThread+0x8b
011effa0 774fe45d kernel32!FreeLibraryAndExitThread+0x28
011effb4 7c80b50b ole32!CRpcThreadCache::RpcWorkerThreadEntry+0x34
011effec 00000000 kernel32!BaseThreadStart+0x37
结构化异常分发机制
异常是一个在代码执行期间发生的事件,这个事件或者是由于CPU 在执行代码时遇到了某个问题(这也称为硬件异常),或者是由于某个指令明确地引发异常(这也称为软件异常)。硬件异常是CPU 使用的机制,用来通知在执行指令流期间遇到的错误,例如执行了无效的指令或者断点语句。由于在代码中没有显式的语句来引发异常,因此在编译器文档中通常将这种硬件异常称为异步异常。
另一方面,软件异常是通过将异常信息与指定的处理模式传递给用户态API kernel32! RaiseException 来引发。在一些高级语言中,例如C++ 或者.NET 语言,都是通过这种机制来抛出异常,并且依赖操作系统来正确地分发异常。由于编译器知道throw 语句将在代码执行中引发中断,因此这种异常也称为同步异常。
本章的剩余部分将使用02sample.exe 作为调试目标。在这个示例中包含了一组不良行为,例如访问无效的地址,在引发异常后不进行处理等。每一种不良行为都可以从应用程序的菜单中进行选择。例如,如果选择选项‘3 ’,那么这个示例将模拟未处理的C++ 异常。
异常结构
为了在整个操作系统中实现一致的异常处理机制,Windows 操作系统将上述两种异常统一起来,并将所有的异常都视作为结构化异常,而不考虑异常的来源。这种一致性包括在操作系统和异常处理器之间通过相同的数据结构来传递异常信息。在<winnt.h> 中包含了一个结构_EXCEPTION_ POINTERS ,当异常被引发时,这个结构将包含一个指向异常记录的指针,以及另一个指向处理器上下文的指针,如下所示:
struct _EXCEPTION_POINTERS {
EXCEPTION_RECORD *ExceptionRecord,
CONTEXT *ContextRecord }
在清单3-16 中给出了在<winnt.h> 中定义的结构EXCEPTION_RECORD 。操作系统将把这个结构传递给调试器,调试器将根据这个结构中的内容来解析和显示异常信息。
清单3-16 定义在头文件<winnt.h> 中的EXCEPTION_RECORD 结构
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;
由于大多数异常都是一些不太严重的调试断点语句,因此操作系统在必要时需要记录异常位置上的处理器状态以在随后恢复代码的执行。处理器状态被保存在处理器架构特定的结构中,这个结构也称为异常上下文,定义在头文件<winnt.h> 中,其中包含了所有的寄存器值。结构中的第一个成员是CONTEXT 类型的结构(参见清单3-17 )。
清单3-17 CONTEXT 结构
typedef struct _CONTEXT {
DWORD ContextFlags;
...
} CONTEXT,
ContextFlags 的取值同样是定义在<winnt.h> 中的常量。例如,在清单3-18 中给出了针对x86 系列处理器定义的一些常量值。对于在x86 处理上运行的应用程序来说,在异常上下文中的第一个值通常是0x0001003f ,这个值表示常量CONTEXT_ALL 。当我们在栈中进行搜索以分析某个内存块的含义时,这个标志是非常有用的。我们可以通过这个标志来找到上下文,并将其设置为当前线程的上下文,从而了解在异常发生之前处理器的状态是什么。
清单3-18 x86 上下文中的标志值
#define CONTEXT_i386 0x00010000 // this assumes that i386 and
#define CONTEXT_CONTROL (CONTEXT_i386 | 0x00000001L) // SS:SP, CS:IP, FLAGS,
BP
#define CONTEXT_INTEGER (CONTEXT_i386 | 0x00000002L) // AX, BX, CX, DX, SI,
DI
#define CONTEXT_SEGMENTS (CONTEXT_i386 | 0x00000004L) // DS, ES, FS, GS
#define CONTEXT_FLOATING_POINT (CONTEXT_i386 | 0x00000008L)// 387 state
#define CONTEXT_DEBUG_REGISTERS (CONTEXT_i386 | 0x00000010L)// DB 0-3,6,7
#define CONTEXT_EXTENDED_REGISTERS (CONTEXT_i386 | 0x00000020L) // cpu-specific
extensions
#define CONTEXT_FULL (CONTEXT_CONTROL | CONTEXT_INTEGER |/
CONTEXT_SEGMENTS)
#define CONTEXT_ALL (CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS |
CONTEXT_FLOATING_POINT | CONTEXT_DEBUG_REGISTERS | CONTEXT_EXTENDED_REGISTERS)
异常的生命周期
硬件异常强行把处理器的控制权从当前执行的程序中转移到处理中断事件的例程中。这些例程称为中断处理函数,它们是由操作系统安装的。当处理器状态切换到内核态之后,内核将把处理器状态保存到一个陷阱上下文中,这个陷阱上下文可以用来在状态迁移之前查看处理器的状态。清单3-19 给出了线程在引发异常之后的调用栈。抛出异常的进程是通过命令行windbg.exe 02sample.exe 在用户态调试器中启动的,通过选择选项‘3 ’来引发异常。然后,这个进程将停止在调试器中,并等待用户输入。事实上,当Windows 操作系统将异常信息分发给调试器时,这个线程将被阻塞,我们可以通过内核态调试器来查看。通过扩展命令!process 来找出这个进程,并且通过扩展命令!thread 来解析线程的栈。
清单3-19 分发到用户态调试器的异常
kd> !process 0 4 02sample.exe
PROCESS ff68a020 SessionId: 0 Cid: 0a7c Peb: 7ffdd000 ParentCid: 0a70
DirBase: 03912000 ObjectTable: e180e158 HandleCount: 7.
Image: 02sample.exe
THREAD ffa7d868 Cid 0a7c.0a78 Teb: 7ffdf000 Win32Thread: 00000000 WAIT
kd> !thread ffa7d868
THREAD ffa7d868 Cid 0a7c.0a78 Teb: 7ffdf000 Win32Thread: 00000000 WAIT: (Executive)
KernelMode Non-Alertable
SuspendCount 1
f7cf3490 SynchronizationEvent
Not impersonating
DeviceMap e19f85a0
Owning Process ff68a020 Image: 02sample.exe
Wait Start TickCount 14796478 Ticks: 1035 (0:00:00:10.364)
Context Switch Count 44
UserTime 00:00:00.0000
KernelTime 00:00:00.0290
Win32 Start Address 02sample!mainCRTStartup (0x0040183d)
Start Address kernel32!BaseProcessStartThunk (0x7c810867)
Stack Init f7cf4000 Current f7cf3414 Base f7cf4000 Limit f7cf1000 Call 0
Priority 10 BasePriority 8 PriorityDecrement 0 DecrementCount 16
ChildEBP RetAddr Args to Child
f7cf342c 804dc6a6 ffa7d8d8 ffa7d868 804dc6f2 nt!KiSwapContext+0x2e ()
f7cf3438 804dc6f2 00000000 ffa7d868 f7cf3488 nt!KiSwapThread+0x46
f7cf3460 8065879b 00000000 00000000 00000000 nt!KeWaitForSingleObject+0x1c2
f7cf3540 80659903 ff68a020 00000000 f7cf3578 nt!DbgkpQueueMessage+0x17c
f7cf3564 8060fed2 f7cf3578 00000001 f7cf3d64 nt!DbgkpSendApiMessage+0x45
f7cf35f0 804fc914 f7cf39d8 00000001 00000000 nt!DbgkForwardException+0x8f
f7cf39b0 804fcbfe f7cf39d8 00000000 f7cf3d64 nt!KiDispatchException+0x1f4
f7cf3d34 804e297d 0006fe48 0006fb64 00000000 nt!KiRaiseException+0x175
f7cf3d50 804df06b 0006fe48 0006fb64 00000001 nt!NtRaiseException+0x31
f7cf3d50 7c81eb33 0006fe48 0006fb64 00000001 nt!KiFastCallEntry+0xf8 (TrapFrame @
f7cf3d64 )
0006fe98 77c2272c e06d7363 00000001 00000003 kernel32!RaiseException+0x53
0006fed8 004012c5 0006feec 00401d38 004012b0 msvcrt!_CxxThrowException+0x36
0006fef0 00401471 00011970 7c9118f1 7ffdd000 02sample!RaiseCPP+0x25
0006ff44 0040196c 00000002 00262588 00262a58 02sample!wmain+0xe1
0006ffc0 7c816d4f 00011970 7c9118f1 7ffdd000 02sample!mainCRTStartup+0x12f
0006fff0 00000000 0040183d 00000000 78746341 kernel32!BaseProcessStart+0x23
kd> .trap f7cf3d64
ErrCode = 00000000
eax=0006fe48 ebx=7ffdd000 ecx=00000000 edx=002625b0 esi=0006fed8 edi=0006fed8
eip=7c81eb33 esp=0006fe44 ebp=0006fe98 iopl=0 nv up ei pl nz na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000206
kernel32!RaiseException+0x53:
001b:7c81eb33 5e pop esi
kd> k
*** Stack trace for last set context - .thread/.cxr resets it
ChildEBP RetAddr
0006fe98 77c2272c kernel32!RaiseException+0x53
0006fed8 004012c5 msvcrt!_CxxThrowException+0x36
0006fef0 00401471 02sample!RaiseCPP+0x25
0006ff44 0040196c 02sample!wmain+0xe1
0006ffc0 7c816d4f 02sample!mainCRTStartup+0x12f
0006fff0 00000000 kernel32!BaseProcessStart+0x23
处理函数将通过这个陷阱信息以及从处理器中获取的其他信息来生成两部分信息:异常记录(描述所发生的异常)和异常上下文(包含的是处理器在异常发生时的状态)。请注意,在切换到内核态时记录下来的陷阱帧信息(在前面栈中的TrapFrame )可以被用作为.trap 命令的上下文信息,如清单3-19 所示。
软件异常的引发是通过调用kernel32!RaiseException 来完成的,然后这个函数又将调用一个未公开的系统函数 ntdll!NtRaiseException 。ntdll!NtRaiseException 将创建异常记录并且在异常上下文中记录进程的状态。在有了异常记录和异常上下文之后,内核就可以通过异常分发机制来分发异常,这种机制与硬件异常的分发机制是类似的。
分发进程将在内核态中启动,然后将根据异常发生时所处的模式来决定是在用户态中继续执行还是在内核态中继续执行。在内核态中发生的所有异常都需要被处理;否则,这个异常将导致一个错误检查(Bug Check ,也称为蓝屏错误或者BSOD ),如下所示:
bug check 0x8E: KERNEL_MODE_EXCEPTION_NOT_HANDLED
在记录了之前描述的异常信息后,操作系统将启动异常分发过程。在这个过程中,Windows 操作系统将执行一系列动作,例如:
? 调用所有已注册的异常处理器,直到异常被处理。
? 提供一些额外的功能,例如异常日志。
? 最终确定对未处理的异常做如何处理。
Windows 操作系统提供的这个复杂功能几乎是悄悄执行的。我们使用了“几乎”这个词,是因为与普通的代码执行相比,异常分发过程的开销相对较高。只要在普通的执行流中没有引发异常,那么分发异常的开销就可以忽略不计。
异常分发
在分发异常时,Windows 操作系统将考虑调试器的可用性—也就是说,究竟是一个附加到普通进程的用户态调试器,还是一个附加到系统进程的内核态调试器。本节将仅讨论在执行用户态代码时发生的异常。
当Windows 操作系统开始处理用户态异常时,它首先会要求用户态调试器附加到进程上以处理异常。如果没有任何调试器被附加到进程,那么内核将查看一个控制分发过程的全局标志,并根据这个标志来分发异常。nt!NTGlobalFlag 中的第0 位控制着异常处理行为,其名字为StopOnException (soe )。当设置了StopOnException 标志时,如果没有用户态调试器附加到进程上,那么在进程中发生的所有异常都将首先被分发到附加在目标系统上的内核态调试器中。如果没有设置这个标志,那么内核态调试器将不会干涉异常分发代码,除非异常有着特殊的调试含义,例如STATUS_BREAPOINT 和STATUS_ SINGLE_STEP 。
解析这个标志的最佳方式是使用扩展命令!gflag ,这个命令将解析出nt!NTGlobalFlag 的内容,如清单3-20 所示。
清单3-20 解析内核的全局标志
kd> dc nt!NtGlobalFlag l1
80540aec 00000001 ....
kd> !gflag
Current NtGlobalFlag contents: 0x00000001
soe - Stop On Exception
与所有其他内核标志一样,这个标志可以在调试器控制台中进行修改。此外,这个标志还可以通过gflags.exe 来修改,这个工具是随Windows 调试工具集一起安装的。清单3-21 给出了一个示例,在示例中通过gflags.exe 来临时性地或者永久性地设置StopOnException 标志。
清单3-21 通过gflags.exe 来修改内核标志
c:/> gflags -k +soe
Current Running Kernel Settings are: 00000000
soe - Stop On Exception
c:/> gflags -r +soe
Current Boot Registry Settings are: 00000001
soe - Stop On Exception
然而,如果想获得更好的交互式体验,那么用户可以在启动gflags.exe 时不带参数,这样可以在图形用户界面中修改内核标志,如图3-2 所示。
无论通过何种方式来修改StopOnException 标志,异常行为产生的影响都是相同的。下一节将介绍内核在分发异常时采取的步骤,下面这些步骤将考虑StopOnException 标志的值。下面我们将介绍分发用户态异常的逻辑流程。图3-3 给出了这个逻辑的流程图。
用户态异常的分发流程可以归纳为:
1. 当引发一个新的异常时,如果存在用户态调试器,那么Windows 内核将尝试把异常分发到用户态调试器,同时异常分发流程将跳到步骤6 继续执行。如果是一个内核态调试器附加到进程上,那么异常分发流程将从步骤2 继续执行;否则,将跳到步骤4 继续执行。
2. 一些对调试器有意义的异常,例如STATUS_BREAKPOINT 或者STATUS_SINGLE_ STEP 等,都将作为调试通知(Debugger Notification )发送到内核态调试器。如果设置了StopOnException 标志,那么所有的异常也将作为调试通知发送到内核态调试器;否则,异常分发流程将从调试步骤4 继续执行。系统被“冻结”起来,并且等待对内核调试通知的响应。
图3-3 异常分发流程
3. 内核态调试器将分析异常,并且根据调试器的当前设置来处理异常。在这种情况下,异常将被清除,并且在内核态调试器对调试通知做出回应后,代码将从发生异常位置开始继续执行。对于未处理的异常,分发流程将从步骤4 继续执行。
4. Windows 内核在调用栈的所有函数中查找一个异常处理器。在这个阶段中找到的异常处理过滤器将被调用,首先从栈中的最近函数开始,直到某个过滤器返回了EXCEPTION_ EXECUTE_ HANDLER 。从Windows XP 和Windows Server 2003 开始,开发人员可以使用一种向量异常处理机制(Vectored Exception Handler Mechanism ),在开始搜索过程之前注册其他的过滤器。在有了之前找到的异常处理器之后,内核将把程序执行流程回退到这个处理函数所在的函数,执行在被遍历函数中注册的异常处理器—这个过程称之为栈回退(Stack Unwind )。最后,代码将继续执行目标函数中的异常处理器。
5. 如果在当前的线程栈中没有任何处理函数能够处理当前的异常,那么会出现什么情况?每个线程都会使用内置的过滤器和处理函数来保护代码,这些函数将处理在用户代码中没有处理的所有异常。这个过滤器通常称之为未处理异常过滤器(Unhandled Exception Filter ),它会采取必要的步骤来结束进程,例如当某个异常没有被处理时,未处理异常过滤器将调用kernel32! Unhandled ExceptionFilter 。第13 章将介绍未处理异常过滤器的逻辑流程。
6. 当用户态调试器被附加到进程时,它将收到异常通知,并且根据调试器的当前设置,这个异常可能会被处理也可能不被处理(请参见前面的关于异常处理设置)。在调试器的帮助文档中将这个通知称为第一轮异常。调试器在处理未处理异常时将继续搜索能够处理这个异常的函数,并且在必要时执行栈回退操作。用户态调试器在处理完异常之后,例如STATUS_BREAKPOINT ,将通过从产生异常的代码位置处继续执行。
7. 如果调试器没有处理异常,并且在步骤6 中也没有找出处理函数,那么Windows 内核将第二次尝试由调试器来处理异常,这个通知也称为第二轮异常。如果调试器仍然没有处理这个异常,那么进程将重新启动步骤6 ,直到异常被处理。
下一节将根据前面介绍的逻辑来给出不同调试器配置对各种异常的影响。
异常在不同调试器配置下的表现
我们将再次通过02sample.exe 示例来说明用户态的异常分发逻辑。不同的选项将使得代码路径产生不同的异常处理行为。在C 语言中,异常处理器是通过__try/__except 这些关键字来创建的,这些关键字是Microsoft 对编译器的一种扩展,用于生成操作系统所需的异常过滤器和处理器。本节将详细讨论在Windows 操作系统中实现的异常处理机制。清单3-22 给出了示例程序中每个选项所对应的代码,这些代码是被编译在02sample.exe 中。
清单3-22 在异常分发逻辑中使用的代码
产生访问违例异常的代码,选项为'1'
void RaiseAV()
{
_alloca(1); // 强制编译器生成一个栈帧
char* invalidAddress = 0;
*invalidAddress = 0;
}
产生断点异常的代码,选项为'2'
void RaiseBP()
{
_alloca(1); // 强制编译器生成一个栈帧
DebugBreak();
}
处理访问违例异常的代码,选项为'b'
__try
{
RaiseAV();
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
}
处理断点异常的代码,选项为'c'
__try
{
RaiseBP();
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
}
上面给出的每个函数都是运行在不同的环境中。接下来将详细讨论在代码与Windows 操作系统之间的所有交互信息(在附加了调试器的情况下则是与调试器进行交互)。在进行实验时,我们假设系统上安装的任何程序都不会改变系统的配置,包括那些调试工具或者带有调试功能的开发工具。
同一个可执行程序将在四种不同的配置下运行,如下所示:
? 第一种配置是不使用调试器,这表示实际的用户环境。我们将这种配置称为常规配置。
? 第二种配置是有一个内核态调试器连接到主机,这通常是用于软件测试阶段。我们将这种配置称为内核态调试器配置或者KD 配置。
? 第三种配置是有一个内核态调试器连接到主机,并且设置了全局标志StopOnException 。我们将这种配置称为带有SOE 的KD 配置。
? 第四种配置是将可执行程序放在用户态调试器下运行,这是开发阶段中的一种常见配置。我们将这种配置称为用户态调试器配置或者UM 配置。
未处理的访问违例异常(STATUS_ACCESS_VIOLATION )
第一个选项将生成人们最熟悉的异常0xC0000005 ,这表示访问违例异常,也称为保护错误(Protection Fault )。清单3-22 的第一个函数必须在前面的每一种配置中调用。函数在各种配置中的行为如下所示:
? 常规配置
在没有调试器的情况下,异常分发代码将查找在前面“异常分发”步骤4 中介绍的异常处理过滤器。如果没有找到任何过滤器,那么异常分发代码将调用kernel32!UnhandleExceptionFilter ,这个函数将使得程序报错并且退出。第13 章将介绍这个过程。
?KD 配置
如果有一个内核态调试器连接到系统,那么系统的行为不会发生变化,程序的退出方式与常规配置中的退出方式一样。
? 带有SOE 的KD 配置
在这种配置中,异常处理代码将把异常转发给内核态调试器并且等待处理。在键入命令g 以及在常规配置中给出的异常处理码之后,系统将继续执行。
?UM 配置
用户态调试器将收到发生异常的通知,这是因为调试器通常被配置为在第一次出现异常时立即停止。在键入了命令g 之后,异常处理代码将为这个异常查找一个处理器。如果没有找到任何处理器,那么异常通知将被再次发送到调试器中,这也就是第二轮异常。调试器对异常进行处理并不会带来任何帮助,因为导致访问违例发生的条件仍然存在,并且导致失败的指令也被再次执行。因此,系统将再次引发异常并将其作为第一轮异常,这个循环将持续执行直到发生错误的条件消失。如果在调试器下启动故障代码,并且让调试器把访问违例异常通知给用户而不是等待用户输入,那么就可以看到这个循环:
c:/>windbg.exe -g -G -xn av C:/AWDBIN/WinXP.x86.chk/02sample.exe
未处理断点的异常(STATUS_BREAKPOINT 异常)
在本章开头已经看到过,STATUS_BREAKPOINT 异常对于调试器来说有着特殊的含义,与访问违例异常相比,系统行为将发生轻微的改变。
? 常规配置
系统将表现出与发生访问违例异常时一样的行为。每个int 3 指令(在DebugBreak() 或者assert() 语句中执行的)都会像其他异常一样被系统和用户看到。与我们在调试器中所看到的不同的是,代码将不会从紧接着int 3 语句之后的位置上继续执行。
?KD 配置
由于这个异常是调试过程所特有的,内核态调试器将停止下来并且处理这个异常。在继续执行时,程序将从int 3 语句之后的指令开始执行。
? 带有SOE 的KD 配置
由于内核态调试器已经处理了STATUS_BREAKPOINT 异常,因此StopOnException 标志不会带来进一步的变化。
?UM 配置
调试器将在断点指令处停止,并且处理异常。在处理完之后,程序将从int 3 语句之后的指令开始执行。
已处理的访问违例异常 在这种情况中使用的代码与用于验证未处理访问违例的代码是类似的,只不过它为异常提供了一个基于帧的异常处理器。
? 常规配置
异常将被如期处理,并且代码将在异常处理器执行完后继续正常执行。
?KD 配置
异常将被如期处理,并且代码将继续正常执行,同时不会发出内核态的通知。
? 带有SOE 的KD 配置
在这种配置中,异常处理机制将把异常转发给内核态调试器,并且等待继续处理。在继续执行时(在键入命令g 之后),异常将在用户态代码中进行处理。
?UM 配置
根据调试器的默认异常处理设置,调试器将在收到第一轮异常通知时停止。在继续执行时,异常处理器将处理异常。
已处理断点异常 当异常特定于调试过程时,例如STATUS_BERAKPOINT 异常或者STATUS_SINGLE_STEP 异常,在处理上会不会存在不同之处?所有的调试器都会尝试解析并处理这种异常。
? 常规配置
异常将被如期处理,并且代码将继续正常执行。
?KD 配置
由于异常是特定于调试的,内核态调试器将停止并且处理这个异常。
? 带有SOE 的KD 配置
在这种配置中,异常处理代码将把这个异常转发给内核态调试器,并且等待对异常的处理。在继续执行时(在键入命令g 之后),程序将从int 3 语句之后的指令开始执行,并且进程将正常结束。
?UM 配置
根据调试器默认的异常处理设置,调试器将在收到第一轮异常通知时停止。在继续执行时,程序将从int 3 语句之后的指令开始执行,并且进程将正常结束。
在使用了不同的异常码来测试所有的配置后,我们可以得出一些有用的结论:
? 在默认情况下,任何未处理的异常都将通过Windows 错误报告(Windows Error Reporting ,WER )机制生成一个崩溃报告,这个报告可用于事后调试。客户可以通过Microsoft 企业错误报告(Corporate Error Reporting )机制或者更新的无代理异常监测(Agentless Exception Monitoring )服务器将这些报告收集起来。客户还可以将这些报告上传到WER 站点,以便于Microsoft 的开发人员或者合作软件商进行分析。第13 章描述了独立软件商如何参与分析WER 报告以及如何为一些常见的问题提供解决方案。
? 虽然在发生未处理异常时,每个用户都会感到不快,但从开发人员的角度来看,这些异常提供了必要的反馈循环来修复应用程序中的缺陷。一种隐藏异常的方法就是对异常进行“虚假处理”,而不考虑异常的类型或者来源。这样,用户将看不到这些异常,但却会给程序的可靠性带来一些长期的问题,这些问题将非常难以诊断并且有时候甚至无法修复,因为它们对用户没有任何“可见的”影响。
? 无论在开发阶段还是在测试阶段,内核态调试器都是一种非常强大的工具,在确保不会与应用程序发生冲突的情况下,我们应该使用内核态调试器来监测一部分系统的行为。
? 在分布式应用程序中,错误将从一个进程传播到另一个进程,这种情况通常是非常难以调试的,因此我们无法知道错误的源头。如果这个错误最初是一个被引发的异常,那么通过带有SOE 的KD 配置以及内核态调试器中的sx 命令,系统将停止在发生异常的位置上。
? 有经验的开发人员通常会使用各种断言技术来了解进程的状态。不幸的是,在产品的发布版本中,也就是测试阶段的目标中,将禁止大多数的断言,这就失去了确保代码正确工作的机会。对于一些重要的断言,可以通过引发断点并且立即进行处理的代码来代替它们。当这些断点被触发时,代码将在调试器中停止,并且对程序的执行性能只会产生很小的影响(由于断言的条件表达式在大多数情况下都为真)。
如果知道了系统在各种配置下的异常处理方式,那么开发人员就能够理解代码停止的原因。开发人员可以利用这种知识来为软件产品定义处理错误的策略,例如通过一个未处理异常过滤器来收集崩溃数据,或者在软件产品中只处理少量的异常并且从进程中收集一些信息。在开发阶段,我们可以对代码和测试环境进行配置和调整,从而为开发过程提供有价值的反馈信息。在理想的情况下,开发人员不需要修改未处理异常过滤器的行为,而是依赖WER 反馈机制。
反调试技术 在一些反调试技术中通过异常机制来判断在当前环境中是否存在调试器,并且防止人们对代码的保护方式进行调试。在数据保护、版权管理或者许可管理等相关的产品中所引发的异常与它们的表现并不一致。
基于帧的异常处理器
我们在本节中已经看到,Windows 异常处理机制是非常灵活的。它通过调用栈中的所有函数来过滤在当前函数中引发的异常。根据异常的类型或者过滤器的参数,这个函数既可以选择处理异常并且在消除造成异常的条件后继续执行,也可以选择忽略异常。这个函数还可以设置一个在当前函数返回时调用的结束处理器(Termination Handler )。本节将介绍在应用程序中支持异常分发的底层机制。在对异常处理代码本身进行调试时,了解这种机制是非常有用的。
虽然本节中描述的机制特定于x86 架构,但通过这里的内容我们能够很好地了解系统如何处理异常以及如何调试这类代码。如果要将函数加入到异常处理机制中,只需满足很小的系统需求。应用程序必须提供的异常处理器,并且将函数注册到进程回退(Process-Unwinding )机制。虽然我们也可以手动编写一个与底层异常处理机制直接交互的异常处理器,但我们通常还是会借助C/C++ 编译器来构建异常帧。
在x86 架构上,异常处理器被组织为一个简单的链表,这个链表属于每个线程的私有信息,可以通过在线程中运行的代码来动态调整。当需要将一个新的异常处理器添加到链表中时,新加入的异常处理器节点将成为链表的头节点,这个头节点被保存在线程环境块(TEB )中。链表的每个节点都存储了相应函数的异常处理器,以及指向下一个节点的指针。图3-4 说明了这个链表的组织结构。
由于在每个函数中至多只能提供一个异常处理器,因此这个链表的长度不能超过调用栈的长度。由于大多数函数并不需要参与到异常分发的逻辑流程中,因此就不会提供一个处理器加入到链表中。清单2-23 使用了在图3-4 中所描述的信息:找到异常处理器链表的头节点,然后通过扩展命令!slist 输出整个链表。Windows 调试器团队认为这个过程比较麻烦,因此提供了一个扩展命令!exchain 来完成输出链表的工作,这个命令还会在可能的情况下对函数处理器进行解析。清单3-23 使用了这些命令来分析当调试器停止时的异常处理器链表,我们通过在示例02sample.exe 中选择选项‘d ’来引发调试器的停止。
清单3-23 分析x86 异常处理器链表
0:000> !teb
TEB at 7ffdf000
ExceptionList: 0006ff28
0:000> * 获得异常处理器链表的类型信息
0:000> dt nt!_NT_TIB ExceptionList
+0x000 ExceptionList : Ptr32 _EXCEPTION_REGISTRATION_RECORD
0:000> !slist $teb _EXCEPTION_REGISTRATION_RECORD 0
SLIST HEADER:
+0x000 Alignment : 700000006ff28
+0x000 Next : 6ff28
+0x004 Depth : 0
+0x006 Sequence : 7
SLIST CONTENTS:
0006ff28
+0x000 Next : 0x0006ff90 _EXCEPTION_REGISTRATION_RECORD
+0x004 Handler : 0x010020d2 _EXCEPTION_DISPOSITION
02sample!_except_handler4+0
0006ff90
+0x000 Next : 0x0006ffdc _EXCEPTION_REGISTRATION_RECORD
+0x004 Handler : 0x010020d2 _EXCEPTION_DISPOSITION
02sample!_except_handler4+0
0006ffdc
+0x000 Next : 0xffffffff _EXCEPTION_REGISTRATION_RECORD
+0x004 Handler : 0x77b88bf2 _EXCEPTION_DISPOSITION
ntdll!_except_handler4+0
Ffffffff
+0x000 Next : ????
+0x004 Handler : ????
0:000> !exchain /f
0006ff28: 02sample!_except_handler4+0 (010020d2)
0006ff90: 02sample!_except_handler4+0 (010020d2)
0006ffdc: ntdll!_except_handler4+0 (77b88bf2)
...
在这种情况中,每个函数都使用了相同的异常处理器,并且扩展命令!exchain 不能解析异常帧或者给出关于异常的一些额外信息。在这些情况下,我们不得不手动对异常帧进行解析。在大多数情况下,异常处理器都是由编译器工具生成的,因此在接下来的一节中将以Microsoft C/C++ 编译器为模型来详细讨论异常处理器的生成细节。编译器通过__try/__except 以及__try/__ finally 等非标准的扩展结构来提供这种支持。
生成基于帧的异常处理器
我们首先从一个简单的函数开始,在这个函数中包含了一个异常处理器和一个异常处理过滤器,通常也称为EXCEPTION_EXECUTE_HANDLER 。异常处理器保护的代码将访问一个无效内存位置,因此将产生一个访问违例异常。清单3-24 给出了这个函数的源代码。
清单3-24 在函数中使用了__try/__except 结构
void try_except()
{
__try
{
*((int *) 0) = 0;
}
__except(ex_filter())
{
global = 1;
}
}
在启动02sample.exe 后,可以在调试器中查看编译器为这个函数生成的汇编代码。清单3-26 给出了这个函数的汇编代码。
清单3-25 函数的汇编代码
0:000> uf 02sample!try_except
02sample!try_except:
...
01001d75 6afe push 0FFFFFFFEh ; 设置块计数器
01001d77 68d02a0001 push offset 02sample!_CT??_R0H+0x60 ( 01002ad0 )
01001d7c 68d2200001 push offset 02sample!_except_handler4 (010020d2)
01001d81 64a100000000 mov eax,dword ptr fs:[00000000h] ; 获得异常处理器链表的头节点
01001d87 50 push eax ; 保存原来的头节点
...
01001d99 8d45f0 lea eax,[ebp-10h]
01001d9c 64a300000000 mov dword ptr fs:[00000000h],eax ; 保存新的头节点
01001da2 8965e8 mov dword ptr [ebp-18h],esp
01001da5 c745fc00000000 mov dword ptr [ebp-4],0 ; 修改块
01001dac c7050000000000000000 mov dword ptr ds:[0],0
01001db6 c745fcfeffffff mov dword ptr [ebp-4],0FFFFFFFEh
01001dbd eb1a jmp 02sample!try_except+0x69 (01001dd9)
02sample!try_except+0x69:
01001dd9 8b4df0 mov ecx,dword ptr [ebp-10h] ; 获得原来的头节点
01001ddc 64890d00000000 mov dword ptr fs:[0],ecx ; 恢复原来的头节点
...
01001dea c3 ret
0:000> dc 01002ad0 l8
01002ad0 fffffffe 00000000 ffffffd8 00000000 ................
01002ae0 fffffffe 01001dbf 01001dc5 00000000 ................
编译器将这个函数分为多个区域,每个区域都有着不同的功能,此外它还生成了一个聚合结构,这个结构为每个区域包含了一个过滤器和一个处理器。为了将这个信息与标准的回退机制关联起来,编译器在函数调用开始时注册一个异常处理器,并且在函数调用结束时取消注册。在模块中的所有函数都将使用这个处理器来处理异常,并且调用用户代码来处理与当前执行的代码块相匹配的异常。这个处理器是在编译器的运行库中实现的,也称为CRT 。
异常处理器如何知道当前正在执行哪个块?x86 上的Microsoft C/C++ 编译器使用了一个本地的计数器来表示当前正在执行的区域。当程序执行跨过区域的边界时,编译器生成的代码将修改这个本地计数器。
我们很难通过汇编代码来理解异常处理代码以及在编译过程中发生的转换操作。为了减少在C/C++ 源代码与汇编代码之间的差距,编译器可以生成一个叫作程序集列表(Assembly Listing )的中间文件。在程序集列表的汇编代码中,除了包含地址之外,还包含了原始源代码注解(Annotation )以及一些有提示性的标记。这对于理解原始C/C++ 源代码中某个指令的作用是非常有帮助的。清单3-26 包含了与函数try_except 相对应的程序集列表。
清单3-27 给出了注解代码,我们可以看到在异常信息块(由标号$__sehtable$ ?try_ except@@YGXXZ 指定)中包含了指向异常过滤器$LN5@try_except 的指针和指向异常处理器$LN6@try_except 的指针。异常处理器__except_handler4 是从MSVCRT 库中导入的,它被保存在紧接着地址0000c 上异常信息块之后的栈上。在执行00035 偏移处的__try 代码块后,区域索引__$SEHRec$[ebp+20] 将从-2 变为0 ,其中-2 这表示这个函数是处于所有异常区域之外,并且在发生异常时不需要执行任何动作。当被保护的区域执行完成时,这个索引将重新被修改为-2 ,这表示代码执行是在所有被保护的区域之外。这个异常处理器链表是由fs:0 表示的。
清单3-26 与清单3-24 中函数相对应的程序集列表
PUBLIC ?try_except@@YGXXZ ;
xdata$x SEGMENT
__sehtable$?try_except@@YGXXZ DD 0fffffffeH
DD 00H
DD 0ffffffd8H
DD 00H
DD 0fffffffeH
DD FLAT:$LN5@try_except
DD FLAT:$LN6@try_except
xdata$x ENDS
_TEXT SEGMENT
?try_except@@YGXXZ PROC ; try_except, COMDAT
...
00005 6a fe push -2 ; fffffffeH
00007 68 00 00 00 00 push OFFSET __sehtable$?try_except@@YGXXZ
0000c 68 00 00 00 00 push OFFSET __except_handler4
00011 64 a1 00 00 00 00 mov eax, DWORD PTR fs:0
...
00029 8d 45 f0 lea eax, DWORD PTR __$SEHRec$[ebp+8 ]
0002c 64 a3 00 00 00 00 mov DWORD PTR fs:0 , eax
00032 89 65 e8 mov DWORD PTR __$SEHRec$[ebp], esp
; 29 : __try
00035 c7 45 fc 00 00 00 00 mov DWORD PTR __$SEHRec$[ebp+20 ],0
; 30 : {
; 31 : *((int *) 0) = 0;
0003c c7 05 00 00 00 00 00 00 00 00 mov DWORD PTR ds:0, 0
; 32 : }
00046 c7 45 fc fe ff ff ff mov DWORD PTR __$SEHRec$[ebp+20 ],-2 ; fffffffeH
0004d eb 1a jmp SHORT $LN4@try_except
$LN5@try_except:
$LN10@try_except:
; 33 : __except(ex_filter())
0004f e8 00 00 00 00 call ?ex_filter@@YGKXZ ; ex_filter
$LN7@try_except:
$LN9@try_except:
00054 c3 ret 0
$LN6@try_except:
00055 8b 65 e8 mov esp, DWORD PTR __$SEHRec$[ebp]
; 34 : {
; 35 : global = 1;
00058 c7 05 00 00 00 00 01 00 00 00 mov DWORD PTR ?global@@3HA, 1 ; global
; 36 : }
00062 c7 45 fc fe ff ff ff mov DWORD PTR __$SEHRec$[ebp+20 ],-2 ; fffffffeH
$LN4@try_except:
; 37 : }
00069 8b 4d f0 mov ecx, DWORD PTR __$SEHRec$[ebp+8]
0006c 64 89 0d 00 00 00 00 mov DWORD PTR fs:0 , ecx
...
0007a c3 ret 0
?try_except@@YGXXZ ENDP ; try_except
_TEXT ENDS
我们如何生成这些代码?这要取决于构建应用程序的开发环境。在WDK 的构建环境中,生成注解代码的过程是非常简单的,注解代码只是编译过程中的另一个目标,这个目标的扩展名为.cod 。例如,文件FuncAV.cpp (这个文件包含了本节使用的源代码)可以通过nmake 命令被编译为注解文件FuncAV. cod ,如清单3-27 所示。
清单3-27 从源代码文件中生成注解文件
C:/AWD/CHAPTER2> nmake FuncAV.cod
Microsoft (R) Program Maintenance Utility Version 7.00.8882
Copyright (C) Microsoft Corp 1988-2000. All rights reserved.
cl -nologo @objfre_wxp_x86/i386/clcod.rsp /Fc /FC ./FuncAV.cpp
FuncAV.cpp
fs:0 表示异常处理器链表的头节点(其值为fs:[0] ),它是TEB 中的第一个指针。对所有的线程来说,选择符fs 都有相同的值,因此你可能会问在多线程环境中将会出现什么情况:当所有异常处理器链表的头节点都保存在同一个地址中时,如何确保异常链表不会被破坏?
操作系统在对特定于线程的信息进行寻址时只会使用fs 选择符,这就为通过相同的“句柄”来访问不同的地址提供了所需的间接功能。虽然这个选择符对进程中所有线程来说有着相同的值,但操作系统能够将各个线程区分开来,具体的做法是:每当将一个新的线程调度到处理器上执行时,都会修改由选择符fs 所指向的段描述符。清单3-28 给出了与选择符fs 相对应的段描述符,其中fs 的值为0x3b 。“Base ”列包含的是TEB 的起始虚拟地址。
清单3-28 在同一个进程的两个不同线程上的线程环境块
0:000> dg @fs
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
-- ---- ---- ----- - - - - - ----
003B 7ffdf000 00000fff Data RW Ac 3 Bg By P Nl 000004f3
0:001> dg @fs
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
-- ---- ---- ----- - - - - - ----
003B 7ffdd000 00000fff Data RW Ac 3 Bg By P Nl 000004f3
在了解了整个异常处理机制后,你应该能够知道当函数中发生异常时将会执行哪些代码,并且你应该可以在必要时在异常过滤器或者异常处理器中设置断点。有时候,你可能会遇到这种情况:源代码能够正确地处理异常,但可执行代码却不能,并且你可能发现这个异常处理器是在编译完可执行程序之后才添加的。
通过对保存在TEB 中的异常处理器链表的头节点进行分析,我们可以找出在当前的栈中有哪些函数正在使用异常处理器。当栈被破坏或者不可用时,这个信息是非常宝贵的,因为在某些内核调试情况中,栈并没有驻留在内存中。
3.1.4 内核态调试器中的调试事件处理
在调试目标与调试器客户端之间通过调试事件来进行通信,这个概念可以很容易地从用户态调试器扩展到内核态调试器,二者之间的主要区别在于调试器与调试目标的通信机制。虽然通信协议并不是公开的,但一些好奇的人们可以通过在调试器控制台中按下组合键CTRL+D 来观察整个协议的详细输出,并找出在内核态调试器与调试目标之间的通信。
我们在前面已经讨论过的,用户态开发人员很少会从内核调试事件上获得好处,因为这些事件对他们来说没有太大的作用。显然,最重要的事件之一就是异常事件EXCEPTION_BREAKPOINT ,当代码在用户态中执行一个由DebugBreak() 或者各种断言函数生成的int 3 语句时,将会引发这个异常。其次重要的事件就是当用户态异常通过StopOnException 标志转发到内核态调试器时生成的异常事件。
最后,当用户模块被映射到内存时,Windows 内核将发送一些通知。这个功能可以通过在全局变量nt!NTGlobalFlag 中设置KernelSymbolLoad(kls) 标志来启用,这个标志可以通过gflags.exe 或者扩展命令!gflag 来修改。
在设置了这个标志后,我们可以通过在内核态调试器中键入命令sxe ld: <module> 来激活通知。当module 被映射到内存时,调试器将收到通知,这是在内核态中对加载module 的进程进行调试的好机会。清单3-9 通过kls 标志来检测notepad.exe 的第一个进程实例。
当我们对Windows 早期启动阶段中加载的一些模块进行调试时,或者当我们难以判断哪个进程将会加载所要调试的模块时,这个功能是非常有用的。然而,当模块已经加载到系统中时,这个通知将不会发送。
清单3-29 通过kls 标志来检测用户态模块的映射过程
kd> !gflag +kls
New NtGlobalFlag contents: 0x00040000
ksl - Enable loading of kernel debugger symbols
kd> sxe ld notepad
kd> g
nt!DebugService2+0x10:
8050b897 cc int 3
kd> k
ChildEBP RetAddr
f3b7da24 8050b8d9 nt!DebugService2+0x10
f3b7da48 805d536c nt!DbgLoadImageSymbols+0x42
f3b7da98 805d5212 nt!MiLoadUserSymbols+0x169
f3b7dadc 8057bc22 nt!MiMapViewOfImageSection+0x4b6
f3b7db38 80503a0b nt!MmMapViewOfSection+0x13c
f3b7db94 80588c21 nt!MmInitializeProcessAddressSpace+0x337
f3b7dce4 80588635 nt!PspCreateProcess+0x333
f3b7dd38 804df06b nt!NtCreateProcessEx+0x7e
f3b7dd38 7c90eb94 nt!KiFastCallEntry+0xf8
WARNING: Frame IP not in any known module. Following frames may be wrong.
0013fa88 00000000 0x7c90eb94
kd> !process -1 0
PROCESS 82f5a020 SessionId: 0 Cid: 0000 Peb: 00000000 ParentCid: 0544
DirBase: 0de15000 ObjectTable: e1b12638 HandleCount: 1.
Image: notepad.exe
3.2 控制调试目标
在了解操作系统在调试运行中目标进程时采用的机制后,还有一个步骤就是了解调试器如何完成这些功能。本节将介绍调试器在控制调试目标时使用的一些方法,以及每种方法将对调试目标产生怎样的影响。
3.2.1 断点的工作原理
本书多处使用了STATUS_BREAKPOINT 异常,尤其是在本章中,但没有很明确地解释这个异常的引发方式。现在,我们就来解释如何在进程中产生这个异常。
x86 指令集包含了一个特殊的指令int 3 ,这个指令将在处理器上产生硬件中断STATUS_BREAKPOINT 以用于调试。为了响应异常STATUS_BREAKPOINT ,处理器将执行位于中断矢量3 中的中断处理器。中断处理器将把这个硬件异常转换为在这条指令地址上引发的一个软件异常。这条指令在指令流中被表示为一个字节0xCC ,也称为操作码(Operation Code )或者opcode 。在没有调试器的情况下,软件异常将被视作为一个普通的异常;否则,Windows 操作系统将告诉调试器在这条指令的地址上发生了中断。
调试器将通过0xCC 来设置断点。在设置断点时,调试器将首先修改断点地址所在内存块的保护模式,这是为了接下来在这个地址上写入一个int 3 指令。这个地址上原来的值和关于断点编号的信息都将保存在调试器的内存中。
断点地址必须是指令流中的一个有效指令的地址,这个地址通常是一条机器指令的第一个字节。如果在机器指令的其他地址上设置的断点,那么将改变指令的含义,从而导致这条指令不会触发硬件异常STATUS_BREAKPOINT 。显然,运行一个包含错误机器指令的程序是非常危险的,并且将产生不可预测的行为。
这种修改内存的操作对于用户来说应该是不可见的,因为这些修改将影响对代码进行反汇编的结果。因此,当调试器停止时,会把所设置的断点又替换为原来的指令,当调试目标再次运行时,将再次把int 3 的操作码插入到目标映像中。
为了说明这种机制,我们在调试器中启动调试目标notepad.exe 。在触发初始断点时,我们在任意地址上设置一个断点,在本示例中是notepad!WinMain 的起始地址,并且将另一个调试器以非侵入的方式附加到同一进程并查看在这个地址上的指令。这将为我们揭示调试目标的真实内存内容。
当用户态调试器等待用户输入命令时,内存将包含最初的指令流。当执行调试目标时,我们将在用户态命令窗口中键入命令g 来改变内存,如清单3-30 中第二部分所示。
清单3-30 在一个非侵入式的调试器中查看进程的内存
在设置断点之前
0:000> u 010028e4
010028e4 85c0 test eax,eax
010028e6 7594 jnz 0100287c
010028e8 e8c3efffff call 010018b0
在设置断点之后
0:000> u 010028e4
010028e4 cc int 3
010028e5 c07594e8 shl byte ptr [ebp-0x6c],0xe8
010028e9 c3 ret
010028ea ef out dx,eax
在设置断点时,内核态调试器将遵循相同的模型,只不过操作系统的内存管理机制将带来一些差异。在Windows 操作系统中,大多数包含可执行代码的内存页在多个进程之间是共享的,正是由于这个功能才使得同一个DLL 可以被加载到不同的进程中。当用户态调试器激活一个新的断点时,它会把内存页的保护状态从“只读”变为“读写”。通过“写时复制(Copy On Write ,COW )”技术生成的新内存页将作为被调试进程的私有页,并且在对其进行修改时不会影响共享这个页的其他进程。由于内核态调试器无法通过COW 技术来生成一个私有页,它将直接在共享页中设置断点。
内核态断点将会影响共享这个内存页的所有进程。而且,根据系统中可用的内存量,在被调试进程执行完之后,内核态断点仍有可能驻留在系统内存中。在实际的调试情况中,这种情况带来的后果是很难预测的,这就像内存负载和整体系统的行为将对Windows 内存管理产生极大的影响。然而,我们可以得出一些关于内核态断点的结论:
? 如果在多个进程共享的内存页中设置断点,那么在所有这些进程中都会产生中断。由于内核态调试器在处理断点时相对较慢,尤其是当通过串行电缆来调试时,因此我们绝不应该在一些被频繁调用的函数中设置断点,例如ntdll!RtlAllocateHeap 。我们可以通过EPROCESS 地址或者KTHREAD 地址来缩小断点的范围,从而减少调试器的停止次数。不幸的是,调试器在每次遇到断点时仍然会收到通知,只不过对于所有不匹配的进程,调试器将自动处理断点。
? 当内核态调试器中的被调试进程结束后,所有的用户态断点都必须删除以避免与其他运行中的进程发生冲突(共享页将仍然在内存中停留一段时间,其中保留了之前设置的所有断点,即使进程被重新启动也是如此)。
? 当用户态调试器与内核态调试器一起使用时,通常必须从内核态调试器中设置这些断点。否则,断点异常将被分发给用户态调试器。由于无法知道int 3 其实是一个断点而并非真实的int 3 指令,因此执行流将被破坏。显然,在键入命令g 后执行的指令流将是完全错误的,最终将在某个调试器中出现大量的访问违例异常或者单步异常。
3.2.2 内存访问断点的工作原理
除了标准的断点指令外,在Windows 操作系统支持的所有处理器中,都能在读取、写入或者执行某个地址时产生异常。命令ba 就是通过处理器的这个功能来实现内存访问断点。处理器的这个功能是由8 个寄存器来控制的(再次强调,我们在这里只讨论x86 架构),名字分别为DR0~DR7 。处理器厂商的帮助文档很详细地说明了这些寄存器的用法。简单来说,在前4 个寄存器DR0~DR3 中包含的是处理器所要监测的虚拟地址,因此它们也称为地址断点寄存器,在DR7 中则包含了关于每个地址的控制信息(例如地址块的长度、需要监视的访问类型以及是否启用等状态),因此DR7 也称为调试控制寄存器。清单3-31 给出了在内核态调试器中触发断点之前的调试寄存器以及在触发断点之后的调试寄存器。
清单3-31 在普通处理器上的调试寄存器
在设置内存访问断点之前
kd> rM 20
dr0=00000000 dr1=00000000 dr2=00000000
dr3=00000000 dr6=ffff0ff0 dr7=00000400 cr4=00000699
ntdll!RtlAllocateHeap+0x5:
001b: 77f57bb3 68781cf577 push 0x77f51c78
在设置内存访问断点(执行访问)之后
kd> ba e1 77f57bae
kd> g
Breakpoint 0 hit
ntdll!RtlAllocateHeap:
001b:77f57bae 6808020000 push 0x208
kd> rM 20
dr0=77f57bae dr1=77f57bae dr2=00000000
dr3=00000000 dr6=ffff0ff1 dr7=00000501 cr4=00000699
ntdll!RtlAllocateHeap:
001b:77f57bae 6808020000 push 0x208
kd> .formats @dr7
Evaluate expression:
Hex: 00000501
Decimal: 1281
Octal: 00000002401
Binary: 00000000 00000000 00000101 00000001
Chars: ....
Time: Wed Dec 31 16:21:21 1969
Float: low 1.79506e-042 high 0
Double: 6.32898e-321
在上述清单中,调试控制寄存器只设置了两位(第0 位和第8 位)这意味着断点0 将被启用。根据Intel 处理器的规范,如果没有额外的信息,例如需要被监测的长度或者需要被监测的访问模式等,那么这个断点将被认为是一个执行访问断点。
与普通断点一样,内核态调试器中的内存访问断点将会影响在系统中运行的所有进程,并且它们将会与同一个系统中运行的用户态调试器相互干扰。如果某个用户态调试器遇到了这个断点而又不知道中断的原因,那么调试器将引发一个STATUS_SINGLE_STEP 异常。
3.2.3 处理器跟踪
调试器的另一个最常使用的功能就是在汇编级别上进行跟踪,这是通过通过底层的处理器跟踪(Processor Tracing )机制来实现的。在x86 处理器上是通过trap 标志来启用跟踪,在调试器控制台中则对应为tf 标志。当设置了这个标志时,处理器将只会执行当前的语句,然后引发一个STATUS_SINGLE_STEP 异常。例如,当我们在调试器控制台中键入命令t 时,调试器将在线程上下文中设置trap 标志,并且继续线程的执行。当加载了一个新的线程上下文,并且处理器引发了STATUS_SINGLE_STEP 异常时,调试器将识别出这个异常,取消trace 标志,并且在上一条指令处停止。这种行为可以很容易重现,通过设置trap 标志并且启动调试目标,如清单3-32 所示。在这个情况中,调试器并不清楚对执行单步操作的“请求”,它只是在控制台上给出异常。
清单3-32 模拟在调试器附加到运行中调试目标后的代码跟踪
0:001> r tf=1
0:001> g
(608.6bc): Single step exception -code 80000004 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=7ffdf000 ebx=00000001 ecx=00000002 edx=00000003 esi=00000004 edi=00000005
eip=77f5f31f esp=0084ffd0 ebp=0084fff4 iopl=0 nv up ei pl zr na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=0038 gs=0000 efl=00000246
ntdll!DbgUiRemoteBreakin+0x2d:
77f5f31f eb07 jmp ntdll!DbgUiRemoteBreakin+0x36 (77f5f328)
除了单步跟踪之外,在一些更新的处理器中还通过实现一些额外的跟踪功能来提高调试器的能力,例如对下一个分支的跟踪。
3.2.4 实时调试中的线程状态管理
对于单线程的进程来说,跟踪是一种简单易用的机制,但对于多线程的进程来说却增加了一种不可预测性。当涉及多个线程时,调试器将使其他的线程自由运行,而当前的线程则执行调试器预定的指令。如果线程的上下文发生了切换并且用户在调试器中键入t ,那么它将触发调试器中另一个已经设置的断点,而不是在下一个命令处停止。代码的执行将不再遵循单一的路径,这就使得我们很难跟踪单个线程执行某个特定任务的过程。我们确实希望在进程中看到单一的线程,这样我们就可以通过一些熟悉的命令来控制它,而不是通过一些局限在单个线程中的断点来控制它。
为了降低多个线程执行相同代码段的可能性,我们可以临时挂起一些不相干的线程,而在进程中只保留一个线程。这是如何做到的?
每当一个新的调试事件需要发送到用户态调试器时,进程中所有运行中的线程都将自动被Windows 内核挂起,直到事件处理完毕。当调试器在处理完这个事件后决定继续执行时,内核将恢复进程中所有线程的执行。在清单3-33 给出的线程中,每个线程都包含了一个挂起计数(Suspend Count ),以及一个冻结/ 解冻(Frozen/Unfrozen )的状态。
清单3-33 转储线程的状态
0:001> ~
0 Id: 1370.fc0 Suspend: 1 Teb: 7ffdf000 Unfrozen
. 1 Id: 1370.101c Suspend: 1 Teb: 7ffde000 Unfrozen
线程的挂起计数由Windows 内核使用的值,可以通过SuspendThread 和ResumeThread 这两个系统函数来控制的。我们还可以通过~n 或者~m 这两个命令在调试器中控制挂起计数。假定线程的标识为<tid> ,那么这个线程可以通过以下命令来挂起:
~<tid>n
并且通过以下命令来恢复执行:
~<tid>m
在使用这些命令时,如清单3-34 所示,我们要确保在调试器脱离进程之前,挂起的次数与恢复执行命令的数量是相等的。一个被挂起的线程将永远保持挂起状态。我们还要理解挂起一个线程对整个进程将产生怎样的影响。例如,在大多数图形用户界面程序中都是通过单线程来接收和分发窗口消息。如果挂起了这个线程,那么整个应用程序也将被冻结。如果在挂起的线程中拥有某个资源,那么在这个资源上等待的所有其他线程将被阻塞,直到这个线程恢复执行。和前面一样,这种无约束的等待被认为是程序挂起。
清单3-34 如何挂起和恢复线程
0:001> * 挂起线程0 的执行
0:001> ~0n
0:001> ~
0 Id: 1370.fc0 Suspend: 2 Teb: 7ffdf000 Unfrozen
. 1 Id: 1370.101c Suspend: 1 Teb: 7ffde000 Unfrozen
0:001> * 恢复线程0 的执行
0:001> ~0m
0:001> ~
0 Id: 1370.fc0 Suspend: 1 Teb: 7ffdf000 Unfrozen
. 1 Id: 1370.101c Suspend: 1 Teb: 7ffde000 Unfrozen
在前面讨论的冻结/ 解冻等状态与之前介绍的挂起状态是不同的。冻结状态是调试器中的概念,在Windows 操作系统中并不支持这个概念。对于每个被冻结的线程,调试器将记住这个状态,并且在调试事件被处理之前增加线程的挂起计数。当调试事件被处理完毕时,挂起计数将被递减,因此挂起计数看上去似乎没有变化。
标识为<tid> 的线程可以通过以下命令来冻结:
~<tid> f
以及通过以下命令来解冻:
~<tid> u
清单3-35 给出了每个命令的示例用法。由于一个被冻结的线程将影响正常的进程执行,调试器将在每处理一个新事件时将告诉用户当前被冻结线程的数量。冻结命令的数量必须与解冻命令的数量相等,这与对挂起- 恢复等命令的要求是一样的。有趣的是,当进程中的最后一个活跃线程被冻结时,调试器将结束调试目标进程,因为在这个进程中已经不会再有任何行为发生。
清单3-35 如何冻结和解冻线程
0:001> * 冻结第一个线程
0:001> ~1f
0:001> * 转储线程的状态
0:001> ~
0 Id: 1098.1418 Suspend: 1 Teb: 7ffdf000 Unfrozen
. 1 Id: 1098.143c Suspend: 1 Teb: 7ffde000 Frozen
0:001> * 让调试目标运行
0:001> g
System 0: 1 of 2 threads are frozen
System 0: 1 of 3 threads were frozen
System 0: 1 of 3 threads are frozen
System 0: 1 of 3 threads were frozen
(1098.15fc): Break instruction exception - code 80000003 (first chance)
eax=7ffd9000 ebx=00000001 ecx=00000002
edx=00000003 esi=00000004 edi=00000005
eip=7c901230 esp=0092ffcc ebp=0092fff4
iopl=0 nv up ei pl zr na po nc
cs=001b ss=0023 ds=0023 es=0023
fs=0038 gs=0000 efl=00000246
ntdll!DbgBreakPoint:
7c901230 cc int 3
0:001> * 解冻第一个线程
0:002> ~1u
0:001> * 转储线程的状态
0:002> ~
0 Id: 1098.1418 Suspend: 1 Teb: 7ffdf000 Unfrozen
1 Id: 1098.143c Suspend: 1 Teb: 7ffde000 Unfrozen
. 2 Id: 1098.15fc Suspend: 1 Teb: 7ffdd000 Unfrozen
最后,调试器还能够将当前正在执行的线程替换为进程中的其他线程。这种改变是临时性的,它将一直持续到新的线程使用完执行时间片,例如被其他线程抢先,或者自动释放剩余的执行时间片,或者进入等待状态。在清单2-36 中可以看到,在当前线程的标识前面有一个句点(. )。如果当前线程不同于活跃线程(也就是产生当前事件的线程),那么在活跃线程标识的前面将出现一个“#”符号。标识为<tid> 的线程可以通过以下命令被置为活跃线程:
~<tid> s
清单3-36 改变当前的线程
0:001> ~
0 Id: 3edc.1970 Suspend: 1 Teb: 7ffdf000 Unfrozen
. 1 Id: 3edc.44e8 Suspend: 1 Teb: 7ffde000 Unfrozen
0:001> ~0s
eax=0043de20 ebx=008f0507 ecx=00420000 edx=a4011de2 esi=0007fefc edi=77d491c6
eip=7c90eb94 esp=0007febc ebp=0007fed8 iopl=0 nv up ei pl zr na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000246
ntdll!KiFastSystemCallRet:
7c90eb94 c3 ret
0:000> ~
. 0 Id: 3edc.1970 Suspend: 1 Teb: 7ffdf000 Unfrozen
# 1 Id: 3edc.44e8 Suspend: 1 Teb: 7ffde000 Unfrozen
改变当前的线程将影响所有依赖当前线程的命令,这对于一些复杂的命令来说是非常有用的,例如命令kb 或者扩展命令!teb 。
3.2.5 通过用户态调试器来挂起线程
当前,内核态调试器并没有提供某种方法来改变执行模式,例如挂起一个线程,恢复一个线程,或者调度当前线程之外的其他线程来执行。之所以没有提供这些功能,主要有几方面的原因,包括提供这种功能的复杂性以及安全性等。而更为重要的是,这种功能在内核空间中的作用是有限的,因为内核空间中的线程数量通常是非常庞大的。
不过,在满足一些条件的情况下,我们可以通过内核态调试器中一些已有的功能来模拟这个功能。本节的剩余内容将给出一些需要使用这种功能的情况。
假设在执行了DebugBreak() 语句之后,某个进程将停止在内核态调试器中。在发生了中断后,进程将不能继续执行,如果继续执行断点之后的代码,那么将导致进程结束。这个中断通常是因为破坏了进程中的某种不变性,例如堆的完整性或者是某个全局变量的值超出了预定范围。包含进程中断相关信息的虚拟地址没有加载到内存中,而是处于页面文件中。.pagein 命令可以用来将页面文件加载到内存中。调试目标必须执行一个线程来完成实际的页入(Page In )操作。由于页入过程中的不确定性,之前导致中断的线程可能会执行并且结束进程。
避免这种情况的解决方案之一就是将这个故障线程置入等待状态,从而不让它执行结束代码。当线程处于等待状态时,可以多次调用.pagein 而无需担心丢失当前的调试会话。通过改变线程的当前指令指针并且强行让线程执行kernel32!Sleep ,线程可以很容易被置入等待状态。这个系统函数只有一个简单的参数,即线程休眠的时间,单位为毫秒。
我们必须对当前的线程栈进行修改,以模拟调用这个API 之前的状态。同时还必须修改线程的上下文以匹配更新之后的栈指针,并且指令指针也必须更新为被调用API 的起始地址。当这个线程继续执行时,它将进入休眠状态,并且根据栈中指定的值休眠相应的时间,如清单3-37 所示。
清单3-37 模拟kernel32!Sleep 调用
kd> r
eax=0040136f ebx=7ffdf000 ecx=004011d0 edx=00262649 esi=00000002 edi=00000000
eip=77f75a58 esp=0006fee8 ebp=0006fef0 iopl=0
nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=0038 gs=0000 efl=00000206
ntdll!DbgBreakPoint:
001b:77f75a58 cc int 3
kd> ed esp-4 <time>
kd> ed esp-8 .
kd> resp=@esp-8
kd> reip=kernel32!Sleep
kd> .pagein <address>;g
...
在整个休眠期间,调试器可以将多个页面加载到内存中,而无需担心丢失进程或者状态会发生不可预期的变化。如果必要的话,甚至还可以启动一个用户态调试器在目标系统中调试故障进程。
无论采用何种方法来进行分析,当休眠时间到了之后,线程将返回它的初始位置。即使在寄存器的值得到了妥善的保存,但如果进程执行超过了这个点,那么仍将是一种危险的行为。
3.3 小结
在本章中,你了解了调试器在调试进程时如何与操作系统交互,以及如何有效地控制调试事件和异常。然后,你还了解了当发生各种异常时系统将如何应对,以及如何在日常的调试工作中使用这些信息。最后,我们分析了控制线程状态的机制,包括调试器的内部支持以及通过手动的方式来修改进程状态。
在了解了这些信息之后,就可以为各种调试情况定义清晰的调试策略,并且有效地使用各项调试功能。