将详细分析Windows调试的本机接口。希望读者对C和通用NT内核体系结构和语义有一些基本的了解。此外,这并不是介绍什么是调试或如何编写调试器。它可以作为经验丰富的调试器编写人员或好奇的安全专家的参考。
本机调试
现在是时候看看事情的本机方面,以及ntdll.dll中的包装层如何与内核通信。DbgUi层的优点是它允许更好地分离Win32和NT内核,而NT内核一直是NT设计的一部分。NTDLL和NTOSKRNL是一起构建的,所以他们对彼此有复杂的了解是正常的。它们共享相同的结构,需要有相同的系统调用ID等。在完美的世界中,NT内核应该对Win32一无所知。
此外,它还可以帮助任何希望在本机应用程序中编写调试功能或编写功能齐全的本机模式调试器的人。如果没有DbgUi,就必须手动调用Nt*DebugObject api,并在某些情况下进行大量的前/后处理。DbgUi将所有这些工作简化为一个简单的调用,并提供一个干净的接口来完成。如果内核在内部发生变化,DbgUi可能会保持不变,只会修改其内部代码。
我们从负责创建调试对象并将其与当前进程关联的函数开始探索。与Win32不同,创建调试对象和实际附加到进程之间有着明显的区别。
NTSTATUS NTAPI DbgUiConnectToDbg(VOID) { OBJECT_ATTRIBUTES ObjectAttributes; /* Don't connect twice */ if (NtCurrentTeb()->DbgSsReserved[1]) return STATUS_SUCCESS; /* Setup the Attributes */ InitializeObjectAttributes(&ObjectAttributes, NULL, 0, NULL, 0); /* Create the object */ return ZwCreateDebugObject(&NtCurrentTeb()->DbgSsReserved[1], DEBUG_OBJECT_ALL_ACCESS, &ObjectAttributes, TRUE); }
如您所见,这是一个微不足道的实现,但它向我们展示了两件事。首先,一个线程只能有一个相关联的调试对象,其次,这个对象的句柄存储在TEB的DbgSsReserved数组字段中。回想一下,在Win32中,第一个索引[0]是存储线程数据的位置。我们现在知道了[1]是存放把手的地方。
现在让我们看看如何连接和分离:
NTSTATUS NTAPI DbgUiDebugActiveProcess(IN HANDLE Process) { NTSTATUS Status; /* Tell the kernel to start debugging */ Status = NtDebugActiveProcess(Process, NtCurrentTeb()->DbgSsReserved[1]); if (NT_SUCCESS(Status)) { /* Now break-in the process */ Status = DbgUiIssueRemoteBreakin(Process); if (!NT_SUCCESS(Status)) { /* We couldn't break-in, cancel debugging */ DbgUiStopDebugging(Process); } } /* Return status */ return Status; } NTSTATUS NTAPI DbgUiStopDebugging(IN HANDLE Process) { /* Call the kernel to remove the debug object */ return NtRemoveProcessDebug(Process, NtCurrentTeb()->DbgSsReserved[1]); }
同样,这些都是非常简单的实现。但是,我们可以了解到,内核并不负责在远程进程中真正的中断,而是由本机层完成。这个DbgUiIssueRemoteBreakin API在调用DebugBreakProcess时也被Win32使用,所以让我们来看看它:
NTSTATUS NTAPI DbgUiIssueRemoteBreakin(IN HANDLE Process) { HANDLE hThread; CLIENT_ID ClientId; NTSTATUS Status; /* Create the thread that will do the breakin */ Status = RtlCreateUserThread(Process, NULL, FALSE, 0, 0, PAGE_SIZE, (PVOID)DbgUiRemoteBreakin, NULL, &hThread, &ClientId); /* Close the handle on success */ if(NT_SUCCESS(Status)) NtClose(hThread); /* Return status */ return Status; }
它所做的只是在进程内创建一个远程线程,然后返回到调用方。那个远程线程有什么魔力吗?让我们看看:
VOID NTAPI DbgUiRemoteBreakin(VOID) { /* Make sure a debugger is enabled; if so, breakpoint */ if (NtCurrentPeb()->BeingDebugged) DbgBreakPoint(); /* Exit the thread */ RtlExitUserThread(STATUS_SUCCESS); }
一点也不特别;线程确保进程真正被调试,然后发出断点。而且,因为这个API是导出的,所以您可以从自己的进程本地调用它来发出调试中断(但请注意,您将杀死自己的线程)。在我们查看Win32调试实现时,我们注意到实际的调试句柄从未使用过,而且调用总是通过DbgUi进行。然后调用NtSetInformationDebugObject系统调用,之前调用了一个特殊的DbgUi API,以实际获取与线程关联的调试对象。这个API也有一个对应的API,所以让我们看看这两个API的作用:
HANDLE NTAPI DbgUiGetThreadDebugObject(VOID) { /* Just return the handle from the TEB */ return NtCurrentTeb()->DbgSsReserved[1]; } VOID NTAPI DbgUiSetThreadDebugObject(HANDLE DebugObject) { /* Just set the handle in the TEB */ NtCurrentTeb()->DbgSsReserved[1] = DebugObject; }
对于那些熟悉面向对象编程的人来说,这似乎类似于访问器和变异器方法的概念。尽管Win32对这个句柄有完美的访问权限,并且可以自己简单地读取它,NT开发人员还是决定让DbgUi更像一个类,并确保通过这些公共方法访问这个句柄。这种设计允许在必要时将调试句柄存储在其他任何地方,并且只有这两个api需要更改,而不是Win32中的多个dll。
现在访问wait/continue函数,它在Win32下只是包装器:
NTSTATUS NTAPI DbgUiContinue(IN PCLIENT_ID ClientId, IN NTSTATUS ContinueStatus) { /* Tell the kernel object to continue */ return ZwDebugContinue(NtCurrentTeb()->DbgSsReserved[1], ClientId, ContinueStatus); } NTSTATUS NTAPI DbgUiWaitStateChange(OUT PDBGUI_WAIT_STATE_CHANGE DbgUiWaitStateCange, IN PLARGE_INTEGER TimeOut OPTIONAL) { /* Tell the kernel to wait */ return NtWaitForDebugEvent(NtCurrentTeb()->DbgSsReserved[1], TRUE, TimeOut, DbgUiWaitStateCange); }
毫不奇怪,这些函数也是DbgUi中的包装器。然而,这是事情开始变得有趣的地方,因为如果您还记得,DbgUi对调试事件使用完全不同的结构,称为DbgUi-WAIT-STATE-CHANGE。我们还有一个API要看,它负责转换,所以首先,让我们看看这个结构的文档:
// // User-Mode Debug State Change Structure // typedef struct _DBGUI_WAIT_STATE_CHANGE { DBG_STATE NewState; CLIENT_ID AppClientId; union { struct { HANDLE HandleToThread; DBGKM_CREATE_THREAD NewThread; } CreateThread; struct { HANDLE HandleToProcess; HANDLE HandleToThread; DBGKM_CREATE_PROCESS NewProcess; } CreateProcessInfo; DBGKM_EXIT_THREAD ExitThread; DBGKM_EXIT_PROCESS ExitProcess; DBGKM_EXCEPTION Exception; DBGKM_LOAD_DLL LoadDll; DBGKM_UNLOAD_DLL UnloadDll; } StateInfo; } DBGUI_WAIT_STATE_CHANGE, *PDBGUI_WAIT_STATE_CHANGE;
这些字段应该很简单,所以让我们看看DBG_STATE枚举:
// // Debug States // typedef enum _DBG_STATE { DbgIdle, DbgReplyPending, DbgCreateThreadStateChange, DbgCreateProcessStateChange, DbgExitThreadStateChange, DbgExitProcessStateChange, DbgExceptionStateChange, DbgBreakpointStateChange, DbgSingleStepStateChange, DbgLoadDllStateChange, DbgUnloadDllStateChange } DBG_STATE, *PDBG_STATE;
如果您查看Win32调试事件结构和关联的调试事件类型,您会注意到一些可能对您有用的差异。对于初学者,异常、断点和单步异常的处理方式不同。在Win32世界中,只有两个区别:用于异常的RIP_事件和用于调试事件的EXCEPTION_DEBUG_事件。尽管代码稍后可以确定这是一个断点还是一个步骤,但这些信息直接来自本机结构。您还将注意到缺少OUTPUT_DEBUG_STRING事件。在这里,DbgUi处于劣势,因为信息是作为异常发送的,并且需要进行后处理(我们将很快对此进行研究)。Win32还不支持另外两种状态,即空闲状态和应答挂起状态。从调试器的角度来看,它们不提供太多信息,因此被忽略。
现在让我们看看实际的结构:
// // Debug Message Structures // typedef struct _DBGKM_EXCEPTION { EXCEPTION_RECORD ExceptionRecord; ULONG FirstChance; } DBGKM_EXCEPTION, *PDBGKM_EXCEPTION; typedef struct _DBGKM_CREATE_THREAD { ULONG SubSystemKey; PVOID StartAddress; } DBGKM_CREATE_THREAD, *PDBGKM_CREATE_THREAD; typedef struct _DBGKM_CREATE_PROCESS { ULONG SubSystemKey; HANDLE FileHandle; PVOID BaseOfImage; ULONG DebugInfoFileOffset; ULONG DebugInfoSize; DBGKM_CREATE_THREAD InitialThread; } DBGKM_CREATE_PROCESS, *PDBGKM_CREATE_PROCESS; typedef struct _DBGKM_EXIT_THREAD { NTSTATUS ExitStatus; } DBGKM_EXIT_THREAD, *PDBGKM_EXIT_THREAD; typedef struct _DBGKM_EXIT_PROCESS { NTSTATUS ExitStatus; } DBGKM_EXIT_PROCESS, *PDBGKM_EXIT_PROCESS; typedef struct _DBGKM_LOAD_DLL { HANDLE FileHandle; PVOID BaseOfDll; ULONG DebugInfoFileOffset; ULONG DebugInfoSize; PVOID NamePointer; } DBGKM_LOAD_DLL, *PDBGKM_LOAD_DLL; typedef struct _DBGKM_UNLOAD_DLL { PVOID BaseAddress; } DBGKM_UNLOAD_DLL, *PDBGKM_UNLOAD_DLL;
如果您熟悉DEBUG_EVENT结构,您应该注意到一些细微的差异。首先,没有进程名的指示,这解释了为什么MSDN将此字段记录为可选字段,而Win32不使用它。您还将注意到线程结构中缺少指向TEB的指针。最后,与新进程不同的是,Win32确实显示了加载的任何新DLL的名称,但在加载DLL结构中似乎也缺少这个名称;我们将很快看到如何处理这个和其他更改。但是,对于额外的信息,我们有“SubsystemKey”字段。由于NT被设计为支持多个子系统,所以这个字段对于识别从哪个子系统创建新线程或进程至关重要。Windows2003SP1增加了对调试POSIX应用程序的支持,虽然我还没有研究过POSIX调试API,但我确信它们是围绕DbgUi实现构建的,而且POSIX库使用此字段的方式不同(很像Win32忽略它)。
现在我们已经看到了这些差异,最后要看的API是DbgUiConvertStateChangeStructure,它负责执行这些修改和修正:
NTSTATUS NTAPI DbgUiConvertStateChangeStructure(IN PDBGUI_WAIT_STATE_CHANGE WaitStateChange, OUT PVOID Win32DebugEvent) { NTSTATUS Status; OBJECT_ATTRIBUTES ObjectAttributes; THREAD_BASIC_INFORMATION ThreadBasicInfo; LPDEBUG_EVENT DebugEvent = Win32DebugEvent; HANDLE ThreadHandle; /* Write common data */ DebugEvent->dwProcessId = (DWORD)WaitStateChange-> AppClientId.UniqueProcess; DebugEvent->dwThreadId = (DWORD)WaitStateChange->AppClientId.UniqueThread; /* Check what kind of even this is */ switch (WaitStateChange->NewState) { /* New thread */ case DbgCreateThreadStateChange: /* Setup Win32 code */ DebugEvent->dwDebugEventCode = CREATE_THREAD_DEBUG_EVENT; /* Copy data over */ DebugEvent->u.CreateThread.hThread = WaitStateChange->StateInfo.CreateThread.HandleToThread; DebugEvent->u.CreateThread.lpStartAddress = WaitStateChange->StateInfo.CreateThread.NewThread.StartAddress; /* Query the TEB */ Status = NtQueryInformationThread(WaitStateChange->StateInfo. CreateThread.HandleToThread, ThreadBasicInformation, &ThreadBasicInfo, sizeof(ThreadBasicInfo), NULL); if (!NT_SUCCESS(Status)) { /* Failed to get PEB address */ DebugEvent->u.CreateThread.lpThreadLocalBase = NULL; } else { /* Write PEB Address */ DebugEvent->u.CreateThread.lpThreadLocalBase = ThreadBasicInfo.TebBaseAddress; } break; /* New process */ case DbgCreateProcessStateChange: /* Write Win32 debug code */ DebugEvent->dwDebugEventCode = CREATE_PROCESS_DEBUG_EVENT; /* Copy data over */ DebugEvent->u.CreateProcessInfo.hProcess = WaitStateChange->StateInfo.CreateProcessInfo.HandleToProcess; DebugEvent->u.CreateProcessInfo.hThread = WaitStateChange->StateInfo.CreateProcessInfo.HandleToThread; DebugEvent->u.CreateProcessInfo.hFile = WaitStateChange->StateInfo.CreateProcessInfo.NewProcess. FileHandle; DebugEvent->u.CreateProcessInfo.lpBaseOfImage = WaitStateChange->StateInfo.CreateProcessInfo.NewProcess. BaseOfImage; DebugEvent->u.CreateProcessInfo.dwDebugInfoFileOffset = WaitStateChange->StateInfo.CreateProcessInfo.NewProcess. DebugInfoFileOffset; DebugEvent->u.CreateProcessInfo.nDebugInfoSize = WaitStateChange->StateInfo.CreateProcessInfo.NewProcess. DebugInfoSize; DebugEvent->u.CreateProcessInfo.lpStartAddress = WaitStateChange->StateInfo.CreateProcessInfo.NewProcess. InitialThread.StartAddress; /* Query TEB address */ Status = NtQueryInformationThread(WaitStateChange->StateInfo. CreateProcessInfo.HandleToThread, ThreadBasicInformation, &ThreadBasicInfo, sizeof(ThreadBasicInfo), NULL); if (!NT_SUCCESS(Status)) { /* Failed to get PEB address */ DebugEvent->u.CreateThread.lpThreadLocalBase = NULL; } else { /* Write PEB Address */ DebugEvent->u.CreateThread.lpThreadLocalBase = ThreadBasicInfo.TebBaseAddress; } /* Clear image name */ DebugEvent->u.CreateProcessInfo.lpImageName = NULL; DebugEvent->u.CreateProcessInfo.fUnicode = TRUE; break; /* Thread exited */ case DbgExitThreadStateChange: /* Write the Win32 debug code and the exit status */ DebugEvent->dwDebugEventCode = EXIT_THREAD_DEBUG_EVENT; DebugEvent->u.ExitThread.dwExitCode = WaitStateChange->StateInfo.ExitThread.ExitStatus; break; /* Process exited */ case DbgExitProcessStateChange: /* Write the Win32 debug code and the exit status */ DebugEvent->dwDebugEventCode = EXIT_PROCESS_DEBUG_EVENT; DebugEvent->u.ExitProcess.dwExitCode = WaitStateChange->StateInfo.ExitProcess.ExitStatus; break; /* Any sort of exception */ case DbgExceptionStateChange: case DbgBreakpointStateChange: case DbgSingleStepStateChange: /* Check if this was a debug print */ if (WaitStateChange->StateInfo.Exception.ExceptionRecord. ExceptionCode == DBG_PRINTEXCEPTION_C) { /* Set the Win32 code */ DebugEvent->dwDebugEventCode = OUTPUT_DEBUG_STRING_EVENT; /* Copy debug string information */ DebugEvent->u.DebugString.lpDebugStringData = (PVOID)WaitStateChange-> StateInfo.Exception.ExceptionRecord. ExceptionInformation[1]; DebugEvent->u.DebugString.nDebugStringLength = WaitStateChange->StateInfo.Exception.ExceptionRecord. ExceptionInformation[0]; DebugEvent->u.DebugString.fUnicode = FALSE; } else if (WaitStateChange->StateInfo.Exception.ExceptionRecord. ExceptionCode == DBG_RIPEXCEPTION) { /* Set the Win32 code */ DebugEvent->dwDebugEventCode = RIP_EVENT; /* Set exception information */ DebugEvent->u.RipInfo.dwType = WaitStateChange->StateInfo.Exception.ExceptionRecord. ExceptionInformation[1]; DebugEvent->u.RipInfo.dwError = WaitStateChange->StateInfo.Exception.ExceptionRecord. ExceptionInformation[0]; } else { /* Otherwise, this is a debug event, copy info over */ DebugEvent->dwDebugEventCode = EXCEPTION_DEBUG_EVENT; DebugEvent->u.Exception.ExceptionRecord = WaitStateChange->StateInfo.Exception.ExceptionRecord; DebugEvent->u.Exception.dwFirstChance = WaitStateChange->StateInfo.Exception.FirstChance; } break; /* DLL Load */ case DbgLoadDllStateChange : /* Set the Win32 debug code */ DebugEvent->dwDebugEventCode = LOAD_DLL_DEBUG_EVENT; /* Copy the rest of the data */ DebugEvent->u.LoadDll.lpBaseOfDll = WaitStateChange->StateInfo.LoadDll.BaseOfDll; DebugEvent->u.LoadDll.hFile = WaitStateChange->StateInfo.LoadDll.FileHandle; DebugEvent->u.LoadDll.dwDebugInfoFileOffset = WaitStateChange->StateInfo.LoadDll.DebugInfoFileOffset; DebugEvent->u.LoadDll.nDebugInfoSize = WaitStateChange->StateInfo.LoadDll.DebugInfoSize; /* Open the thread */ InitializeObjectAttributes(&ObjectAttributes, NULL, 0, NULL, NULL); Status = NtOpenThread(&ThreadHandle, THREAD_QUERY_INFORMATION, &ObjectAttributes, &WaitStateChange->AppClientId); if (NT_SUCCESS(Status)) { /* Query thread information */ Status = NtQueryInformationThread(ThreadHandle, ThreadBasicInformation, &ThreadBasicInfo, sizeof(ThreadBasicInfo), NULL); NtClose(ThreadHandle); } /* Check if we got thread information */ if (NT_SUCCESS(Status)) { /* Save the image name from the TIB */ DebugEvent->u.LoadDll.lpImageName = &((PTEB)ThreadBasicInfo.TebBaseAddress)-> Tib.ArbitraryUserPointer; } else { /* Otherwise, no name */ DebugEvent->u.LoadDll.lpImageName = NULL; } /* It's Unicode */ DebugEvent->u.LoadDll.fUnicode = TRUE; break; /* DLL Unload */ case DbgUnloadDllStateChange: /* Set Win32 code and DLL Base */ DebugEvent->dwDebugEventCode = UNLOAD_DLL_DEBUG_EVENT; DebugEvent->u.UnloadDll.lpBaseOfDll = WaitStateChange->StateInfo.UnloadDll.BaseAddress; break; /* Anything else, fail */ default: return STATUS_UNSUCCESSFUL; } /* Return success */ return STATUS_SUCCESS; }
让我们看看这些有趣的装饰。首先,通过使用ThreadBasicInformation类型调用NtQueryInformationThread很容易修复TEB指针的不足,该类型返回指向TEB的指针,然后将其保存在Win32结构中。至于调试字符串,API分析异常代码并查找DBG_PRINTEXCEPTION_C,它有一个特定的异常记录,该记录被解析并转换为调试字符串输出。
到目前为止还算不错,但在加载DLL的代码中可能出现了最糟糕的黑客攻击。因为加载的DLL在内核内存中没有EPROCESS或ETHREAD这样的结构,但是在ntdll的私有Ldr结构中,唯一标识它的是内存中内存映射文件的节对象。当内核收到为可执行内存映射文件创建节的请求时,它会将文件名保存在TEB(或者TIB,更确切地说,是TIB)中的一个字段中,该字段称为arbiryuserpointer。
然后,此函数知道一个字符串位于那里,并将其设置为调试事件的lpImageName成员的指针。自从第一次构建以来,这个黑客就一直在NT中,据我所知,它仍然存在于Vista中。会这么难解决吗?
再次,我们的讨论到此结束,因为ntdll中处理调试对象的部分已经所剩无几。以下是本系列本部分讨论内容的概述:
- DbgUi在内核和Win32或其他子系统之间提供了一定程度的分离。它是作为一个完全独立的类编写的,甚至有访问器和变异器方法,而不是公开它的句柄。
- 线程调试对象的句柄存储在TEB中DbgSsReserved数组的第二个字段中。
- DbgUi允许一个线程有一个DebugObject,但是使用本机系统调用允许您做任何您想要做的事情。
- 大多数dbguiapi是NtXxxDebugObject系统调用的简单包装器,并使用TEB句柄进行通信。
- DbgUi负责进入附加的进程,而不是内核。
- DbgUi对调试事件使用自己的结构,内核可以理解这种结构。在某些方面,此结构提供了有关某些事件的更多信息(例如子系统以及这是单步还是断点异常),但在其他方面,缺少某些信息(例如指向线程TEB的指针或单独的调试字符串结构)。
- TIB(位于TEB的仲裁指针成员中)包含调试事件期间加载的DLL的名称。