来自《软件技术加密内幕》和chm版本不太一样
在Win32中自带了一些API函数,它们提供了相当于一般调试器的大多数功能,这些函数统称为Win32调试API(Win32 Debug API)。利用这些API可以做到加载一个程序或捆绑到一个正在运行的程序上以供调试;可以获得被调试的程序的底层信息,例如进程ID、进入地址、映像基址等;甚至可以对被调试的程序进行任意的修改,包括进程的内存、线程的运行环境等。
简而言之,读者可以用这些API写一个进程调试器。就像现在流行的调试器Visual C++调试器、WinDBG、OllyDbg等一样。当然除了能写调试器外,利用调试API还能做很多不同寻常的工作。
3.1 Win32调试API原理
3.1.1 调试相关函数简要说明
Windows提供了一组Win32 Debug API,其具体定义如下。
(1)ContinueDebugEvent函数
说明:此函数允许调试器恢复先前由于调试事件而挂起的线程。
语法: BOOL ContinueDebugEvent( DWORD dwProcessId, DWORD dwThreadId, DWORD dwContinueStatus )
参数:
dwProcessId DWORD 被调试进程的进程标识符
dwThreadId DWORD 欲恢复线程的线程标识符
dwContinueStatus DWORD 此值指定了该线程将以何种方式继续,包含两个定义值 DBG_CONTINUE 和 DBG_EXCEPTION_NOT_HANDLED
返回值 BOOL 如果函数成功,则返回非零值;如果失败,则返回零
(2)DebugActiveProcess
说明:此函数允许将调试器捆绑到一个正在运行的进程上。
语法: BOOL DebugActiveProcess( DWORD dwProcessId )
参数:
dwProcessId DWORD 欲捆绑进程的进程标识符
返回值 BOOL 如果函数成功,则返回非零值;如果失败,则返回零
(3)DebugActiveProcessStop
说明:此函数允许将调试器从一个正在运行的进程上卸载。
语法: BOOL DebugActiveProcessStop( DWORD dwProcessId )
参数:
dwProcessId DWORD 欲卸载的进程的进程标识符
返回值 BOOL 如果函数成功,则返回非零值;如果失败,则返回零
注意:Windows 9x内核不支持此函数。
(4)DebugBreak
说明:在当前进程中产生一个断点异常,如果当前进程不是处在被调试状态,那么这个异常将被系统例程接管,多数情况下会导致当前进程被终止。
语法: VOID DebugBreak( VOID)
参数:无
其他:其实这个函数的用处与在程序中直接插入INT 3的效果是一样的,如果反编译Windows 98的KERNEL32.dll,读者可以发现这个函数只包含两句,一句是INT 3,一句是RET。
(5)DebugBreakProcess
说明:在指定进程中产生一个断点异常。
语法: VOID DebugBreakProcess ( HANDLE hProcess)
参数:
hProcess HANDLE 进程的句柄
返回值 无
(6)FatalExit
说明:此函数将使调用进程强制退出,将控制权转移至调试器。与ExitProcess不同的是,在退出前会先调用一个INT 3断点。
语法: VOID FatalExit( int ExitCode)
参数:
ExitCode int 退出码
返回值 无
(7)FlushInstructionCache
说明:刷新指令高速缓存。
语法: BOOL FlushInstructionCache( HANDLE hProcess, LPCVOID lpBassAddress, SIZE_T dwSize)
参数:
hProcess HANDLE 进程的句柄
lpBassAddress LPCVOID 欲刷新区域的基地址
dwSize SIZE_T 欲刷新区域的长度
返回值 BOOL 如果函数成功,则返回非零值;如果失败,则返回零
(8)GetThreadContext
说明:获取指定线程的执行环境。
语法: BOOL GetThreadContext( HANDLE hThread, LPCONTEXT lpContext )
参数:
hThread HANDLE 欲获取执行环境的线程的句柄
lpContext LPCONTEXT 指向CONTEXT结构的指针
返回值 BOOL 如果函数成功,则返回非零值;如果失败,则返回零
(9)GetThreadSelectorEntry
说明:此函数返回指定选择器和线程的描述符表的入口地址。
语法: BOOL GetThreadSelectorEntry( HANDLE hThread, DWORD dwSelector, LPLDT_ENTRY lpSelectorEntry )
参数:
hThread HANDLE 包含指定选择器的线程的句柄
dwSelector DWORD 选择器数目
lpSelectorEntry LPLDT_ENTRY 指向用来接收描述符表的结构的指针
返回值 如果函数成功,则返回非零值,此外lpSelectorEntry指向的结构中将被填入接收到的描述符表;如果失败,则返回零
(10)IsDebuggerPresent
说明:此函数用来判断调用进程是否处于被调试环境中。
语法: BOOL IsDebuggerPresent( VOID)
参数:
返回值 BOOL:如果进程处在被调试状态,则返回非零值,不是处在被调试状态则返回零
(11)OutputDebugString
说明:将一个字符串传递给调试器显示。
语法: VOID OutputDebugString( LPCYSTR lpOutputString)
参数:
lpOutputString LPCYSTR:指向要显示的以“00”结尾的字符串的指针
返回值 无
(12)ReadProcessMemory
说明:读取指定进程的某区域内的数据。
语法: BOOL ReadProcessMemory( HANDLE hProcess, LPCVOID lpBassAddress, LPVOID lpBuffer, SIZE_T nSize, SIZE_T * lpNumberOfBytesRead)
参数:
hProcess HANDLE 进程的句柄
lpBassAddress LPCVOID 欲读取区域的基地址
lpBuffer LPVOID 保存读取数据的缓冲的指针
nSize SIZE_T 欲读取的字节数
lpNumberOfBytesRead SIZE_T 存储已读取字节数的地址指针
返回值 BOOL:如果函数成功,则返回非零值;如果失败,则返回零
(13)SetThreadContext
说明:设置指定线程的执行环境。
语法: BOOL SetThreadContext( HANDLE hThread, LPCONTEXT lpContext )
参数:
hThread HANDLE 欲设置执行环境的线程的句柄
lpContext LPCONTEXT 指向CONTEXT结构的指针
返回值 BOOL:如果函数成功,则返回非零值;如果失败,则返回零
(14)WaitForDebugEvent
说明:此函数用来等待被调试进程发生调试事件。
语法: BOOL WaitForDebugEvent( LPDEBUG_ENENT lpDebugEvent, DWORD dwMilliseconds)
参数:
lpDebugEvent LPDEBUG_ENENT 指向接收调试事件信息的DEBUG_ ENENT结构的指针
dwMilliseconds DWORD 该函数用来等待调试事件发生的毫秒数,如果这段时间内没有调试事件发生,函数将返回调用者;如果将该参数指定为INFINITE,函数将一直等待直到调试事件发生
返回值 BOOL:如果函数成功,则返回非零值;如果失败,则返回零
(15)WriteProcessMemory
说明:在指定进程的某区域内写入数据。
语法: BOOL WriteProcessMemory( HANDLE hProcess, LPCVOID lpBassAddress, LPVOID lpBuffer, SIZE_T nSize, SIZE_T * lpNumberOfBytesRead)
参数:
hProcess HANDLE 进程的句柄
lpBassAddress LPCVOID 欲写入区域的基地址
lpBuffer LPVOID 保存欲写入数据的缓冲的指针
nSize SIZE_T 欲写入的字节数
lpNumberOfBytesRead SIZE_T 存储已写入字节数的地址的指针
返回值 BOOL:如果函数成功,则返回非零值;如果失败,则返回零
3.1.2 调试事件
作为调试器,监视目标进程的执行、对目标进程发生的每一个调试事件做出应有的反应是它的主要工作。当目标进程发生一个调试事件后,系统将会通知调试器来处理这个事件。调试器将利用WaitForDebugEvent函数来获取目标进程的相关环境信息。可能存在的调试事件类型如表3-1所示。
表 3-1 调试事件
调 试 事 件
|
含 义
|
CREATE_PROCESS_DEBUG_EVENT
|
进程被创建。当调试的进程刚被创建(还未运行)或调试器开始调试已经激活的进程时,就会生成这个事件
|
CREATE_THEAD_DEBUG_EVENT
|
在调试进程中创建一个新的进程或调试器开始调试已经激活的进程时,就会生成这个调试事件。要注意的是当调试的主线程被创建时不会收到该通知
|
EXCEPTION_DEBUG_EVENT
|
在调试的进程中出现了异常,就会生成该调试事件
|
EXIT_PROCESS_DEBUG_EVENT
|
每当退出调试进程中的最后一个线程时,产生这个事件
|
EXIT_THREAD_DEBUG_EVENT
|
调试中的线程退出时事件发生,调试的主线程退出时不会收到该通知
|
LOAD_DLL_DEBUG_EVENT
|
每当被调试的进程装载DLL文件时,就生成这个事件。当PE装载器第一次解析出与DLL文件有关的链接时,将收到这一事件。调试进程使用了LoadLibrary时也会发生。每当DLL文件装载到地址空间中去时,都要调用该调试事件
|
OUTPUT_DEBUG_STRING_EVENT
|
当调试进程调用DebugOutputString函数向程序发送消息字符串时该事件发生
|
UNLOAD_DLL_DEBUG_EVENT
|
每当调试进程使用FreeLibrary函数卸载DLL文件时,就会生成该调试事件。仅当最后一次从过程的地址空间卸载DLL文件时,才出现该调试事件(也就是说DLL文件的使用次数为0时)
|
RIP_EVENT
|
只有Windows 98检查过的构件才会生成该调试事件。该调试事件是报告错误信息
|
当WaitForDebugEvent接收到一个调试事件时,它将把调试事件的信息填写入DEBUG_EVENT结构中并返回。这个结构定义如下:
- typedef struct _DEBUG_EVENT {
- DWORD dwDebugEventCode;
- DWORD dwProcessId;
- DWORD dwThreadId;
- union {
- EXCEPTION_DEBUG_INFO Exception;
- CREATE_THREAD_DEBUG_INFO CreateThread;
- CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
- EXIT_THREAD_DEBUG_INFO ExitThread;
- EXIT_PROCESS_DEBUG_INFO ExitProcess;
- LOAD_DLL_DEBUG_INFO LoadDll;
- UNLOAD_DLL_DEBUG_INFO UnloadDll;
- OUTPUT_DEBUG_STRING_INFO DebugString;
- RIP_INFO RipInfo;
- } u;
- } DEBUG_EVENT;
在dwDebugEventCode中的值标记了所发生的调试事件的类型。dwProcessId的值是调试事件所发生的进程的标识符。dwThreadId的值是调试事件所发生的线程的标识符。u结构包含了关于调试事件的更多信息,根据上面dwDebugEventCode的不同,它可以是表3-2中所示的结构。
表 3-2 事件信息与 u 结构成员
dwDebugEventCode
|
u 的解释
|
CREATE_PROCESS_DEBUG_EVENT
|
名为CreateProcessInfo的CREATE_PROCESS_DEBUG_INFO结构
|
EXIT_PROCESS_DEBUG_EVENT
|
名为ExitProcess的EXIT_PROCESS_DEBUG_INFO结构
|
CREATE_THREAD_DEBUG_EVENT
|
名为CreateThread的CREATE_THREAD_DEBUG_INFO结构
|
EXIT_THREAD_DEBUG_EVENT
|
名为ExitThread的EXIT_THREAD_DEBUG_EVENT 结构
|
LOAD_DLL_DEBUG_EVENT
|
名为LoadDll的LOAD_DLL_DEBUG_INFO 结构
|
UNLOAD_DLL_DEBUG_EVENT
|
名为UnloadDll的UNLOAD_DLL_DEBUG_INFO结构
|
EXCEPTION_DEBUG_EVENT
|
名为Exception的EXCEPTION_DEBUG_INFO结构
|
OUTPUT_DEBUG_STRING_EVENT
|
名为DebugString的OUTPUT_DEBUG_STRING_INFO 结构
|
RIP_EVENT
|
名为RipInfo的RIP_INFO 结构
|
那么如何访问这些数据呢?假设程序调用了WaitForDebugEvent函数并返回,那么要做的第一件事就是检查dwDebugEventCode字段中的值,根据它来判断debugger进程中发生了哪种类型的调试事件。比如说,如果dwDebugEventCode字段的值为 CREATE_PROCESS_DEBUG_EVENT,就可认为u的成员为CreateProcessInfo 并可通过u.CreateProcessInfo来访问。
下面是常用的CREATE_PROCESS_DEBUG_INFO结构的简要说明。
CREATE_PROCESS_DEBUG_INFO 结构定义:
- typedef struct _CREATE_PROCESS_DEBUG_INFO {
- HANDLE hFile;
- HANDLE hProcess;
- HANDLE hThread;
- LPVOID lpBaseOfImage;
- DWORD dwDebugInfoFileOffset;
- DWORD nDebugInfoSize;
- LPVOID lpThreadLocalBase;
- LPTHREAD_START_ROUTINE lpStartAddress;
- LPVOID lpImageName;
- WORD fUnicode;
- } CREATE_PROCESS_DEBUG_INFO, *LPCREATE_PROCESS_DEBUG_INFO;
|
3.1.3 如何在调试时创建并跟踪一个进程
这是使用 Win32 调试 API 的第一步。可以通过以下方式创建进程。
1. 如何创建一个新进程以供调试
通过CreateProcess创建新进程时,如果在dwCreationFlags标志字段中设置了DEBUG_PROCESS或DEBUG_ONLY_THIS_PROCESS标志,将创建一个用以调试的新进程。如果是以DEBUG_PROCESS标志创建新进程,调试器将会接收到目标进程及由目标进程创建的所有子进程发生的所有调试事件,一般来说这是没有必要的。建议可以指定DEBUG_ONLY_THIS_PROCESS和DEBUG_PROCESS的组合标志来禁止它,如果设置了DEBUG_ONLY_THIS_PROCESS标志,调试器将只会收到目标进程的调试事件,而对其子进程的调试事件不予理睬。当进程创建成功后,可以通过查看PROCESS_INFORMATION结构来获取被创建进程及其主线程的进程标识符和线程标识符。
由于操作系统将调试对象标记为在特殊模式下运行,所以,可以使用IsDebuggerPresent函数查看进程是否在调试器下运行。
2. 如何将调试器捆绑到一个正在运行的进程上
利用DebugActiveProcess函数可以将调试器捆绑到一个正在运行的进程上,如果执行成功,则效果类似于利用DEBUG_ONLY_THIS_PROCESS标志创建的新进程。
要注意的是,在NT内核下当试图通过DebugActiveProcess函数将调试器捆绑到一个创建时带有安全描述符的进程上时,将被拒绝。在Windows 9x中则简单得多,只有当指定了一个无效的进程标识符时调用才会失败。所以看上去NT内核的系统要更安全。
将调试器捆绑到一个进程上一般是比较好的一种做法,但是有时除了利用CreateProcess函数来载入进程外没有其他的办法。那么到底该用哪种方法呢?这涉及读者将要进行的工作。比如要做一个简单的游戏修改器时,用临时捆绑调试器的方法可能比较好,如果要做一些不同寻常的工作的话,利用载入的方法可能更好,因为它获得目标进程及其线程的所有控制权,这样就可以为所欲为了。
3.1.4 调试循环体
用调试API建立一个简单的调试程序是非常简单的,所有要做的只是创建一个用来调试的新进程,然后执行相关代码来监视所有的调试事件。笔者把监视所有的调试事件的这部分代码称为“调试循环体”,为什么呢?因为它的实现非常简单,看上去就像一个“while”循环,所需要做的只是使用WaitForDebugEvent和ContinueDebugEvent函数。就像上面说的,WaitForDebugEvent在一段时间内等待目标进程中调试事件的发生,如果在这段时间没有调试事件发生,那么函数将返回FALSE。如果在指定时间内调试事件发生了,那么函数将返回TRUE,并且它会把所发生的调试事件及其相关信息填写入一个DEBUG_EVENT结构。然后调试器会检查这些信息,并据此做出相应的反应。在对这些事件做出相应的操作后,就可以使用ContinueDebugEvent函数来恢复线程的执行,并等待下一个调试事件的发生。要注意的一点是WaitForDebugEvent只能使用在创建的或是捆绑上的进程中的某个线程上。
WaitForDebugEvent – ContinueDebugEvent循环的C语言示例:
- PROCESS_INFORMATION pi;
- STARTUP_INFO si;
- DEBUG_EVENT devent;
- If (CreateProcess( 0 , "target.exe" , 0 , 0 ,FALSE ,DEBUG_ONLY_THIS_PROCESS , 0 ,0 ,&si , π))
- {
- while(TRUE)
- {
- if (WaitForDebugEvent( &devent , 150))
- {
- switch (devent.dwDebugEventCode)
- {
- case CREATE_PROCESS_DEBUG_EVENT:
-
- break;
- case EXIT_PROCESS_DEBUG_EVENT:
-
- break;
- case EXCEPTION_DEBUG_EVENT:
-
- break;
- }
- ContinueDebugEvent(devent.dwProcessId , devent.dwThreadId , DBG_CONTINUE);
- }
- else
- {
-
- }
- }
- }
- else
- {
- MessageBox(0,"Unexpected load error","Fatal Error" ,MB_OK);
- }
|
3.1.5 如何处理调试事件
在上一节的示例中已经看到调试器如何捕获调试事件,并且利用C/C++中定义的case/switch语法做出了相应的动作。当每一个调试事件发生时,根据事件的不同类型,都会有一段不同的处理程序来进行处理。关于调试事件的更多信息可以根据DEBUG_EVENT中U结构的相应成员来取得。作为示例,再来看一下EXCEPTION_DEBUG_EVENT结构。选择它是因为遇到一个异常断点并追踪它的来龙去脉是经常要做的事情。其他的事件结构请参考API说明。
EXCEPTION_DEBUG_EVENT结构:
- typedef struct _EXCEPTION_DEBUG_INFO
- {
- EXCEPTION_RECORD ExceptionRecord;
- DWORD dwFirstChance;
- } EXCEPTION_DEBUG_INFO;
- 其中EXCEPTION_RECORD结构包含异常的很多信息,内容如下:
- typedef struct _EXCEPTION_RECORD
- {
- DWORD ExceptionCode;
- DWORD ExceptionFlags;
- struct _EXCEPTION_RECORD *ExceptionRecord;
- PVOID ExceptionAddress;
- DWORD NumberParameters;
- DWORD ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
- } EXCEPTION_RECORD;
-
|
结构中字段的定义
|
ExceptionCode
ExceptionFlags
ExceptionRecord
ExceptionAddress
NumberParameters
ExceptionInformation
|
DWORD :用来描述异常类型的代码。
DWORD :零表示可继续异常。反之,则值为 EXCEPTION_NONCONTINUABLE 。
指向 _EXCEPTION_RECORD 结构的指针。
PVOID :异常发生的地址。
DWORD : ExceptionInformation 队列中定义的 32 位参数的数目。
额外的 32 位消息队列,主要在嵌套异常时使用,对多数异常情况,它没有定义。
|
从这些结构中可找到需要的一切,比如可以发现:发生异常的类型、是否可以继续执行、异常发生的地址等信息。
注意: 在发生一个 EXCEPTION_NONCONTINUABLE 异常后如果试图去继续执行,那么会产生一个 EXCEPTION_NONCONTINUABLE_EXCEPTION 异常。
最常见的异常情况是EXCEPTION_BREAKPOINT和EXCEPTION_SINGLE_ STEP。当执行时遇到一个INT 3断点时会产生EXCEPTION_BREAKPOINT异常,如果设置了单步执行标志,那么执行完一条指令后会发生一个EXCEPTION_SINGLE_ STEP异常。关于异常还有一点要知道,当以调试的方式创建一个进程的时候,在进入进程之前,系统会先执行一次DebugBreak函数,这样会产生一个EXCEPTION_BREAKPOINT异常,如果一切正常,那么这应该是第一个遇到的也是必定会遇到的异常。
可以调用ContinueDebugEvent函数来继续线程的运行,ContinueDebugEvent函数的dwContinueStatus参数有两个取值,分别是DBG_EXCEPTION_NOT_HANDLED和DBG_CONTINUE。对于大多数调试事件,这两个值没有什么区别,都是恢复线程。惟一的例外是EXCEPTION_DEBUG_EVENT,如果线程报告发生了一个异常调试事件,就意味着在被调试的线程中发生了一个异常。如果指定了DBG_CONTINUE,线程将忽略它自己的异常处理部分并继续执行。在这种情况下,程序必须在以DBG_CONTINUE恢复线程之前检查并处理异常,否则异常将不断地发生,直至程序被系统终止。如果指定了 DBG_EXCEPTION_NOT_HANDLED值,就是告诉Windows:程序并不处理异常。Windows将使用被调试线程的默认异常处理函数来处理异常。这一般发生在什么时候呢?在进程被载入后发生的第一个EXCEPTION_DEBUG_EVENT,我们必须以DBG_CONTINUE为标志继续,在程序中如果调用了DebugBreak函数,或者插入INT3设置断点成功,并将内存恢复后,都应该使用DBG_CONTINUE为标志继续。如果在程序中发生了不确定的异常,特别是调试带壳程序的时候,多半是由于外壳的SEH引起的,此时应该以DBG_EXCEPTION_NOT_HANDLED为标志继续,以便让被调试程序本身的异常处理机制来处理。
在发生其他调试事件时,可以利用类似的结构取得线程、进程所调用的DLL或其他一些事物的信息集合。
3.1.6 线程环境详解
在WIN32系统中,进程的概念实际包含了它的私有地址空间、代码、数据和一个主线程。每个进程都有一个最初的主线程,通过这个主线程可以在以后创建在同一地址空间中运行的其他线程。和一般说法不同的是进程并不执行代码,真正执行代码的是线程。每个线程共同分享相同的地址空间和相同的系统资源,但是它们各自又有不同的执行环境,这到底如何理解呢?Windows是一个多任务多线程的操作系统,在系统的同一时间里看似运行着多个线程,但事实并非如此。Windows分配给每个线程一小段时间片,这段时间结束后,Windows将冻结当前线程并切换到下一个具有最高优先级的线程。在切换之前,Windows将把当前线程执行状态保存到一个名为CONTEXT的结构中。这个环境包含下面几部分:
● 线程执行所用寄存器
● 系统堆栈和用户堆栈
● 线程所用的描述符表等其他状态信息
这样当该线程再次恢复运行时,Windows就可以恢复最近一次线程运行的“环境”,好像中间什么都没有发生一样。
CONTEXT结构包含了特定处理器的寄存器数据,系统使用CONTEXT结构执行各种内部操作。由于此结构是依赖于硬件的,所以在X86,Alpha等不同的系统上,此结构是不同的。下面是X86系统中此结构的构成情况:
- typedef struct _CONTEXT {
- DWORD ContextFlags;
- DWORD Dr0;
- DWORD Dr1;
- DWORD Dr2;
- DWORD Dr3;
- DWORD Dr6;
- DWORD Dr7;
- FLOATING_SAVE_AREA FloatSave;
- DWORD SegGs;
- DWORD SegFs;
- DWORD SegEs;
- DWORD SegDs;
- DWORD Edi;
- DWORD Esi;
- DWORD Ebx;
- DWORD Edx;
- DWORD Ecx;
- DWORD Eax;
- DWORD Ebp;
- DWORD Eip;
- DWORD SegCs;
- DWORD EFlags;
- DWORD Esp;
- DWORD SegSs;
- } CONTEXT;
其中的FloatSave是指向FLOATING_SAVE_AREA结构的指针,FLOATING_ SAVE_AREA结构定义如下:
- typedef struct _FLOATING_SAVE_AREA {
- DWORD ControlWord;
- DWORD StatusWord;
- DWORD TagWord;
- DWORD ErrorOffset;
- DWORD ErrorSelector;
- DWORD DataOffset;
- DWORD DataSelector;
- BYTE RegisterArea[SIZE_OF_80387_REGISTERS];
- DWORD Cr0NpxState;
- } FLOATING_SAVE_AREA;
另外,其中的ContextFlags字段用于控制GetThreadContext和SetThreadContext处理那些环境信息。它的定义如下:
CONTEXT_CONTROL ContextFlags 包含此标志时处理 Ebp,Eip,Cs, Flages,Esp,Ss
CONTEXT_INTEGER ContextFlags 包含此标志时处理 Edi,Esi,Ebx,Edx,Ecx,Eax
CONTEXT_SEGMENTS ContextFlags 包含此标志时处理 GS,FS,ES,DS
CONTEXT_FLOATING_POINT ContextFlags 包含此标志时处理 FLOATING_SAVE_AREA FloatSave
CONTEXT_DEBUG_REGISTERS ContextFlags 包含此标志时处理 Dr0,Dr1,Dr2,Dr3,Dr6,Dr7
CONTEXT_FULL = (CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS)
|
为什么在CONTEXT_FULL中不包含CONTEXT_DEBUG_REGISTERS和CONTEXT_FLOATING_POINT呢?或许这只能去问微软,不过这看上去确实有点不合情理。
Windows实际上允许查看线程内核对象的内部情况,以便抓取它的当前一组CPU寄存器。若要进行这项操作,可以调用GetThreadContext和SetThreadContext函数。
GetThreadContext用来获取指定线程的执行环境,语法如下:
语法: BOOL GetThreadContext (HANDLE hThread, LPCONTEXT lpContext )
参数:
hThread HANDLE :欲获取执行环境的线程的句柄
lpContext LPCONTEXT :指向 CONTEXT 结构的指针
|
要注意的是,在使用GetThreadContext函数之前,必须先将ContextFlags初始化为适当的标志,指明想要收回哪些寄存器,并将该结构的地址传递给GetThreadContext函数。例如设置的标志是CONTEXT_CONTROL,只返回Ebp,Eip,Cs,Flages,Esp,Ss这些值。
SetThreadContext用来设置指定线程的执行环境,语法如下:
语法: BOOL SetThreadContext(HANDLE hThread, LPCONTEXT lpContext )
参数:
hThread HANDLE :欲设置执行环境的线程的句柄
lpContext LPCONTEXT :指向 CONTEXT 结构的指针
|
与GetThreadContext函数相类似,SetThreadContext函数也是通过ContextFlags的值来控制哪些数据将被恢复。
这两个函数威力非凡。有了它们,对于被调试进程你就有了上帝的能力。如果改变其寄存器内容,那么在被调试程序恢复运行前,这些值将会写回寄存器中。在进程环境中所做的任何改动,都将反映到被调试程序中。想像一下:甚至可以改变EIP寄存器的内容,这样就可以让程序运行到想要的任何地方! 在正常情况下是不可能做到这一点的。
在调用GetThreadContext函数之前,应该调用SuspendThread函数,否则线程可能被调度,而且线程的环境可能与收回的不同。一个线程实际上有两个环境:一个是用户方式,一个是内核方式。GetThreadContext函数只能返回线程的用户方式环境。如果调用SuspendThread函数来停止线程的运行,但该线程目前正在用内核方式运行,那么,即使SuspendThread实际上尚未暂停该线程的运行,它的用户方式仍然处于稳定状态。线程在恢复用户方式之前,无法执行更多的用户方式代码,因此可以放心地将线程视为处于暂停状态,GetThreadContext函数将能正常运行。
所以,正确的做法应该是先利用 SuspendThread 函数暂停一个线程,当设置好环境后再利用 ResumeThread 函数来恢复它。但要注意的是: ResumeThread 函数并不能保证线程真地继续执行,为什么呢?每一个线程都有一个线程暂停计数器,当线程正在运行时计数器为 0 ,当其他线程对此线程使用 SuspendThread 函数时计数器会增加 1 ,调用 ResumeThread 会使计数器减小 1 ,所以当调用 SuspendThread 函数后计数器变为 1 。但是 Windows 是一个多线程操作系统,所以很有可能某个其他的线程也对此线程调用了 SuspendThread ,这时计数器就会变为 2 ,这时再调用 ResumeThread 只会使计数器变回为 1 ,线程将继续暂停,直到计数器变为 0 。那么,如何确定线程是否真地被继续执行了呢?很简单,检查函数返回值就可以了。如果返回值为 0 ,则表示线程已经恢复执行了,如果不为 0 ,则表示线程继续被暂停,如果为 0xffffffff ,则说明函数调用失败了。
同样,在调用SetThreadContext函数之前,必须暂停,否则结果将无法预测。
3.1.7 如何在另一个进程中注入代码
现在让我们更深入一些来讨论,有时候需要将一段代码注入到某个进程的地址空间中,实际上这并不非常复杂,但在真正开始做之前先得解决一个小小的麻烦:首先得需要一小段地址空间来存放补丁代码。这似乎很简单,有的读者会说,利用VirtualAllocEx不就可以了吗?遗憾的是,VirtualAllocEx只在Windows NT内核下被支持,在Windows 9X内核下不被支持,那怎么办呢?如果注入的代码很短小,那么可以利用原进程的各个区块之间的间隙,甚至可以把代码注入到原进程文件头中的DOS stub部分,当然这样的话执行之前先得更改目标进程文件头的读写属性。如果要注入的部分比较大呢?只能先将目标进程中的某个代码页保存,然后注入新的代码,执行完后再将原始的代码写回。具体的步骤如下:
( 1 )利用 CreateProcess 函数创建一个供调试的进程。
( 2 )建立 WaitForDebugEvent 和 ContinueDebugEvent 构成的调试循环体。
( 3 )利用 SuspendThread 函数挂起目标线程。
( 4 )利用 VirtualProtectEx 函数修改目标页的读写权限。
( 5 )利用 ReadProcessMemory 函数读取目标页。
( 6 )利用 GetThreadContext 函数保存线程环境。
( 7 )利用 WriteProcessMemory 函数写入新代码页。
( 8 )确认在新指令中的最后一个代码是 INT 3 ,我们需要利用它在指令执行完成后获得系统控制权。 INT 3 产生的异常将会被我们的程序捕获,要注意的是必须确认这是一个 breakpoint 异常,并且是在我们放置 INT 3 的位置。
( 9 )保存一份 CONTEXT 结构的临时拷贝。
( 10 )在这份临时拷贝中设置新的 EIP 值。
( 11 )恢复原线程的执行,它将执行我们的代码,直到 INT 3 被执行,当它被执行时会被我们的程序捕获,目标线程再次被挂起。
( 12 )利用 WriteProcessMemory 函数恢复原始代码页。
( 13 )恢复原始代码页的读写属性。
( 14 )利用 SetThreadContext 恢复线程原始的环境。
( 15 )恢复原线程执行。
如果需要让注入的代码和进程原始的代码同时存在于进程空间中,而且准备注入的代码比较大,则必须为目标进程分配一些地址空间。调用VirtuallAlloc的代码是非常短小的,可以先在目标进程中注入调用VirtuallAlloc的代码,利用它可以获取一些额外的地址空间,一般来说几个KB就足够了,不要试图去申请比如10MB的空间,那样很容易导致执行失败。当然还有另一个办法,可以在自己的进程中调用VirtualAllocEx,这也可以为目标进程分配一定的地址空间,可惜的是它只能运行在Windows NT内核下。
如果为了某些工作,需要将某个区块的相对地址转换为线性虚拟地址,就可以使用函数GetThreadSelectorEntry。
最后提醒一下,向其他线程注入代码时,千万要注意堆栈的平衡问题,如果不注意,可能会产生非常严重的错误。