Windows 上的跟踪和日志记录技术。第二部分 - 内核模式下的简单跟踪方法





5.00/5 (6投票s)
讨论简单的跟踪方法以及 Windows 10 中涉及的新跟踪技术
目录
引言
内核驱动程序也需要能够跟踪其工作流程。如果驱动程序崩溃,用户将看到蓝屏,因此在最终确定生产驱动程序之前,必须正确检查整个工作流程和处理过程。为此,我们也可以使用一些基本方法。本文介绍驱动程序为了跟踪目的与宿主应用程序通信的简单方法。我们还将研究这些方法是如何工作的,包括一些系统内部知识。
使用 DbgPrint 进行文本输出
输出调试信息也适用于内核驱动程序。为此,我们设计了 API DbgPrint
和 DbgPrintEx
。它们使用带有可变参数的格式化字符串。此外,DbgPrintEx
允许指定组件 ID 和消息级别。那么,我们如何通过驱动程序中的 DbgPrint
API 调用看到这些输出呢?同样,DbgView 工具将在此方面提供帮助。该工具允许捕获来自 Windows 应用程序以及内核驱动程序的输出。我们之前已经弄清楚了它在用户模式应用程序中是如何工作的。现在是时候了解它是如何在内核中完成的了。
在驱动程序中打印调试输出消息文本时,添加名称前缀是一个好习惯,因为 DebugView
工具允许按消息中的文本进行过滤。为了演示 DbgView
如何为驱动程序执行输出,让我们在简单的内核模式驱动程序的某些函数中添加一些 DbgPrint
调用。例如,在驱动程序的 IRP_MJ_CREATE
分派处理程序例程中,我们有以下代码
EXTERN_C NTSTATUS DriverDispatchOpen(IN PDEVICE_OBJECT pDO, IN PIRP Irp)
{
PAGED_CODE();
UNREFERENCED_PARAMETER (pDO);
DbgPrint("%S: %s\n",DRIVER_NAME,__FUNCTION__);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest( Irp, IO_NO_INCREMENT );
return STATUS_SUCCESS;
}
一旦我们启动了一个加载驱动程序的测试应用程序,我们就可以在 DbgView
应用程序中看到来自驱动程序的输出
要在 DbgView
中接收内核通知,需要以管理员权限启动应用程序,并在应用程序的“捕获”菜单下启用“捕获内核”。
DbgPrint
API 也可用于用户模式应用程序,因为它从 ntdll.dll 导出。这可以按以下方式实现
ULONG DbgPrint(PCSTR Format,...) {
ULONG result = STATUS_INVALID_PARAMETER;
if (Format) {
typedef ULONG(__cdecl * PDbgPrint)(PCSTR Format, ...);
HMODULE ntdll = LoadLibraryA("ntdll.dll");
if (ntdll) {
PDbgPrint DbgPrint = (PDbgPrint)GetProcAddress(ntdll, "DbgPrint");
result = ERROR_BAD_DLL_ENTRYPOINT;
va_list args;
va_start(args, Format);
int _length = _vscprintf(Format, args) + 1;
char * _string = (CHAR *)malloc(_length);
if (_string) {
__try {
memset(_string, 0, _length);
_vsprintf_p(_string, _length, Format, args);
if (DbgPrint) {
result = DbgPrint(_string);
}
}
__finally {
free(_string);
}
}
else {
result = STATUS_INVALID_PARAMETER;
}
va_end(args);
FreeLibrary(ntdll);
}
else {
result = ERROR_DLL_NOT_FOUND;
}
}
return result;
}
如果调试器已附加到应用程序,那么我们在输出窗口中收到的通知与我们使用 OutputDebugString
实现时收到的通知相同。您可以在下面的屏幕截图中看到代码的执行情况
以及 DbgPrint
用法的 .NET 实现示例
class Program
{
[DllImport("ntdll.dll", CharSet = CharSet.Ansi)]
static extern int DbgPrint(string Format, __arglist);
[DllImport("ntdll.dll", CharSet = CharSet.Ansi)]
static extern int DbgPrint(string Format);
static void Main(string[] args)
{
DbgPrint("This text output with DbgPrint implementation\n");
Console.WriteLine("Press any key to quit");
Console.ReadKey();
}
}
如果您的 C# 实现的输出窗口中没有文本,则需要在项目设置中启用本机代码调试选项。如果用户模式中没有附加调试器,则该函数将传递给内核调试器。
系统范围的内核模式字符串
DbgPrint
API 不会将文本信息输出到系统范围的区域,而 OutputDebugString
会。但是,如果我们可以以相同的方式输出,那就太好了,这样我们就可以使用之前创建的工具来接收系统范围的字符串。事件、互斥体、段、信号量等内核对象由系统管理,它们可以驻留在用户模式应用程序和内核中。由于我们有命名对象,因此我们可以尝试在内核驱动程序中打开和使用它们。让我们看看如何做到这一点。首先,我们创建共享对象,并使用 ZwOpenEvent
和 ZwOpenSection
内核 API。第一个 API 用于打开事件,第二个 API 用于打开段。
// Create shared objects
if (NT_SUCCESS(Status)) {
OBJECT_ATTRIBUTES Attributes = { 0 };
UNICODE_STRING Name = { 0 };
RtlInitUnicodeString(&Name, L"\\BaseNamedObjects\\DBWIN_BUFFER");
InitializeObjectAttributes(&Attributes, &Name,
(OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE), NULL, NULL);
Status = ZwOpenSection(&hMap, SECTION_MAP_WRITE, &Attributes);
}
if (NT_SUCCESS(Status)) {
OBJECT_ATTRIBUTES Attributes = { 0 };
UNICODE_STRING Name = { 0 };
RtlInitUnicodeString(&Name, L"\\BaseNamedObjects\\DBWIN_BUFFER_READY");
InitializeObjectAttributes(&Attributes, &Name,
(OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE), NULL, NULL);
Status = ZwOpenEvent(&hBufferReady, SYNCHRONIZE, &Attributes);
}
if (NT_SUCCESS(Status)) {
OBJECT_ATTRIBUTES Attributes = { 0 };
UNICODE_STRING Name = { 0 };
RtlInitUnicodeString(&Name, L"\\BaseNamedObjects\\DBWIN_DATA_READY");
InitializeObjectAttributes(&Attributes, &Name,
(OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE), NULL, NULL);
Status = ZwOpenEvent(&hDataReady, EVENT_MODIFY_STATE, &Attributes);
}
内核中的共享对象命名应以 \BaseNamedObject\ 前缀开头。可以使用 WinObj 工具查看系统上的全局对象。
在代码中,我们只对打开已创建的对象感兴趣,就像我们在上一部分中为 OutputDebugString
实现所做的那样,所以让接收方应用程序(如 DbgView
)创建这些对象。如果它们没有被创建,那么就没有应用程序在等待跟踪消息,我们可以跳过输出。下一步是实际的文本输出。映射共享数据并将文本写入映射指针的操作与用户模式实现类似,但在这里,我们使用另一个 API 来映射内核模式下的共享数据:ZwMapViewOfSection
。
__try {
PVOID pBuffer = nullptr;
size_t view_size = 0;
if (hMap && hBufferReady && hDataReady) {
// Map section buffer
Status = ZwMapViewOfSection(hMap, ZwCurrentProcess(), &pBuffer, 0, 0, 0,
&view_size, ViewUnmap, 0, PAGE_READWRITE);
}
// Be sure that we have enough space and buffer pointer
if (pBuffer && view_size > 5) {
if (view_size > 0x1000) view_size = 0x1000;
size_t cch = strlen(text);
const char * p = text;
while (cch > 0) {
// Split message as shared buffer have 4096 bytes length
size_t length = view_size - 5;
if (cch < length) {
length = cch;
}
// Wait for buffer to be free
if (STATUS_SUCCESS == (Status = ZwWaitForSingleObject
(hBufferReady, FALSE, &timeout))) {
// First 4 bytes is the process ID
*((ULONG32*)pBuffer) = pid;
memcpy((PUCHAR)pBuffer + sizeof(ULONG32), p, length);
// Append string end character for large text
((PUCHAR)pBuffer)[length + 4] = '\0';
LONG lPrevState = 0;
// Notify that message is ready
ZwSetEvent(hDataReady, &lPrevState);
}
else {
break;
}
cch -= length;
p += length;
}
// Unmap shared buffer
ZwUnmapViewOfSection(ZwCurrentProcess(), pBuffer);
}
}
__finally {
if (hBufferReady) ZwClose(hBufferReady);
if (hDataReady) ZwClose(hDataReady);
if (hMap) ZwClose(hMap);
}
从内核模式方面,无法在系统上创建共享互斥体。在以前的操作系统版本中,操作互斥体的函数 ZwOpenMutant
或 ZwCreateMutant
和 ZwReleaseMutant
在 ntoskrnl.lib 中可用。但是,自 Windows Vista 起,ntoskrnl.exe 包含这些 API,但它们未导出,并且没有直接调用它们的方法。因此,我们将进行 string
输出,而不使用共享互斥体。实现算法与我们在上一篇文章中讨论的用户模式应用程序类似。区别仅在于使用的函数调用。
我们将实现放入 WriteSharedDebugOutputText
函数中,该函数接受文本和 ProcessId
作为参数。然后,我们修改 IRP_MJ_CREATE
分派处理程序,并在其实现中添加对我们 API 的调用。
EXTERN_C NTSTATUS DriverDispatchOpen(IN PDEVICE_OBJECT pDO, IN PIRP Irp)
{
PAGED_CODE();
UNREFERENCED_PARAMETER (pDO);
DbgPrint("%S: %s\n",DRIVER_NAME,__FUNCTION__);
PEPROCESS process = PsGetCurrentProcess();
ULONG32 pid = process ? (ULONG32)HandleToHandle32(PsGetProcessId(process)) : 0;
WriteSharedDebugOutputText("Test Message From Driver", pid);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest( Irp, IO_NO_INCREMENT );
return STATUS_SUCCESS;
}
现在我们可以在 DbgView
应用程序中检查结果。
如前所述,在驱动程序实现中,我们使用 \BaseNamedObject\ 前缀作为共享内核对象的命名。您可以在进程资源管理器工具(我在上一篇文章中展示过)中看到应用程序中创建的内核对象名称。完整的对象名称可能包含会话前缀和会话编号。通过 NtQueryObject
API 可以获得实际的完整对象名称,该 API 也可用于用户模式。例如,使用此 API,我们可以创建或打开我们的命名共享段对象。
WCHAR Name[] = L"DBWIN_BUFFER";
HANDLE hMap = OpenFileMappingW(FILE_MAP_READ, FALSE, Name);
if (!hMap) {
SECURITY_DESCRIPTOR SecurityDescriptor = { 0 };
SECURITY_ATTRIBUTES sa = { 0 };
InitializeSecurityDescriptor(&SecurityDescriptor, SECURITY_DESCRIPTOR_REVISION);
SetSecurityDescriptorDacl(&SecurityDescriptor, TRUE, 0, 0);
sa.nLength = sizeof(sa);
sa.bInheritHandle = FALSE;
sa.lpSecurityDescriptor = &SecurityDescriptor;
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, &sa,
PAGE_READWRITE, 0, 0x1000, Name);
}
因此,我们在用户模式应用程序中有一个段对象,我们使用 DBWIN_BUFFER
这个名称字符串来创建它。调用 NtQueryObject
API 以请求名称信息可以按以下方式完成
HMODULE ntdll = LoadLibraryA("ntdll.dll");
typedef NTSTATUS(NTAPI * PFN_NtQueryObject)(HANDLE, ULONG, PVOID, ULONG, PULONG);
PFN_NtQueryObject NtQueryObject =
(PFN_NtQueryObject)GetProcAddress(ntdll, "NtQueryObject");
ULONG _size = 0;
// First get the size, if it failed then size remain zero
NtQueryObject(hMap, ObjectNameInformation, NULL, 0, &_size);
// We allocate more space for zero ending unicode string
PUNICODE_STRING text = (PUNICODE_STRING)malloc(_size + 2);
if (text) {
// Fill with zeroes to have proper line ending
memset(text, 0x00, _size + 2);
if (0 == NtQueryObject(hMap, ObjectNameInformation, text, _size, &_size)) {
// Display if we have something
if (text->Length > 0 && text->Buffer) {
wprintf(L"Object Name: \"%s\"\n", text->Buffer);
}
}
free(text);
}
可以在以下屏幕截图中看到代码的执行结果
实际上,NtQueryObject
请求 ObjectNameInformation
时会返回 OBJECT_NAME_INFORMATION
结构,该结构声明如下。
typedef struct _OBJECT_NAME_INFORMATION {
UNICODE_STRING Name;
} OBJECT_NAME_INFORMATION, *POBJECT_NAME_INFORMATION;
但在示例中,我们只是直接将其强制转换为 UNICODE_STRING
结构的指针。请求 ObjectNameInformation
使用 NtQueryObject
是未公开的。要实现此代码的 .NET 版本,首先,打开或创建映射对象,与我们在上一部分中所做的一样。
MemoryMappedFile MappedFile = null;
try
{
MappedFile = MemoryMappedFile.OpenExisting(
object_names[i], MemoryMappedFileRights.Read);
}
catch
{
}
if (MappedFile == null)
{
MemoryMappedFileSecurity memory_security = new MemoryMappedFileSecurity();
memory_security.AddAccessRule(new AccessRule<MemoryMappedFileRights>(
new SecurityIdentifier("S-1-1-0"), MemoryMappedFileRights.ReadWrite,
AccessControlType.Allow));
MappedFile = MemoryMappedFile.CreateOrOpen(object_names[i], 0x1000,
MemoryMappedFileAccess.ReadWrite,
MemoryMappedFileOptions.None,
memory_security, System.IO.HandleInheritability.None);
}
C# 中 NtQueryObject
API 的包装函数如下所示
[DllImport("ntdll.dll")]
static extern int NtQueryObject([In] IntPtr Handle,
[In] int ObjectInformationClass,
[In,Out] IntPtr ObjectInformation,
[In,MarshalAs(UnmanagedType.U4)] int ObjectInformationLength,
[Out, MarshalAs(UnmanagedType.U4)] out int ReturnLength);
我们在 C++ 实现中也做了类似的事情。第一次调用 NtQueryObject
时请求大小,然后分配上述结构所需的内存。我们分配额外的 2 字节,以便有一个零结尾的 Unicode 字符串以进行正确的封送处理。否则,我们需要执行不同的封送方法。
int size = 0;
// Get the size of the structure in bytes
NtQueryObject(
MappedFile.SafeMemoryMappedFileHandle.DangerousGetHandle(),
ObjectNameInformation, IntPtr.Zero, 0, out size);
// We allocate here 2 more bytes for proper marshaling to structure
IntPtr p = Marshal.AllocCoTaskMem(size + 2);
if (p != IntPtr.Zero)
{
try
{
if (0 == NtQueryObject(
MappedFile.SafeMemoryMappedFileHandle.DangerousGetHandle(),
ObjectNameInformation, p, size, out size))
{
// This will work, as we allocate 2 bytes for zero ending string
UNICODE_STRING text = Marshal.PtrToStructure<UNICODE_STRING>(p);
if (text.Length != 0)
{
Console.WriteLine("{0} Object Name: \"{1}\"", object_info[i], text.Buffer);
}
}
}
finally
{
Marshal.FreeCoTaskMem(p);
}
}
请求信息后,我们将指针类型强制转换为 UNICODE_STRING
结构。该结构具有 Length
和 MaximumLength
字段以及 Buffer
string
指针。Length
指定 Buffer
指针的 string
长度(以字节为单位)。此 string
可能没有零结尾。但是,我们在这里没有 string
指针封送问题,因为我们分配了额外的 2 字节,并且内存分配后用零填充。因此,在任何情况下我们都有一个零结尾的 string
。代码的运行结果与 C++ 实现相同。
接收系统范围的内核字符串输出
如果您运行上一篇文章中接收系统范围 string
的应用程序,同时运行驱动程序控制应用程序,那么您将看不到在打开设备期间显示的文本输出。但是,DbgView
应用程序可以正确显示它。您也可以尝试取消选中“捕获全局 Win32”选项,然后来自驱动程序的文本消息将不会出现。由此,我们可以得出结论,从内核接收 OutputDebugString
在实现上存在差异。在运行 DbgView
时,您可以启动 进程资源管理器 工具,打开句柄选项卡,并将其内容与 DbgView
中已选中和未选中的“捕获全局 Win32”选项进行比较。
您可以看到 Event
、Section
和 Mutant
对象之间的差异。此外,如果取消选中“捕获全局 Win32”选项,那么 WinObj
工具将不会显示命名句柄,正如我们之前所展示的。
因此,我们可以看到全局对象在其名称中不包含会话前缀和数字。然后我们尝试创建与内核中同名的对象,但我们遇到了错误:ERROR_PATH_NOT_FOUND
或 ERROR_BAD_PATHNAME
。您可以使用以下代码进行尝试
WCHAR Names[][100] = {
L"\\BaseNamedObjects\\DBWIN_BUFFER",
L"BaseNamedObjects\\DBWIN_BUFFER"
};
HANDLE hMap = NULL;
for (int i = 0; i < _countof(Names) && !hMap; i++) {
hMap = OpenFileMappingW(FILE_MAP_READ, FALSE, Names[i]);
if (!hMap) {
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, &sa,
PAGE_READWRITE, 0, 0x1000, Names[i]);
}
if (!hMap) {
wprintf(L"Error [%s]\t%08d\n", Names[i], GetLastError());
}
}
if (hMap) {
wprintf(L"Succeeded\n");
CloseHandle(hMap);
}
执行此代码的结果是无法打开或创建该段。
我们在 .NET 实现中得到了类似的结果。唯一的区别是我们会在打开或创建映射段时收到异常。
string[] Names = {
"\\BaseNamedObjects\\DBWIN_BUFFER",
"BaseNamedObjects\\DBWIN_BUFFER"
};
foreach (var name in Names)
{
MemoryMappedFile MappedFile = null;
try
{
MappedFile = MemoryMappedFile.OpenExisting(
name, MemoryMappedFileRights.Read);
}
catch
{
}
if (MappedFile == null)
{
try
{
MappedFile = MemoryMappedFile.CreateOrOpen(name, cch,
MemoryMappedFileAccess.ReadWrite,
MemoryMappedFileOptions.None,
memory_security, System.IO.HandleInheritability.None);
}
catch (Exception exception)
{
Console.Write("Error [{0}]\t0x{1:X8}\t{2}",name,
exception.HResult, exception.Message);
}
}
if (MappedFile != null)
{
Console.WriteLine("\nSucceeded");
MappedFile.Dispose();
}
}
在异常处理程序中,我们显示 HRESULT
和错误文本信息。您可以看到错误号是相同的:我们必须排除 0x80070000
,结果将是一个错误代码。上述代码的执行结果如下
正如我们所见,BaseNamedObjects
是用于可从内核和用户模式访问的共享对象的特殊对象命名空间。默认情况下,应用程序在当前会话的 BaseNamedObjects
命名空间中创建对象。要显式地在全局或会话命名空间中创建对象,应用程序可以使用带有 Global\ 或 Local\ 前缀的对象名称。这意味着,为了从用户模式应用程序访问共享对象,我们应该使用 Global\ 前缀。一旦我们尝试使用该前缀创建对象,我们就可以在测试代码中看到正确的对象名称。
WCHAR Name[] = L"Global\\DBWIN_BUFFER";
HANDLE hMap = OpenFileMappingW(FILE_MAP_READ, FALSE, Name);
if (!hMap) {
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, &sa, PAGE_READWRITE, 0, 0x1000, Name);
}
if (hMap && hMap != INVALID_HANDLE_VALUE) {
ULONG _size = 0;
// First get the size, if it failed then size remain zero
NtQueryObject(hMap, ObjectNameInformation, NULL, 0, &_size);
// We allocate more space for zero ending unicode string
POBJECT_NAME_INFORMATION text = (POBJECT_NAME_INFORMATION)malloc(_size + 2);
if (text) {
// Fill with zeroes to have proper line ending
memset(text, 0x00, _size + 2);
if (0 == NtQueryObject(hMap, ObjectNameInformation, text, _size, &_size)) {
if (text->Name.Length > 0 && text->Name.Buffer) {
wprintf(L"Global Object Name: \"%s\"\n", text->Name.Buffer);
}
}
free(text);
}
CloseHandle(hMap);
}
现在,我们可以比较创建的对象名称
如果您以非管理员身份运行上述代码,那么创建带有 Global\ 前缀的映射段将失败,并返回 ERROR_ACCESS_DENIED
错误代码。
使用这样的前缀,我们也能够在 .NET 应用程序中创建全局段对象。当我们以管理员身份运行时,我们得到了与上面屏幕截图相同的名称。但是,如果我们以特定用户的身份运行,那么在执行 MemoryMappedFile.CreateOrOpen
方法(使用全局段)的调用时,执行会进入无限循环。我们还在输出窗口中看到 System.IO.FileNotFoundException
异常。但该异常会在内部处理,导致 CreateOrOpen
方法挂起。
在接收方测试应用程序中,我们需要使用 Global\ 前缀创建所有对象,才能与驱动程序通信并接收共享文本。我们修改了上一篇文章中的一个示例,并看到了结果。
在内核模式下接收 DbgPrint
如前所述,DbgView
允许我们显示使用 DbgPrint
调用进行输出的内核驱动程序的消息。为此,DbgView
应用程序使用其驱动程序,该驱动程序处理接收 DbgPrint
的通知,并向宿主应用程序提供输出。为了接收此类通知,我们可以在我们的驱动程序中使用未公开的 DbgSetDebugPrintCallback
API。通过此 API,我们可以设置一个回调例程,当使用 DbgPrint
、KdPrint
或其变体在内核模式下执行输出文本时,该例程将被调用。
// Set The DbgPrint Callback
DbgSetDebugPrintCallback(DebugPrintCallback,TRUE);
该 API 的第一个参数是回调例程,第二个参数是一个布尔变量,用于启用或禁用回调通知。让我们设计一个回调函数,将字符串输出到我们之前设计的 DebugOutputString
的共享段中,这样我们就可以在我们的测试应用程序或 DbgView
应用程序中以通用方式接收它。我们的回调实现将如下所示
EXTERN_C VOID DebugPrintCallback(PSTRING Output, ULONG ComponentId, ULONG Level)
{
UNREFERENCED_PARAMETER(ComponentId);
UNREFERENCED_PARAMETER(Level);
if (Output && Output->Length) {
KIRQL irql;
KeAcquireSpinLock(&s_DebugSpinLock,&irql);
__try {
// Allocate buffer for output
size_t cch = (Output->Length + 1) + 200;
PCHAR text = (PCHAR)ExAllocatePool(NonPagedPool, cch);
if (text) {
__try {
const char temp[] = ">> DBGPRINT: \"";
size_t cb = strlen(temp);
// Fill with zeroes
memset(text, 0x00, cch);
char * p = text;
// Put prefix
memcpy(p, temp, cb);
p += cb;
// Add content
memcpy(p, Output->Buffer, Output->Length);
p += Output->Length;
// If it ended with line break just skip it
if (p[-1] == '\n') p--;
// Append line ending
*p++ = '"'; *p++ = '\0';
// Output
MyOutputDebugString(text);
}
__finally {
ExFreePool(text);
}
}
}
__finally {
KeReleaseSpinLock(&s_DebugSpinLock,irql);
}
}
}
回调接收传递给 DbgPrintEx
或 DbgPrint
API 的组件 ID 和消息级别,因此在实现中可以根据这些值执行自己的过滤。回调在通过内部过滤后被调用。
为了能够在用户模式应用程序中显示输出,我们将使用我们之前实现的函数。我们在消息开头添加前缀“>> DBGPRINT:
”文本,然后调用我们的内核 DebugOutputString
实现。在 DebugOutputString
内核实现中,我们调用 WriteSharedDebugOutputText
来输出系统范围的 string
,或者根据 IRQL 级别进行延迟执行,具体原因将在后续主题中进行描述。设置和删除回调以及处理输出都由自旋锁进行。因此,WriteSharedDebugOutputText
函数的执行是从 DPC 级别的回调进行的。
由于接收通知的使用方式未公开,因此使用 KeAcquireSpinLockRaiseToSynch
API 和自旋锁而不是 KeAcquireSpinLockRaiseToDpc
API 来将执行提高到 SYNCH 级别可能是有意义的,但对我来说,这工作正常。
EXTERN_C NTSTATUS MyOutputDebugString(LPCSTR pszText) {
NTSTATUS Status = STATUS_INVALID_PARAMETER;
if (pszText && strlen(pszText)) {
size_t cch = (strlen(pszText) + 1);
char * text = (char *)ExAllocatePool(NonPagedPool,cch);
if (text) {
__try {
memset(text, 0x00, cch);
memcpy(text, pszText, cch - 1);
PEPROCESS process = PsGetCurrentProcess();
ULONG32 pid = process ? (ULONG32)HandleToHandle32(PsGetProcessId(process)) : 0;
if (PASSIVE_LEVEL == KeGetCurrentIrql()) {
// For Passive level just write the text
Status = WriteSharedDebugOutputText(text, pid);
}
else {
LARGE_INTEGER time_out = { 0 };
Status = KeWaitForSingleObject
(&s_Lock, Executive, KernelMode, FALSE, &time_out);
if (Status == STATUS_SUCCESS) {
__try {
if (s_pDeviceObject) {
// Otherwise perform delayed processing
PIO_OUTPUT_TEXT io = (PIO_OUTPUT_TEXT)ExAllocatePool(
NonPagedPool,
sizeof(IO_OUTPUT_TEXT));
if (io) {
memset(io, 0x00, sizeof(IO_OUTPUT_TEXT));
io->Item = IoAllocateWorkItem(s_pDeviceObject);
if (io->Item) {
io->Pid = pid;
io->Text = text;
text = nullptr;
IoQueueWorkItem(io->Item,
StringOutputWorkItemProc,
DelayedWorkQueue, io);
Status = STATUS_SUCCESS;
}
else {
Status = STATUS_UNSUCCESSFUL;
ExFreePool(io);
}
}
}
else {
Status = STATUS_DEVICE_DOES_NOT_EXIST;
}
}
__finally {
KeReleaseMutex(&s_Lock, FALSE);
}
}
}
}
__finally {
if (text) ExFreePool(text);
}
}
else {
Status = STATUS_NO_MEMORY;
}
}
return Status;
}
要从接收通知中删除回调,我们需要将 FALSE
作为第二个参数传递给 DbgSetDebugPrintCallback
API 调用。
我们的驱动程序与 DbgView
结合执行的结果。
正如您所见,一条消息的 PID
号码是 4
。这是一个系统进程,其 DriverEntry
被调用。
将 DbgPrint
输出转发到系统范围 string
缓冲区并在我们的测试应用程序中接收的执行结果。
有时,您可能会启动 DbgView
工具,并发现内核捕获不起作用,因为该工具使用的驱动程序 dbgv.sys 已被锁定在系统上。
这可能是因为调试通知回调未被移除,并且无法替换和加载驱动程序文件。有一个小技巧可以解决这个问题。首先,将该 sys 文件从工具资源解压缩到 System32\drivers 文件夹。由于文件被锁定,您无法删除它,这会导致所述问题。但是,为了使工具在不重启 PC 的情况下恢复工作,可以只重命名该文件并重新启动 DbgView
工具,然后它将正常工作。您可以看到新的 dbgv.sys 文件出现在目标文件夹中,但重命名后的文件在您重新启动之前仍被锁定。在下一个屏幕截图中,我将 dbgv.sys 重命名为 _dbgv.sys 并尝试删除它。
过滤 DbgPrint 输出
正如我们在上面的调试打印回调代码实现中所看到的,除了文本之外,我们还有组件 ID 和级别。这些参数与格式化的 string
参数一起通过 DbgPrintEx
函数传递。DbgPrint
API 不包含级别和组件 ID。在 Windows Vista 及更高版本的 Windows 中,所有由 DbgPrint
和 KdPrint
发送的消息都与 DEFAULT
组件相关联。因此,内部调用 DbgPrintEx
时,组件值是 DPFLTR_DEFAULT_ID
,级别是 DPFLTR_INFO_LEVEL
。可以使用强制转换为 ULONG
的“-1
”代替 DPFLTR_DEFAULT_ID
。
如果我们尝试运行处理接收系统范围字符串的驱动程序测试应用程序,但不启动 DbgView
工具,或在其中禁用“捕获内核”选项,那么我们将无法接收前面显示的 DbgPrint
通知。发生这种情况是因为在 DbgPrint
API 的文本传递到回调之前,它会根据这些组件 ID 和级别值在内部检查筛选设置。如果通过的级别对目标组件启用,则回调会接收到它。这些设置是系统范围的,DbgView
工具在启用“捕获内核”选项时会启用它们,并在应用程序退出时将其重置。因此,我们需要设置这些筛选设置才能接收通知。
默认过滤可以通过修改注册表值来启用或禁用。设置位于 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Debug Print Filter 注册表项。如果根本没有 Debug Print Filter 键,则表示尚未设置任何过滤设置。您可以创建此键并设置默认过滤值。每个条目值都以组件名称命名,并表示默认过滤级别(以 DWORD
为单位)。级别值是 0 到 32 范围内的位字段掩码组合。主要级别位值可以在 dpfilter.h SDK 头文件中找到。
#define DPFLTR_ERROR_LEVEL 0
#define DPFLTR_WARNING_LEVEL 1
#define DPFLTR_TRACE_LEVEL 2
#define DPFLTR_INFO_LEVEL 3
#define DPFLTR_MASK 0x80000000
组件的名称也可以在同一头文件中的 DPFLTR_TYPE
枚举中找到。它显示为 DPFLTR_XXXX_ID
,其中 XXXX
是注册表中使用的组件名称。名称必须大写创建。
typedef enum _DPFLTR_TYPE {
DPFLTR_SYSTEM_ID = 0,
DPFLTR_SMSS_ID = 1,
DPFLTR_SETUP_ID = 2,
DPFLTR_NTFS_ID = 3,
DPFLTR_FSTUB_ID = 4,
DPFLTR_CRASHDUMP_ID = 5,
DPFLTR_CDAUDIO_ID = 6,
DPFLTR_CDROM_ID = 7,
DPFLTR_CLASSPNP_ID = 8,
DPFLTR_DISK_ID = 9,
DPFLTR_REDBOOK_ID = 10,
DPFLTR_STORPROP_ID = 11,
DPFLTR_SCSIPORT_ID = 12,
DPFLTR_SCSIMINIPORT_ID = 13,
//...
//...
DPFLTR_FSLIB_ID = 154,
DPFLTR_ENDOFTABLE_ID
} DPFLTR_TYPE;
例如,如果我们想启用接收所有默认组件通知,我们应该创建一个名为 DEFAULT
的值,并将其设置为 0xff
。
重新启动后,所有具有默认组件 ID(DPFLTR_DEFAULT_ID
)和注册表中启用的级别的通知都将提供给 DbgPrintCallback
。
这些设置在 PC 启动时加载。可以使用 WinDbg 修改当前会话的过滤设置。
要为组件指定新的过滤器掩码,您应该启动本地或远程内核调试会话。并访问符号 Kd_XXXX_Mask
,其中 XXXX
是所需的组件名称。要显示掩码值,可以使用 WinDbg
的 dd
命令。它将组件符号掩码作为参数。例如,显示默认组件 ID(DPFLTR_DEFAULT_ID
)的用法。
要修改掩码值,可以使用 ed
命令,该命令的过滤器级别值参数以及组件符号掩码。
WinDbg
允许您从 DbgPring
API 获取输出,但在 Windows Vista 之后,这仅在远程调试模式下有效。在 WinDbg
中,DbgPrint
缓冲区的内容会立即显示在“调试器命令”窗口中,除非通过 GFlags 的“缓冲 DbgPrint
输出”选项禁用了它。此应用程序是“Windows 调试工具”的一部分。
GFlags
工具将这些参数存储在 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\GlobalFlag DWORD
值中。它包含指定标志的按位组合。抑制调试器输出标志称为 FLG_DISABLE_DBGPRINT
,值为 0x08000000
。
如果 DbgView
工具能够实时配置组件过滤设置,那么我们也可以尝试以编程方式启用或禁用过滤。为此,系统中存在两个与调试过滤相关的函数,它们允许以编程方式控制这些设置。一个用于查询过滤设置,另一个用于更改这些设置。这些函数未公开。它们在内核中可用:DbgSetDebugFilterState
、DbgQueryDebugFilterState
,在用户模式中可用:NtSetDebugFilterState
、NtQueryDebugFilterState
。
为了了解该函数的工作原理,我们可以实现一个简单的应用程序,在启用 DbgView
工具的“捕获内核”选项以及“启用详细内核输出”之前启动它。
// Maximum number of levels
const ULONG DPFLTR_LEVEL_MAX = 0x1E;
// The component id
ULONG ComponentId = DPFLTR_DEFAULT_ID;
ULONG Mask = 0;
ULONG Level = 0;
while (Level < DPFLTR_LEVEL_MAX) {
// Request value for selected level
if (NtQueryDebugFilterState(ComponentId, Level)) {
// Build mask
Mask |= (1 << Level);
}
Level++;
}
// Display the mask
wprintf(L"DPFLTR_DEFAULT_ID Filter Mask: \"%08X\"\n",Mask);
在此代码中,我们为默认组件 ID(DPFLTR_DEFAULT_ID
)构建了一个级别掩码并显示它。DbgView
之前的代码执行结果如下
这意味着默认情况下,只有 DPFLTR_ERROR_LEVEL
的错误消息会传递到调试打印回调。一旦启动 DbgView
工具,您就可以比较掩码
您也可以使用 WinDbg
检查该掩码,如前所述。
此代码的 .NET 实现类似。我们只需要为 NtQueryDebugFilterState
API 创建一个包装器。
[DllImport("ntdll.dll", CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.U1)]
static extern bool NtQueryDebugFilterState(
[In, MarshalAs(UnmanagedType.U4)] int ComponentId,
[In, MarshalAs(UnmanagedType.U4)] int Level);
const int DPFLTR_LEVEL_MAX = 0x1E;
const int DPFLTR_DEFAULT_ID = 101;
// The component id
int ComponentId = DPFLTR_DEFAULT_ID;
int Mask = 0;
int Level = 0;
while (Level<DPFLTR_LEVEL_MAX)
{
// Request value for selected level
if (NtQueryDebugFilterState(ComponentId, Level))
{
// Build mask
Mask |= (1 << Level);
}
Level++;
}
// Display the mask
Console.WriteLine("DPFLTR_DEFAULT_ID Filter Mask: \"{0:X8}\"\n", Mask);
让我们整合全部功能。在加载驱动程序之前的应用程序中,我们将每个组件 ID 的现有级别掩码值存储在数组中。数组的大小是 DPFLTR_ENDOFTABLE_ID
* 31
。同时,我们为每个组件启用过滤级别。
// Array of saved states
BOOLEAN SavedStates[DPFLTR_ENDOFTABLE_ID * DPFLTR_LEVEL_MAX] = { 0 };
ULONG ComponentId = 0;
PBOOLEAN States = (PBOOLEAN)&SavedStates;
BOOLEAN State = TRUE;
while (ComponentId < DPFLTR_ENDOFTABLE_ID) {
ULONG Level = 0;
while (Level < DPFLTR_LEVEL_MAX) {
*States++ = NtQueryDebugFilterState(ComponentId, Level);
NtSetDebugFilterState(ComponentId, Level++, State);
}
ComponentId++;
}
应用程序退出后,我们将级别掩码从该数组恢复。
ULONG ComponentId = 0;
PBOOLEAN States = (PBOOLEAN)&SavedStates;
while (ComponentId < DPFLTR_ENDOFTABLE_ID) {
ULONG Level = 0;
while (Level < DPFLTR_LEVEL_MAX) {
BOOLEAN State = (BOOLEAN)(*States++);
NtSetDebugFilterState(ComponentId, Level++, State);
}
ComponentId++;
}
内核中上述功能的相同实现。保存组件级别状态,可以在 DriverEntry
中调用。
#define DPFLTR_LEVEL_MAX 0x1E
// Saved DbgPrint States
ULONG s_SavedStates[DPFLTR_ENDOFTABLE_ID * DPFLTR_LEVEL_MAX] = { 0 };
ULONG ComponentId = 0;
PULONG States = (PULONG)&s_SavedStates;
// Enable States
BOOLEAN State = TRUE;
while ( ComponentId < DPFLTR_ENDOFTABLE_ID ) {
ULONG Level = 0;
while ( Level < DPFLTR_LEVEL_MAX ) {
*States++ = DbgQueryDebugFilterState(ComponentId, Level);
DbgSetDebugFilterState(ComponentId, Level++, State);
}
ComponentId++;
}
恢复级别,可以在驱动程序卸载时调用。
ULONG ComponentId = 0;
PULONG States = (PULONG)&s_SavedStates;
// Set Filter State Back
while (ComponentId < DPFLTR_ENDOFTABLE_ID) {
ULONG Level = 0;
while (Level < DPFLTR_LEVEL_MAX) {
BOOLEAN State = (BOOLEAN)(*States++);
DbgSetDebugFilterState(ComponentId, Level++, State);
}
ComponentId++;
}
与用户模式相比,我们这里有一个 ULONG
值数组,而不是 BOOLEAN
;这是基于 DbgQueryDebugFilterState
函数的返回结果。
一旦我们将这些功能添加到驱动程序测试应用程序中,我们就可以从 DbgPrintCallback
接收消息。
结果是,我们能够启动 C++ 应用程序(带有驱动程序加载模式)和 C# 应用程序(用于接收全局 string
),如下面的屏幕截图所示。
代码示例
本部分的代码可供下载。如果您想尝试本部分的示例驱动程序,可以从源代码编译。它配置为使用 Visual Studio 中的 WDK 工具集进行编译。构建过程中,它会创建测试证书 drv1.cer 并对驱动程序进行签名。要使用该驱动程序,您需要在此 PC 上安装该证书并启用系统的测试模式,或禁用驱动程序签名检查。驱动程序测试应用程序只能从管理员运行,因为它加载和卸载驱动程序需要管理员权限。
历史
- 2023 年 6 月 15 日:初始版本