65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2023 年 6 月 15 日

CPOL

17分钟阅读

viewsIcon

9082

downloadIcon

290

讨论简单的跟踪方法以及 Windows 10 中涉及的新跟踪技术

目录

引言

内核驱动程序也需要能够跟踪其工作流程。如果驱动程序崩溃,用户将看到蓝屏,因此在最终确定生产驱动程序之前,必须正确检查整个工作流程和处理过程。为此,我们也可以使用一些基本方法。本文介绍驱动程序为了跟踪目的与宿主应用程序通信的简单方法。我们还将研究这些方法是如何工作的,包括一些系统内部知识。

使用 DbgPrint 进行文本输出

输出调试信息也适用于内核驱动程序。为此,我们设计了 API DbgPrintDbgPrintEx。它们使用带有可变参数的格式化字符串。此外,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 会。但是,如果我们可以以相同的方式输出,那就太好了,这样我们就可以使用之前创建的工具来接收系统范围的字符串。事件、互斥体、段、信号量等内核对象由系统管理,它们可以驻留在用户模式应用程序和内核中。由于我们有命名对象,因此我们可以尝试在内核驱动程序中打开和使用它们。让我们看看如何做到这一点。首先,我们创建共享对象,并使用 ZwOpenEventZwOpenSection 内核 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);
}

从内核模式方面,无法在系统上创建共享互斥体。在以前的操作系统版本中,操作互斥体的函数 ZwOpenMutantZwCreateMutantZwReleaseMutantntoskrnl.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 结构。该结构具有 LengthMaximumLength 字段以及 Buffer string 指针。Length 指定 Buffer 指针的 string 长度(以字节为单位)。此 string 可能没有零结尾。但是,我们在这里没有 string 指针封送问题,因为我们分配了额外的 2 字节,并且内存分配后用零填充。因此,在任何情况下我们都有一个零结尾的 string。代码的运行结果与 C++ 实现相同。

接收系统范围的内核字符串输出

如果您运行上一篇文章中接收系统范围 string 的应用程序,同时运行驱动程序控制应用程序,那么您将看不到在打开设备期间显示的文本输出。但是,DbgView 应用程序可以正确显示它。您也可以尝试取消选中“捕获全局 Win32”选项,然后来自驱动程序的文本消息将不会出现。由此,我们可以得出结论,从内核接收 OutputDebugString 在实现上存在差异。在运行 DbgView 时,您可以启动 进程资源管理器 工具,打开句柄选项卡,并将其内容与 DbgView 中已选中和未选中的“捕获全局 Win32”选项进行比较。

您可以看到 EventSectionMutant 对象之间的差异。此外,如果取消选中“捕获全局 Win32”选项,那么 WinObj 工具将不会显示命名句柄,正如我们之前所展示的。

因此,我们可以看到全局对象在其名称中不包含会话前缀和数字。然后我们尝试创建与内核中同名的对象,但我们遇到了错误:ERROR_PATH_NOT_FOUNDERROR_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,我们可以设置一个回调例程,当使用 DbgPrintKdPrint 或其变体在内核模式下执行输出文本时,该例程将被调用。

// 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);
        }
    }
}

回调接收传递给 DbgPrintExDbgPrint 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 中,所有由 DbgPrintKdPrint 发送的消息都与 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 是所需的组件名称。要显示掩码值,可以使用 WinDbgdd 命令。它将组件符号掩码作为参数。例如,显示默认组件 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 工具能够实时配置组件过滤设置,那么我们也可以尝试以编程方式启用或禁用过滤。为此,系统中存在两个与调试过滤相关的函数,它们允许以编程方式控制这些设置。一个用于查询过滤设置,另一个用于更改这些设置。这些函数未公开。它们在内核中可用:DbgSetDebugFilterStateDbgQueryDebugFilterState,在用户模式中可用:NtSetDebugFilterStateNtQueryDebugFilterState

为了了解该函数的工作原理,我们可以实现一个简单的应用程序,在启用 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 日:初始版本
© . All rights reserved.