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

Windows 上的追踪和日志记录技术。第三部分 - 内核中的用户模式句柄

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2023年7月5日

CPOL

24分钟阅读

viewsIcon

7163

downloadIcon

462

继续讨论简单的内核驱动程序追踪机制,通过将输出信息写入从主机应用程序传递给驱动程序的文件句柄、管道和控制台。

目录

引言

从上一篇文章中,我们已经了解到驱动程序可以在内核中使用句柄,其方式与用户模式下通过名称打开共享句柄相同。这些是命名对象,它们分别从用户空间和内核空间访问。使用全局名称前缀打开共享句柄并不总是有效,因为需要考虑不同用户可能存在的安全问题,例如,驱动程序在本地系统账户下运行,Windows 服务可以在本地服务账户下运行,而特定应用程序可以作为访客运行。更重要的是,自 Windows Vista 以来,创建映射段需要启用 SeCreateGlobalPrivilege

如果我们尝试将文件对象的句柄传递给驱动程序,并且驱动程序向其输出,那会怎样?在内核中使用用户空间句柄存在一些隐藏的陷阱。在计划使用用户空间句柄时,您应该意识到许多可能的问题。所有这些问题都将在此处描述,通过妥善处理它们,您可以确保您的应用程序和驱动程序正常工作。

与驱动程序通信

首先,我们应该设计与驱动程序的通信,因为我们计划传递我们自己创建的句柄。对于小数据输入或输出,一个很好的方法是设备输入和输出控制机制(IOCTL)。我们需要能够启用和禁用对我们用户空间句柄的输出。这可以分成单独的设备 IOCTL 消息,但我决定将其放入单个调用中,并使用以下结构作为输入。

#include <pshpack1.h>
typedef struct _APP_HANDLE_INFO {
    // Application Handle
    HANDLE    Handle;
    // Enable Or Disable
    BOOLEAN Enabled;
}APP_HANDLE_INFO,*PAPP_HANDLE_INFO;
#include <poppack.h>

在 C# 中看起来是这样的

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct APP_HANDLE_INFO
{
    // Application Handle
    [MarshalAs(UnmanagedType.SysInt)]
    public IntPtr Handle;
    // Enable Or Disable
    [MarshalAs(UnmanagedType.U1)]
    public bool Enabled;
};

在此结构中,我们有对象的句柄和一个布尔变量,用于启用或禁用此句柄作为输出目标。稍后,我将解释为什么我们在此处使用结构。该结构将通过 DeviceIoControl 函数传递,该函数允许与驱动程序通信。我们还准备了控制代码,驱动程序将在指定的调度例程中进行检查。

#define IOCTL_DRIVER_CONFIGURE_HANDLE_OUTPUT        \
        CTL_CODE( FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS )

为了在驱动程序中处理 IOCTL,我们应该为 IRP_MJ_DEVICE_CONTROL 准备调度例程,该例程将接收我们的驱动程序调用。在该例程中,我们检查控制代码,如果它等于 IOCTL_DRIVER_CONFIGURE_HANDLE_OUTPUT 值,则处理输入结构参数。

EXTERN_C NTSTATUS DriverDispatchDeviceControl(IN PDEVICE_OBJECT pDO, IN PIRP Irp)
{
    PAGED_CODE();
    UNREFERENCED_PARAMETER(pDO);

    NTSTATUS Status = STATUS_SUCCESS;
    PIO_STACK_LOCATION Stack = IoGetCurrentIrpStackLocation(Irp);
    ULONG ControlCode = Stack->Parameters.DeviceIoControl.IoControlCode;

    Irp->IoStatus.Information =
        Stack->Parameters.DeviceIoControl.OutputBufferLength;

    switch (ControlCode) {
    case IOCTL_DRIVER_CONFIGURE_HANDLE_OUTPUT:
    {
        DbgPrint("%S: IOCTL_DRIVER_CONFIGURE_HANDLE_OUTPUT \n", DRIVER_NAME);
        Irp->IoStatus.Status = STATUS_SUCCESS;
        Irp->IoStatus.Information = 0;

        //... Process control message

        break;
    }
    default:
        Irp->IoStatus.Status = STATUS_INVALID_PARAMETER;
        Irp->IoStatus.Information = 0;
        break;
    }
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return Status;
}

基本实现

我们可以传递给驱动程序的基本句柄是文件句柄。因此,我们在控制台应用程序中打开文件,驱动程序将向其写入信息。

APP_HANDLE_INFO info = {0};
info.Enabled = TRUE;
info.Handle = CreateFile(_T("d:\\mylog.txt"), (GENERIC_READ | GENERIC_WRITE), 
    FILE_SHARE_READ, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
DWORD dwBytesReturned = 0;
// Enable Shared Handle
if (DeviceIoControl(hDevice,IOCTL_DRIVER_CONFIGURE_HANDLE_OUTPUT,
    &info, sizeof(info), NULL,0, &dwBytesReturned,NULL) == 0) {
    _tprintf(_T("DeviceIOControl Failed %d\n"),GetLastError());
}

在上面的代码中,我们通过描述的方法将文件句柄传递给驱动程序。C# 中的相同实现如下所示:

var file = new FileStream(@"d:\mylog.txt", FileMode.Create, 
                          FileAccess.ReadWrite, FileShare.Read);

APP_HANDLE_INFO info = new APP_HANDLE_INFO();
info.Handle = file.SafeFileHandle.DangerousGetHandle();
info.Enabled = true;

int Size = Marshal.SizeOf(info);
IntPtr ptr = Marshal.AllocCoTaskMem(Size);
Marshal.StructureToPtr(info, ptr, false);

// Enable Shared Handle
if (!DeviceIoControl(hDevice, IOCTL_DRIVER_CONFIGURE_HANDLE_OUTPUT,
    ptr, Size, IntPtr.Zero, 0, out BytesReturned, IntPtr.Zero))
{
    Console.WriteLine("DeviceIOControl Failed {0}", DrvCommon.GetLastError());
}

在驱动程序中,我们无法直接使用该句柄,我们必须为内核使用重新打开它。首先,当我们从用户模式接收到句柄时,我们需要在内核中引用它。为此,我们将使用 ObReferenceObjectByHandle 内核 API。它具有类似于 DuplicateHandle 用户模式 API 的功能。

// Create Object Reference
Irp->IoStatus.Status = 
    ObReferenceObjectByHandle(
        target->Handle, 0, 
        *IoFileObjectType, UserMode, (PVOID *)&s_pUserObject, NULL);

然后在内核中引用用户对象时,它会增加内部对象引用计数器,并标记此引用是在内核模式下创建的,因此我们不必担心用户模式应用程序意外退出以及驱动程序因句柄无效而崩溃。要减少引用,请使用 ObDereferenceObject API。增加对象引用后,我们需要打开另一个将在内核中使用的该对象的句柄。这通过 ObOpenObjectByPointer API 完成。

// Open Kernel Handle
Irp->IoStatus.Status = 
    ObOpenObjectByPointer(
        s_pUserObject,OBJ_KERNEL_HANDLE,
        NULL,GENERIC_WRITE,*IoFileObjectType,KernelMode,
        &s_hOutputKernelHandle
    );

现在我们可以在内核模式下使用句柄变量 s_hOutputKernelHandle。此类句柄在使用后必须通过 ZwClose 函数关闭。

我们应该准备一个例程来关闭所有用户句柄对象。一旦我们从应用程序收到 APP_HANDLE_INFO 结构的 Enabled 字段等于 FALSE 的 IOCTL,就会调用该例程。

EXTERN_C VOID CloseUserOutputHandle() {
    PAGED_CODE();
    if (s_hOutputKernelHandle) {
        ZwClose(s_hOutputKernelHandle);
    }
    if (s_pUserObject) {
        ObDereferenceObject(s_pUserObject);
    }
    s_hOutputKernelHandle = NULL;
    s_pUserObject = NULL;
    s_hOutputUserHandle = NULL;
}

为了写入数据,我们将使用 ZwWriteFile API 和我们之前打开的句柄。

IO_STATUS_BLOCK iosb = { 0 };
CHAR text[] = "Hello From Driver :)\n"
Status = ZwWriteFile(s_hOutputKernelHandle,
    NULL, NULL, NULL, &iosb, (PVOID)text, (ULONG)strlen(text), NULL, NULL);

出于测试目的,我们在 IRP_MJ_DEVICE_CONTROL 调度例程中设置用户句柄后立即调用它并检查结果。然后我们在驱动器 D: 上启动应用程序,出现文件 mylog.txt,一旦我们调用 DeviceIoControl,该文件中就会有驱动程序写入的文本。

句柄和平台问题

为我们驱动程序中的 IOCTL 定义的 APP_HANDLE_INFO 结构很好,但让我们考虑一下以下情况:我们有一个 x64 操作系统,因此在内核中我们也安装了 x64 驱动程序,其 HANDLE 大小为 64 位。同时,调用驱动程序的用户模式应用程序可以构建为 x86 或 x64,因此每个应用程序中的 HANDLE 将具有不同的大小。在下面的测验图片中,找出哪些进程作为 x86 构建运行,哪些作为 x64 运行。

注意:如果您尝试示例驱动程序,您应该记住测试应用程序会根据编译平台检查目标文件夹中已编译的驱动程序,该驱动程序可以是 x86 或 x64。因此,要在 64 位 Windows 平台上尝试 x86 测试应用程序,您应该将 x64 平台驱动程序放置到应用程序文件夹中。或者通过 x64 应用程序安装驱动程序,并注释掉卸载代码行。驱动程序测试应用程序中存在一些命令行参数,因此您可以使用它们来检查实现说明。

因此,为了展示上述问题,我决定使用结构作为驱动程序的输入。有两种方法可以处理这个问题。一种非常简单——将结构 Handle 字段设为固定大小类型,而不是可能因应用程序平台而异的 HANDLE。因此结构将是

#include <pshpack1.h>
typedef struct _APP_HANDLE_INFO {
    // Application Handle
    PVOID64    Handle;
    // Enable Or Disable
    BOOLEAN Enabled;
}APP_HANDLE_INFO,*PAPP_HANDLE_INFO;
#include <poppack.h>

同时在 C# 中。

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct APP_HANDLE_INFO
{
    // Application Handle
    [MarshalAs(UnmanagedType.U8)]
    public long Handle;
    // Enable Or Disable
    [MarshalAs(UnmanagedType.U1)]
    public bool Enabled;
};

另一种方法不那么简单,但更具实用性。结构保持原样供应用程序使用,更改将在驱动程序端进行。由于输入可能有两个不同的 x664 和 x86 平台结构,并且我们的驱动程序是 x64 目标,因此我们需要将不同的输入转换为驱动程序的通用结构,即 x64 平台的结构。因此,对于将传递给驱动程序的 x86 平台,此结构看起来像

#include <pshpack1.h>
typedef struct _APP_HANDLE_INFO32 {
    // Input Handle
    ULONG32    Handle;
    // Enable Or Disable
    BOOLEAN Enabled;
}APP_HANDLE_INFO32,*PAPP_HANDLE_INFO32;
#include <poppack.h>

如果进程是 x86,我们可以使用 IoIs32bitProcess 函数检测到。因此,如果应用程序是 32 位,我们接收到 APP_HANDLE_INFO32 结构,然后只需将其字段转换为 x64 结构,最后使用后者进行处理。该算法的代码如下:

PAPP_HANDLE_INFO target = (PAPP_HANDLE_INFO)Irp->AssociatedIrp.SystemBuffer;
APP_HANDLE_INFO _info = {0};
// That only need for X64 build
#if defined(_WIN64)
// If host process 32 bit application
if (IoIs32bitProcess(Irp) && cch >= sizeof(APP_HANDLE_INFO32)) {
    PAPP_HANDLE_INFO32 _info32 = (PAPP_HANDLE_INFO32)target;
    // Fill X64 structure
    target = &_info;
    target->Handle = Handle32ToHandle((const void * __ptr32)_info32->Handle);
    target->Enabled = _info32->Enabled;
    cch = sizeof(_info);
}
#endif

我们仅在驱动程序在 x64 平台上构建时才调用该代码块,因为在 x86 平台上,我们将具有相同的 APP_HANDLE_INFO32 结构作为目标。

关闭句柄并跟踪主机进程退出

如前所述,为了关闭句柄输出,我们调用相同的 IOCTL 并使用相同的结构,但将 Enabled 字段设置为 FALSE。如果我们不调用 IOCTL 关闭我们的句柄并且应用程序即将退出,那么我们应该在 IRP_MJ_CLOSE 调度例程期间检查:传递句柄的进程是否正在关闭驱动程序句柄,或者它是否被系统关闭。我们可以通过以下方式实现:如果调用进程 PID 与设置用户句柄的进程相同,那么我们应该调用关闭例程并释放用户句柄。

// Get current process PID
PEPROCESS process = PsGetCurrentProcess();
HANDLE pid = process ? PsGetProcessId(process) : 0;
BOOLEAN bClose = FALSE;
    
if (STATUS_SUCCESS ==
    KeWaitForSingleObject(&s_LockUserHandle, Executive, KernelMode, FALSE, NULL)) {
    // Compare the Id with IOCTL Id
    bClose = (pid != 0 && s_hUserPID == pid);
    KeReleaseMutex(&s_LockUserHandle, FALSE);
}
if (bClose) {
    // Close user handle
    CloseUserOutputHandle();
}

在我们的实现中,在通过 IOCTL 注册用户模式句柄时,我们还应该保存进程 PID

有可能应用程序意外退出,这可能发生在崩溃期间、调试器结束进程或有人在任务管理器下关闭它。我们也必须记住这种情况,因为那时驱动程序中的句柄会变得无效,我们必须关闭它。为了在我们的驱动程序中处理这个问题,我们可以使用 PsSetCreateProcessNotifyRoutine 函数,它类似于上一篇文章中提到的 DbgSetDebugPrintCallback,它有两个参数:第一个是回调函数指针,第二个是启用或禁用该回调的布尔变量。

// Set Process Callback
PsSetCreateProcessNotifyRoutine(CreateProcessNotifyCallback,FALSE);

第二个参数的值 FALSE 表示回调已启用。在驱动程序卸载例程中,我们应该调用此函数并将第二个参数设置为 TRUE。回调函数接收父进程 PID、目标进程 PID 和一个标志,该标志显示目标进程是启动还是退出。要查看该回调的工作方式,我们可以向其中添加一个 DbgPrint API 调用,并在 DbgView 中查看输出。

EXTERN_C VOID CreateProcessNotifyCallback(HANDLE ParentId, 
                                          HANDLE ProcessId, BOOLEAN Create) {
    PAGED_CODE();
    UNREFERENCED_PARAMETER(ParentId);
    DbgPrint("Process: %lld %s\n", ProcessId, Create ? "Started" : "Exits");
}

回调使用的结果在下一张截图中

由于我们在通过 IOCTL 注册用户模式句柄的实现中保存了进程 PID,因此一旦该进程退出,我们就关闭已打开的句柄。

EXTERN_C VOID CreateProcessNotifyCallback
         (HANDLE ParentId,HANDLE ProcessId,BOOLEAN Create) {
    PAGED_CODE();
    UNREFERENCED_PARAMETER(ParentId);
    DbgPrint("Process: %lld %s\n",ProcessId, Create ? "Started" : "Exits");
    
    // We only interesting for process exits
    if (!Create) {
        BOOLEAN bClose = FALSE;
        if (STATUS_SUCCESS ==
            KeWaitForSingleObject(&s_LockUserHandle, Executive, 
                                  KernelMode, FALSE, NULL)) {
            // Detach our object if target process exits
            bClose = (ProcessId && s_hUserPID == ProcessId);
            KeReleaseMutex(&s_LockUserHandle, FALSE);
        }
        if (bClose) {
            CloseUserOutputHandle();
        }
    }
}

管道句柄

我同意从驱动程序向应用程序文件句柄写入数据并不是很有用。我们可以尝试用于通信的另一个句柄是管道。因此,我们创建一个管道对并将用于写入的管道句柄传递给驱动程序。在应用程序中,我们将等待来自读取器管道的数据,如果其上出现任何数据,我们只需将其写入控制台窗口。驱动程序中不需要更改代码。所有更改都在驱动程序控制应用程序中进行。

HANDLE handle = NULL;
HANDLE hReadPipe = NULL;
DWORD dwBytesReturned = 0;

CreatePipe(&hReadPipe, &handle, NULL, 0);
APP_HANDLE_INFO info = {0};
info.Handle = handle;
info.Enabled = TRUE;

// Enable Shared Handle
if (DeviceIoControl(hDevice,IOCTL_DRIVER_CONFIGURE_HANDLE_OUTPUT,
    &info, sizeof(info), NULL,0, &dwBytesReturned,NULL) == 0) {
    _tprintf(_T("DeviceIOControl Failed %d\n"),GetLastError());
}

这部分的 C# 实现如下。

var server = new AnonymousPipeServerStream(PipeDirection.Out,
                                HandleInheritability.Inheritable);
APP_HANDLE_INFO info = new APP_HANDLE_INFO();
info.Handle = server.SafePipeHandle.DangerousGetHandle();
info.Enabled = true;
int Size = Marshal.SizeOf(info);
IntPtr ptr = Marshal.AllocCoTaskMem(Size);
Marshal.StructureToPtr(info, ptr, false);

// Enable Shared Handle
DeviceIoControl(hDevice, IOCTL_DRIVER_CONFIGURE_HANDLE_OUTPUT,
    ptr, Size, IntPtr.Zero, 0, out BytesReturned, IntPtr.Zero);

现在接收并显示数据

HANDLE hHandles[] = { g_hQuit, hReadPipe };
DWORD dwTimeOutput = 1000;
HANDLE hCurrentHandle = GetStdHandle(STD_ERROR_HANDLE);
bool bExit = false;
while (!bExit) {
    DWORD dwRead = 0;
    BYTE buf[1024] = { 0 };
    if (hReadPipe) {
        // Check for any information available on a pipe
        if (PeekNamedPipe(hReadPipe, buf, sizeof(buf), &dwRead, NULL, NULL) 
            && dwRead) {
            // Pull data From pipe
            if (!ReadFile(hReadPipe, buf, sizeof(buf), &dwRead, NULL) || 
                dwRead == 0) {
                break;
            }
        }
    }
    // If Something readed then output it into stderr
    if (dwRead) {
        WriteFile(hCurrentHandle, buf, dwRead, &dwRead, NULL);
    }

    // Check quit event
    DWORD _result = WaitForMultipleObjects
                    (_countof(hHandles),hHandles,FALSE,dwTimeOutput);
        
    if (WAIT_OBJECT_0 == _result) {
        bExit = true;
    }

    // Check If Key Were Pressed
    if (_kbhit()) {
        bExit = true;
    }
}

我们等待管道句柄或退出事件被信号量通知。一旦通过管道接收到数据,我们就将其输出到 stderr

.NET 实现看起来不同,因为我们必须单独打开接收器管道,使用服务器管道的 string 标识符。

var pipe = new AnonymousPipeClientStream(PipeDirection.In, 
                                        server.GetClientHandleAsString());
CancellationTokenSource cancel = new CancellationTokenSource();
Stream console = Console.OpenStandardOutput();
pipe.CopyToAsync(console, 4096, cancel.Token);
while (true)
{
    // Wait Until Quit
    if (g_evQuit.WaitOne(1000)) break;
    // Check If Key Were Pressed
    if (Console.KeyAvailable)
    {
        Console.ReadKey();
        break;
    }
}
cancel.Cancel();
cancel.Dispose();
console.Dispose();

对于输出,我们打开控制台流并调用 CopyToAsync 方法,该方法执行所有工作。一旦退出事件发生,我们取消 async 任务并处置所有流。
执行结果如下

在驱动程序中,我们具有相同的回调功能来接收 DbgPrint 输出,该输出现在直接通过管道句柄传递到应用程序。这种用户模式句柄的使用方式更好,对吗?

控制台句柄

好的,控制台怎么样?我们也可以获取从函数 GetStdHandle() 接收到的控制台句柄。如我们从上一篇文章中已知,该句柄可以在系统中使用 WriteFile API。因此,让我们尝试传递该句柄并检查结果。是的,当您尝试传递控制台句柄时,在驱动程序中的下一个代码中会收到错误 STATUS_NOT_SUPPORTED

Irp->IoStatus.Status = 
    ObOpenObjectByPointer(
        s_pUserObject,OBJ_KERNEL_HANDLE,
        NULL,GENERIC_WRITE,*IoFileObjectType,KernelMode,
        &s_hOutputKernelHandle
    );

如果我们跳过 GENERIC_WRITE 参数,上面的代码工作正常。但随后,在写入输出文本时,我们遇到了错误 STATUS_ACCESS_DENIED

// Write text output 
Status = ZwWriteFile(s_hOutputKernelHandle,
    NULL, NULL, NULL, &iosb, (PVOID)text, (ULONG)strlen(text), NULL, NULL);

所以我们无法打开句柄进行写入,因为不支持写入操作。如何使其工作,或者是否不可能?要回答这个问题,我们应该了解控制台是什么以及我们如何使用它。

首先,让我们检查我们正在传递给驱动程序的控制台句柄的对象名称。我们可以通过调用 NtQueryObject API,并将 ObjectNameInformation 请求作为 ObjectInformationClass 来完成。该请求返回一个类型为 OBJECT_NAME_INFORMATION 的结构。

typedef struct _OBJECT_NAME_INFORMATION {
    UNICODE_STRING Name;
} OBJECT_NAME_INFORMATION, *POBJECT_NAME_INFORMATION;

ULONG ObjectNameInformation = 1;

该结构包含一个 Unicode string,并且可以具有可变大小。这就是为什么我们应该调用 NtQueryObject API 两次。第一次用于检索所需大小,第二次用于填充已分配的结构。

HANDLE hHandle = GetStdHandle(STD_OUTPUT_HANDLE);
ULONG _size = 0;
NtQueryObject(hHandle, 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) {
    memset(text, 0x00, _size + 2);
    NtQueryObject(hHandle, ObjectNameInformation, text, _size, &_size);
    if (text->Name.Length > 0 && text->Name.Buffer) {
        wprintf(L"Console Object Name: \"%s\"\n", info ? info : L"", text->Name.Buffer);
    }
    free(text);
}

该 API 由 ntdll.dll 导出,在用户模式下可用,在内核中,可以使用 ZwQueryObjectObQueryNameString 函数。
代码执行结果如下

因此,我们收到的句柄是控制台驱动程序的句柄。在 Process Explorer 中,我们可以看到几个 ConDrv 对象的实例。

这些实例用于作为 stdinstdoutstderr 句柄与应用程序通信,另外三个用于与代理通信——它是控制台主机进程和应用程序之间的中间层。

用户模式下的控制台输出

如果我们查看任务管理器中的测试控制台应用程序,我们会看到两个不同的进程——一个是我们的应用程序,另一个是控制台主机窗口。

在创建控制台期间(可以为控制台应用程序自动完成,也可以通过 AllocConsole API 手动完成,正如我们上一篇文章中讨论的那样),会创建该子进程。我们正在向应用程序中的控制台句柄输出,并且指定的代理对象将消息发送到控制台进程并执行字符显示。

如果无法像常规文件一样将数据写入接收到的句柄,那么可以通过 IOCTL 与驱动程序通信,就像我们在驱动程序中传递句柄结构和启用输出一样。我们可以在内核模式和用户模式下进行。在内核中,我们有 ZwDeviceIoControlFile API。要尝试在用户模式下调用控制台驱动程序,我们有类似的 NtDeviceIoControlFile API,它从 ntdll.dll 库导出。

与控制台进行输入和输出的 IOCTL 是 IOCTL_CONDRV_ISSUE_USER_IO,其定义如下:

#define IOCTL_CONDRV_ISSUE_USER_IO \
    CTL_CODE(FILE_DEVICE_CONSOLE, 5, METHOD_OUT_DIRECT, FILE_ANY_ACCESS)

该 IOCTL 接受结构 CD_USER_DEFINED_IO 作为输入参数。

typedef struct _CD_USER_DEFINED_IO {
    HANDLE Client;
    ULONG InputCount;
    ULONG OutputCount;
    CD_IO_BUFFER Buffers[ANYSIZE_ARRAY];
} CD_USER_DEFINED_IO, *PCD_USER_DEFINED_IO;

这是描述输入和输出缓冲区列表的基本结构。我们必须分配该结构,其大小后跟 CD_IO_BUFFER 结构,该大小等于 InputCountOutputCount 字段的总和。

typedef struct _CD_IO_BUFFER {
    ULONG_PTR Size;
    PVOID Buffer;
} CD_IO_BUFFER, *PCD_IO_BUFFER;

首先是输入缓冲区,然后是输出缓冲区。每个缓冲区包含指向参数的数据指针和该参数的大小。我们有两个输入缓冲区:第一个描述我们传递给控制台驱动程序的消息结构,第二个是文本信息。在输出时,我们只引用接收输出到控制台的字符数的结构。

typedef struct _CONSOLE_MSG_HEADER {
    ULONG ApiNumber;
    ULONG ApiDescriptorSize;
} CONSOLE_MSG_HEADER, *PCONSOLE_MSG_HEADER;

#include <pshpack4.h>
typedef struct _CONSOLE_WRITECONSOLE_MSG {
    OUT ULONG NumRecords;
    IN BOOLEAN Unicode;
} CONSOLE_WRITECONSOLE_MSG, *PCONSOLE_WRITECONSOLE_MSG;
#include <poppack.h>

typedef struct _CONSOLE_MSG {
    CONSOLE_MSG_HEADER Header;
    CONSOLE_WRITECONSOLE_MSG Msg;
}CONSOLE_MSG, *PCONSOLE_MSG;

控制台消息包含头部和有效载荷。有效载荷有一个大的联合,但我们只对控制台写入文本操作感兴趣。头部有一个 ApiNumber 字段,它定义了与控制台的操作。对于文本输出,它应该设置为 API_NUMBER_WRITECONSOLE 值,定义如下:

#define API_NUMBER_WRITECONSOLE    0x01000006

有效载荷的 NumRecords 字段在输出时接收已处理字符的数量。
下一步是把所有东西都准备好并填充缓冲区。现在让我们看看如何实现它。

HMODULE hDll = LoadLibraryW(L"ntdll.dll");
// Console output with IOCTL
typedef NTSTATUS(NTAPI * PFN_NtDeviceIoControlFile) (
    HANDLE, HANDLE, PIO_APC_ROUTINE, PVOID,
    PIO_STATUS_BLOCK, ULONG, PVOID, ULONG, PVOID, ULONG);

PFN_NtDeviceIoControlFile NtDeviceIoControlFile = 
    (PFN_NtDeviceIoControlFile)GetProcAddress(hDll, "NtDeviceIoControlFile");

在上面的代码中,我们加载 ntdll 库并初始化导出的 API。

CHAR text[] = "This text will be output into console with direct IOCTL\n";
// Total size of the data: CD_USER_DEFINED_IO with two additional CD_IO_BUFFER 
size_t size = sizeof(CD_USER_DEFINED_IO) + 2 * sizeof(CD_IO_BUFFER);
PCD_USER_DEFINED_IO buffer = (PCD_USER_DEFINED_IO)malloc(size);
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);

我们分配了一个包含 CD_USER_DEFINED_IO 和一个嵌入式 CD_IO_BUFFER 的缓冲区,并额外分配了两个 CD_IO_BUFFER 结构,因为我们有两个输入缓冲区和一个输出缓冲区。现在准备结构并填充这些缓冲区。

IO_STATUS_BLOCK iosb = { 0 };
memset(buffer, 0x00, size);
// Initialize message structure
CONSOLE_MSG msg = { API_NUMBER_WRITECONSOLE, sizeof(CONSOLE_WRITECONSOLE_MSG), 0, 0 };
// We not use it 
buffer->Client = NULL;
// Two Input buffers
buffer->InputCount = 2;
// One Output
buffer->OutputCount = 1;
// First Input Message Structure
buffer->Buffers[0].Buffer = &msg;
buffer->Buffers[0].Size = sizeof(msg);
// Second Buffer of the text string
buffer->Buffers[1].Buffer = (PVOID)text;
buffer->Buffers[1].Size = strlen(text);
// The Output resulted number of characters
buffer->Buffers[2].Buffer = &msg.Msg;
buffer->Buffers[2].Size = sizeof(msg.Msg);
// Call API
NTSTATUS Status = NtDeviceIoControlFile(handle,
    NULL, NULL, NULL, &iosb, IOCTL_CONDRV_ISSUE_USER_IO, buffer, (ULONG)size, NULL, 0);

free(buffer);

代码执行结果如下

您可以在断点处检查,在调用 NtDeviceIoControlFile API 后,输出缓冲区的 NumRecords 字段与我们指定为输入参数的文本长度相同,这意味着整个 string 已输出到控制台缓冲区。

.NET 实现稍微困难一些,因为它需要正确初始化所有指针。首先,定义所需的结构类型。在 CD_USER_DEFINED_IO C# 封装器中,我们没有指定 CD_IO_BUFFER 数组,该数组后跟 OutputCount 字段,因为我们将手动写入这些值。

[StructLayout(LayoutKind.Sequential, Pack = 0)]
class CD_USER_DEFINED_IO
{
    public IntPtr Client;
    [MarshalAs(UnmanagedType.U4)]
    public int InputCount;
    [MarshalAs(UnmanagedType.U4)]
    public int OutputCount;
}

所有其他结构定义如下

[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 8)]
class CONSOLE_MSG_HEADER
{
    [MarshalAs(UnmanagedType.U4)]
    public int ApiNumber;
    [MarshalAs(UnmanagedType.U4)]
    public int ApiDescriptorSize;

    public CONSOLE_MSG_HEADER(int ApiNumber, int ApiDescriptorSize)
    {
        this.ApiNumber = ApiNumber;
        this.ApiDescriptorSize = ApiDescriptorSize;
    }
}

[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 8)]
class CONSOLE_WRITECONSOLE_MSG
{
    [MarshalAs(UnmanagedType.U4)]
    public int NumRecords;
    [MarshalAs(UnmanagedType.Bool)]
    public bool Unicode;

    public CONSOLE_WRITECONSOLE_MSG(int NumRecords, bool Unicode)
    {
        this.NumRecords = NumRecords;
        this.Unicode = Unicode;
    }
}

[StructLayout(LayoutKind.Sequential)]
class CONSOLE_MSG
{
    public CONSOLE_MSG_HEADER Header;
    public CONSOLE_WRITECONSOLE_MSG Msg;

    public CONSOLE_MSG
    (int ApiNumber, int ApiDescriptorSize, int NumRecords, bool Unicode)
    {
        Header = new CONSOLE_MSG_HEADER(ApiNumber, ApiDescriptorSize);
        Msg = new CONSOLE_WRITECONSOLE_MSG(NumRecords, Unicode);
    }
}

用数据填充结构。

// Prepare Structures 
CONSOLE_MSG msg = new CONSOLE_MSG(API_NUMBER_WRITECONSOLE,
                  Marshal.SizeOf(typeof(CONSOLE_WRITECONSOLE_MSG)), 0, false);

CD_USER_DEFINED_IO buffer = new CD_USER_DEFINED_IO();
buffer.Client = IntPtr.Zero;
buffer.InputCount = 2;
buffer.OutputCount = 1;

下一步是分配所需的指针并填充缓冲区。

// We need 4 pointers
IntPtr[] ptr = new IntPtr[4];
// Here is the sizes of allocated memory for quick access
int[] Sizes = new int[] { StructureSize, Marshal.SizeOf(msg),
    text.Length, Marshal.SizeOf(msg.Msg) };
// Allocate memory
ptr[0] = Marshal.AllocHGlobal(Sizes[0]);
ptr[1] = Marshal.AllocHGlobal(Sizes[1]);
ptr[2] = Marshal.StringToHGlobalAnsi(text);
ptr[3] = Marshal.AllocHGlobal(Sizes[3]);

// Setup Pointers
Marshal.StructureToPtr(buffer, ptr[0], false);
Marshal.StructureToPtr(msg, ptr[1], false);
Marshal.StructureToPtr(msg.Msg, ptr[3], false);

IntPtr p = ptr[0] + StructureSize - 3 * IoBufferSize;
for (int i = 0; i < 3; i++)
{
    Marshal.WriteIntPtr(p, (IntPtr)Sizes[i + 1]);
    Marshal.WriteIntPtr(p + IntPtr.Size, ptr[i + 1]);
    p += IoBufferSize;
}

在上面的代码中,我们为容纳 3 个 CD_IO_BUFFER 结构分配了额外的空间。在我们将 CD_USER_DEFINED_IO 复制到指针后,我们将其移动到末尾,并通过为每个字段调用 Marshal.WriteIntPtr 来手动填充 CD_IO_BUFFER 结构。所以现在我们准备好调用 NtDeviceIoControlFile API。

// Status
IO_STATUS_BLOCK iosb = new IO_STATUS_BLOCK();
// Console
IntPtr handle = GetStdHandle(STD_OUTPUT_HANDLE);
        
// Write Output
int Status = NtDeviceIoControlFile(handle,
    IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 
    iosb, IOCTL_CONDRV_ISSUE_USER_IO, ptr[0], StructureSize, IntPtr.Zero, 0);

Marshal.PtrToStructure(ptr[3], msg.Msg);

// msg.Msg.NumRecords - contains number of characters output
// same as iosb.Information

C# 中的 NtDeviceIoControlFile API 具有以下声明

[DllImport("ntdll.dll")]
[return: MarshalAs(UnmanagedType.U4)]
static extern int NtDeviceIoControlFile(
    [In] IntPtr FileHandle,
    [In, Optional] IntPtr Event,
    [In, Optional] IntPtr ApcRoutine,
    [In, Optional] IntPtr ApcContext,
    [Out, MarshalAs(UnmanagedType.LPStruct)] IO_STATUS_BLOCK IoStatusBlock,
    [In, MarshalAs(UnmanagedType.U4)] int IoControlCode,
    [In] IntPtr InputBuffer,
    [In, MarshalAs(UnmanagedType.U4)] int InputBufferLength,
    [In] IntPtr OutputBuffer,
    [In, MarshalAs(UnmanagedType.U4)] int OutputBufferLength
    );

执行结果与 C++ 实现相同。
如果您想了解更多控制台内部信息,可以查看 GitHub 上的 Microsoft terminal 项目。

内核中的控制台输出实现

是的,使用 IOCTL 的控制台输出正在工作,现在是时候在内核中尝试它了。在内核模式下,我们已经发现我们可以不指定 GENERIC_WRITE 标志来打开对象,并且为了向控制台窗口写入文本,我们可以调用 ZwDeviceIoControlFile API。代码将类似于用户模式实现。

size_t size = sizeof(CD_USER_DEFINED_IO) + 2 * sizeof(CD_IO_BUFFER);
PCD_USER_DEFINED_IO buffer = (PCD_USER_DEFINED_IO)ExAllocatePool(NonPagedPool, size);

if (buffer) {
    memset(buffer, 0x00, size);
    // Prepare console arguments
    CONSOLE_MSG msg = { API_NUMBER_WRITECONSOLE, 
                        sizeof(CONSOLE_WRITECONSOLE_MSG), 0, 0 };

    buffer->Client = NULL;
    buffer->InputCount = 2;
    buffer->OutputCount = 1;
    buffer->Buffers[0].Buffer = &msg;
    buffer->Buffers[0].Size = sizeof(msg);
    buffer->Buffers[1].Buffer = (PVOID)text;
    buffer->Buffers[1].Size = strlen(text);
    buffer->Buffers[2].Buffer = &msg.Msg;
    buffer->Buffers[2].Size = sizeof(msg.Msg);
    // Call console output
    Status = ZwDeviceIoControlFile(s_hOutputKernelHandle,
        NULL, NULL, NULL, &iosb, IOCTL_CONDRV_ISSUE_USER_IO, 
                    buffer, (ULONG)size, NULL, 0);

    ExFreePool(buffer);
}
else {
    Status = STATUS_NO_MEMORY;
}

在我们将代码集成到驱动程序中并传递控制台句柄后,我们在测试应用程序中获得了结果。

识别句柄类型

由于我们对所有句柄类型都保持通用实现,并且传递的句柄可以是管道、文件或控制台,因此需要一种方法来识别它们,并根据我们得到的对象类型,使用不同的输出方法。可以通过几种方式识别我们拥有的对象,并根据我们拥有的类型,调用写入函数或设备控制函数。在这种情况下,简单的方法是如果我们无法使用 GENERIC_WRITE 标志打开对象,则将其用作控制台,否则以常规方式写入输出。但让我们检查识别此类对象的其他方法。

其中一种方法我们已经知道——通过使用 NtQueryObject API 检索对象的名称。对于控制台,我们已经知道它返回什么,只需查看我们从其他对象类型中获得的内容。

并非所有对象都安全地使用 NtQueryObject API 来获取对象名称,因为如果管道被锁定等待数据,此 API 可能会在用户模式下导致管道对象挂起。通过设计自己的应用程序,您可以处理这个问题,但如果您的应用程序正在检查另一个进程的句柄,您应该意识到这一点。这就是为什么使用 NtQueryObject API 检索对象名称没有公开文档的原因。在内核模式下,此类问题不会出现,因为我们可以直接访问对象头结构。

您可能知道用于检索文件名的函数 NtQueryInformationFile。在该函数中,有一个文件名请求:FileNameInformation。该函数填充预分配的 FILE_NAME_INFORMATION 类型的结构。

typedef struct _FILE_NAME_INFORMATION {
    ULONG FileNameLength;
    WCHAR FileName[1];
} FILE_NAME_INFORMATION, *PFILE_NAME_INFORMATION;

FILE_INFORMATION_CLASS FileNameInformation = (FILE_INFORMATION_CLASS)9;

用法原型是

HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
IO_STATUS_BLOCK iosb = { 0 };

ULONG _size = 1024 + sizeof(FILE_NAME_INFORMATION);
PFILE_NAME_INFORMATION information = (PFILE_NAME_INFORMATION)malloc(_size);
if (information) {
    memset(information, 0x00, _size);
    NtQueryInformationFile(hHandle, &iosb, information, _size, FileNameInformation);
    if (information->FileNameLength) {
        wprintf(L"Console Object Name: \"%s\"\n", info ? info : L"", 
                information->FileName);
    }
    free(information);
}

如果我们运行此类请求来比较我们从上一个代码示例中获得的结果,我们将收到以下输出:

C# 中相同代码的实现方式如下:

int size = 1024 + Marshal.SizeOf<FILE_NAME_INFORMATION>();
IntPtr p = Marshal.AllocCoTaskMem(size);

IO_STATUS_BLOCK iosb = new IO_STATUS_BLOCK();
if (p != IntPtr.Zero)
{
    try
    {
        // Clear Memory as Marshal.Alloc not doing so
        ZeroMemory(p, size);
        // Request Information Block
        NtQueryInformationFile(handle, iosb,
                    p, size, FileNameInformation);
        // Length in bytes
        int FileNameLength = Marshal.ReadInt32(p);
        if (FileNameLength > 0 && FileNameLength < size - 4)
        {
            string FileName = Marshal.PtrToStringUni(p + 4, (FileNameLength >> 1));
            Console.WriteLine("{0} FileInformation: \"{1}\"", info, FileName);
        }
    }
    finally
    {
        Marshal.FreeCoTaskMem(p);
    }
}

函数 NtQueryInformationFile 以及请求对象名称的 NtQueryObject 用于 GetFinalPathNameByHandle API,该 API 通过给定句柄构建文件的完整路径。并且该函数比 NtQueryObject 更安全,不会导致挂起。

正如我们所看到的,NtQueryInformationFile 函数只适用于文件。因此,它不适合在我们的情况下使用。

另一种方法是通过句柄获取设备的类型。设备类型的定义可以在 winioctl.h 头文件中找到,它们以 FILE_DEVICE_* 开头。

//...
#define FILE_DEVICE_VMBUS               0x0000003E
#define FILE_DEVICE_CRYPT_PROVIDER      0x0000003F
#define FILE_DEVICE_WPD                 0x00000040
#define FILE_DEVICE_BLUETOOTH           0x00000041
#define FILE_DEVICE_MT_COMPOSITE        0x00000042
#define FILE_DEVICE_MT_TRANSPORT        0x00000043
#define FILE_DEVICE_BIOMETRIC           0x00000044
#define FILE_DEVICE_PMI                 0x00000045
#define FILE_DEVICE_EHSTOR              0x00000046
#define FILE_DEVICE_DEVAPI              0x00000047
#define FILE_DEVICE_GPIO                0x00000048
#define FILE_DEVICE_USBEX               0x00000049
#define FILE_DEVICE_CONSOLE             0x00000050
#define FILE_DEVICE_NFP                 0x00000051
#define FILE_DEVICE_SYSENV              0x00000052
#define FILE_DEVICE_VIRTUAL_BLOCK       0x00000053
#define FILE_DEVICE_POINT_OF_SERVICE    0x00000054
#define FILE_DEVICE_STORAGE_REPLICATION 0x00000055
#define FILE_DEVICE_TRUST_ENV           0x00000056
//...

根据我们获得的设备类型值,我们调用 IOCTL 或文件写入函数。为了检索设备类型,我们可以使用 NtQueryVolumeInformationFile API。此函数从 ntdll 库导出,用于用户模式应用程序。在内核模式下,使用相同的 API,但带有 Zw 前缀:ZwQueryVolumeInformationFile。我们应该使用 FileFsDeviceInformation 请求调用此函数。在该请求上,NtQueryVolumeInformationFile API 会填充传递的 FILE_FS_DEVICE_INFORMATION 结构。

typedef struct _FILE_FS_DEVICE_INFORMATION {
    DEVICE_TYPE DeviceType;
    ULONG       Characteristics;
} FILE_FS_DEVICE_INFORMATION, *PFILE_FS_DEVICE_INFORMATION;

ULONG FileFsDeviceInformation = 4;

我们对上述结构的 DeviceType 字段感兴趣。下面是调用此 API 以获取控制台句柄的代码示例。

FILE_FS_DEVICE_INFORMATION console_info = { 0 };
IO_STATUS_BLOCK iosb = { 0 };
handle = GetStdHandle(STD_OUTPUT_HANDLE);
NtQueryVolumeInformationFile(handle, &iosb, 
    &console_info, sizeof(console_info), FileFsDeviceInformation);

不同对象句柄的输出类型显示在下一个截图中。

如果您查找这些定义名称,您会发现这些值分别是 FILE_DEVICE_DISKFILE_DEVICE_NAMED_PIPEFILE_DEVICE_CONSOLE。因此,在驱动程序代码中,我们也可以根据这些信息设置处理方法。

在 .NET 中,我们也能够实现上述代码。

int Size = Marshal.SizeOf(typeof(FILE_FS_DEVICE_INFORMATION));
IntPtr ptr = Marshal.AllocCoTaskMem(Size);
IO_STATUS_BLOCK iosb = new IO_STATUS_BLOCK();

IntPtr handle = GetStdHandle(STD_OUTPUT_HANDLE);
NtQueryVolumeInformationFile(handle, iosb, ptr, Size, FileFsDeviceInformation);
Marshal.PtrToStructure(ptr, console_info);

我们分配了一个大小为 FILE_FS_DEVICE_INFORMATION 结构的指针,并将此指针作为参数传递给 NtQueryVolumeInformationFile API。函数返回后,我们将指针数据转换为实际的结构变量。

控制台实现中的进程空间问题

因此,我们从驱动程序获得了进程控制台的输出。这非常酷,但我们在从应用程序接收到 IOCTL 后立即将文本输出到控制台。这意味着是在与应用程序空间相同的线程上进行的。但是如果我们从 DbgPrint 回调中调用了我们的 API 呢?在这种情况下,我们没有看到任何输出,并且在执行 ZwDeviceIoControlFile API 后我们得到的错误代码是 STATUS_NOT_SUPPORTED。结果显示在下一个截图中。

切换到主机进程空间

有两种方法可以解决上述情况。第一种是通常的简单方法。在通过 IOCTL 调用控制台驱动程序时,我们应该将线程上下文切换到我们的应用程序进程。

这可以通过 KeStackAttachProcess API 完成。

KAPC_STATE State = { 0 };
// Switch to Target Console Process 
KeStackAttachProcess(s_Process, &State);

但是,在此之前,在设置句柄时,我们应该保存进程结构以便在该函数中使用它。s_Process 变量的类型为 PEPROCESS

if (NT_SUCCESS(Irp->IoStatus.Status)) {
    s_Process = PsGetCurrentProcess();
}

并在之后立即通过 KeUnstackDetachProcess API 将上下文切换回来。

// Switch Process Back  
KeUnstackDetachProcess(&State);

附加到另一个进程地址空间的代码块应该非常简单。尽管 Microsoft 不建议在附加上下文块中调用任何驱动程序,但我们正在调用在该进程中创建的控制台对象。
您可以在下一张截图中看到解决方案的结果。

无论如何,如果您害怕使用这些 API,那么您可以尝试另一种方法来解决所述问题。

在内核中创建主机进程空间线程

第二种解决方案最为复杂,但它展示了系统更有趣的内部结构。对于该解决方案,我们可以创建一个内核线程,该线程将在主机进程下执行。听起来不可能?如果我们查看用于创建线程的内核函数 PsCreateSystemThread,它包含一个 ProcessHandle 输入参数,您可以在其中指定主机进程句柄,并且线程将在该进程空间中创建。当驱动程序接收到 IOCTL 以启用用户句柄时,我们将创建该线程。

HANDLE hThread;
KeResetEvent(&s_EvQuit);
// Start Output Thread
Status = PsCreateSystemThread(&hThread, 0, NULL, 
         ZwCurrentProcess(), NULL, ConsoleHandlerThread, NULL);
if (NT_SUCCESS(Status)) {
    Status = ObReferenceObjectByHandle(hThread, GENERIC_READ | GENERIC_WRITE,
        NULL, KernelMode, (PVOID *)&s_pThreadObject, NULL);
}
if (!NT_SUCCESS(Status)) {
    // Set drop event once we have error
    KeSetEvent(&s_EvQuit, IO_NO_INCREMENT, FALSE);
}

一旦 IOCTL 禁用该句柄,我们将关闭该线程。当指定的事件被设置为信号状态时,线程将退出。一旦该事件被信号,我们将等待线程退出。

KeSetEvent(&s_EvQuit, IO_NO_INCREMENT, FALSE);
PFILE_OBJECT pThread = NULL;
if (STATUS_SUCCESS ==
    KeWaitForSingleObject(&s_LockUserHandle, Executive, KernelMode, FALSE, NULL)) {
    pThread = s_pThreadObject;
    s_pThreadObject = NULL;
    KeReleaseMutex(&s_LockUserHandle, FALSE);
}
if (pThread) {
    KeWaitForSingleObject(pThread, Executive, KernelMode, FALSE, NULL);
    ObDereferenceObject(pThread);
}

现在我们需要准备处理文本输出的能力。我们将文本消息放入列表中,并向指定的事件发送信号,以便线程开始处理列表中的消息。

size_t cch = strlen(text) + 1;
PIO_OUTPUT_TEXT item = (PIO_OUTPUT_TEXT)ExAllocatePool
                       (NonPagedPool, sizeof(IO_OUTPUT_TEXT));
if (item) {
    memset(item, 0x00, sizeof(IO_OUTPUT_TEXT));
    // Put text into Thread Queue
    // Wait For List Mutex 
    if (STATUS_SUCCESS == (Status = KeWaitForSingleObject(
            &s_ListLock, Executive, KernelMode, FALSE,
            PASSIVE_LEVEL != KeGetCurrentIrql() ? &time_out : NULL))) {
        item->Text = (CHAR*)ExAllocatePool(NonPagedPool, cch);
        memcpy(item->Text, text, cch);
        // Insert entry into the list
        InsertTailList(&s_List, &(item->Entry));
        // Notify that we have some data
        KeSetEvent(&s_EvHaveData, IO_NO_INCREMENT, FALSE);
        KeReleaseMutex(&s_ListLock, FALSE);
    }
    else {
        ExFreePool(item);
    }
}

当 IRQL 级别执行高于被动级别时,或者调用进程的 PID 不等于目标进程 PID 时,会执行调用线程。当前实现中的结构 IO_OUTPUT_TEXT 包含用于将元素放入列表的 Entry 字段。

typedef struct
{
    // List Entry
    LIST_ENTRY        Entry;
    // Text For Output
    CHAR          *   Text;
}IO_OUTPUT_TEXT,*PIO_OUTPUT_TEXT;

实际的线程代码看起来像

// Console Thread Function
VOID ConsoleHandlerThread(PVOID Context)
{
    PAGED_CODE();
    UNREFERENCED_PARAMETER(Context);
    PVOID hEvents[2] = { 0 };
    hEvents[0] = &s_EvHaveData;
    hEvents[1] = &s_EvQuit;

    while (TRUE) {
        // Wait For Event Of Quit Or Data Arriving
        NTSTATUS Status = KeWaitForMultipleObjects(2, hEvents, 
            WaitAny, Executive, KernelMode, FALSE, NULL, NULL);
        if (Status == STATUS_WAIT_0) {
            while (TRUE) {
                PIO_OUTPUT_TEXT item = NULL;
                // Lock List Mutex
                if (STATUS_SUCCESS == KeWaitForSingleObject(&s_ListLock, 
                                        Executive, KernelMode, FALSE, 0)) {
                    if (!IsListEmpty(&s_List)) {
                        // Extract record from start of the list 
                        PLIST_ENTRY entry = s_List.Flink;
                        if (entry) {
                            item = CONTAINING_RECORD(entry, IO_OUTPUT_TEXT, Entry);
                            RemoveEntryList(entry);
                        }
                    }
                    if (!item) {
                        // Reset data fag if no records 
                        // as we have manual reset event
                        KeResetEvent(&s_EvHaveData);
                    }
                    KeReleaseMutex(&s_ListLock, FALSE);
                }
                if (!item) break;
                if (item->Text) {
                    // Actual Output Writing
                    WriteUserHandleOutputText(item->Text);
                    ExFreePool(item->Text);
                }
                ExFreePool(item);
            }
        } else {
            break;
        }
    }
    // Just mark that we are done
    KeSetEvent(&s_EvQuit, IO_NO_INCREMENT, FALSE);
    PsTerminateSystemThread(STATUS_SUCCESS);
}

现在我们可以启动测试应用程序并查看结果。

用户空间线程导致的应用程序锁定问题

带额外内核线程的应用程序也运行良好,如果您打开进程资源管理器工具,您可以看到一个额外的线程,其起始地址为 drv2.sys!ConsoleHandlerThread。一旦您暂停执行,您将无法在 Visual Studio 线程窗口中看到该线程。更重要的是:该线程无法从进程资源管理器中关闭。因此,如果我们的应用程序意外退出并且没有调用 IOCTL 来禁用句柄,那么应用程序将挂起,因为内核中的进程线程仍将处于活动状态。您可以通过示例应用程序重现这种情况,在调用 IOCTL 启用句柄后立即退出主线程。“quit”命令行参数可用于此目的。

如您在图中所示,应用程序不包含任何用户模式线程,但它仍在运行。而且它无法通过任务管理器或进程资源管理器关闭。
为了避免这种情况,我们可以在应用程序中添加额外的 IOCTL。

// IOCTL Fallback For quit if hang happening
#define IOCTL_DRIVER_SHUTDOWN_OUTPUT        \
        CTL_CODE( FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS )

通过接收此 IOCTL,驱动程序将停止该线程(如果它存在)。

case IOCTL_DRIVER_SHUTDOWN_OUTPUT:
    CloseUserOutputHandle();
    break;

在应用程序中,我们通过额外的命令行参数“shutdown”来控制这一点。

// Shutdown fallback
if (argc > 1 && _stricmp(argv[1],"shutdown") == 0 ) {

    if (DeviceIoControl(hDevice,IOCTL_DRIVER_SHUTDOWN_OUTPUT,
        NULL, 0, NULL,0, &dwBytesReturned,NULL) == 0) {
        _tprintf(_T("DeviceIOControl Failed %d\n"),GetLastError());
    }
    CloseHandle(hDevice);
    return 0;
}

C# 实现也有相同的参数的相同处理程序。

跟踪进程线程的启动和停止

进程启动和停止回调在前面描述的情况下不起作用,因为我们的进程由于未完成的线程而未退出。但在系统中,有一个 API 可以设置回调来跟踪线程的启动或停止。其实现与以前的回调初始化函数及其参数不同。在此回调函数的情况下,我们有两个不同的 API 来启用和禁用回调。

要启用回调,我们应该启动 PsSetCreateThreadNotifyRoutine API 并传递回调函数地址。

// Set Thread Callback
PsSetCreateThreadNotifyRoutine(CreateThreadNotifyRoutine);

我们在 DriverEntry 实现中启用回调。要禁用我们的回调,我们应该使用 PsRemoveCreateThreadNotifyRoutine API 并传递我们要禁用的相同回调地址。实际的回调函数如下所示。

EXTERN_C VOID CreateThreadNotifyRoutine(HANDLE ProcessId, 
                                        HANDLE ThreadId, BOOLEAN Create) {
    UNREFERENCED_PARAMETER(ThreadId);
    PAGED_CODE();
    DbgPrint("Thread %lld in Process: %lld %s\n", 
        ThreadId, ProcessId, Create ? "Started" : "Exits");
}

作为参数,我们接收进程 ID、线程 ID 和一个布尔标志,该标志表示线程已创建或退出。我们添加 DbgPrint 调用来跟踪该回调的工作方式。

在实际的回调中,我们需要一个功能来跟踪我们进程的线程数,然后启动附加线程,然后只有一个内核线程保持活动状态——然后关闭它以执行适当的应用程序退出。让我们为此扩展线程回调功能。

BOOLEAN bClose = FALSE;
if (STATUS_SUCCESS ==
    KeWaitForSingleObject(&s_LockUserHandle, Executive, KernelMode, FALSE, NULL)) {
    // Our Process Threads Changed
    if (s_pThreadObject && ProcessId && s_hUserPID == ProcessId) {
        if (!Create) {
            if (1 >= --s_nThreadsCount) {
                bClose = TRUE;
            }
        }
        else {
            s_nThreadsCount++;
        }
    }
    KeReleaseMutex(&s_LockUserHandle, FALSE);
}
// Finally close user handle
if (bClose) {
    CloseUserOutputHandle();
}

在函数中,我们看到我们只检查指定进程的线程。如果线程创建,我们增加线程计数器,然后它退出,我们减少计数器。

获取进程的线程数

好的,为了正确实现上述功能,我们需要在 IOCTL 中启用句柄输出时,获取进程的初始线程数,即代码中的变量 s_nThreadsCount。我们知道用户模式下的工具助手库 API 可以实现这一点,但在内核中不可用。那么是否有我们可以使用的其他 API 呢?是的,用户模式应用程序的函数是 NtQuerySystemInformation,内核模式的函数是 ZwQuerySystemInformation。我们应该以 SYSTEM_INFORMATION_CLASS 的形式请求 SystemProcessInformation。成功时,函数会用 SYSTEM_PROCESS_INFORMATION 结构填充用户分配的缓冲区。否则,如果失败,错误代码为 STATUS_INFO_LENGTH_MISMATCH,函数会设置需要分配的字节数以填充所有信息。

NTSTATUS Status;
ULONG Length = 0x10000;
PVOID p = NULL;
// Allocate memory 
while (TRUE) {
    Status = STATUS_NO_MEMORY;
    ULONG Size = Length;
    p = realloc(p,Size);
    if (p) {
        Status = NtQuerySystemInformation(SystemProcessInformation,p,Size,&Length);
        if (Status == STATUS_INFO_LENGTH_MISMATCH) {
            // Align Memory
            Length = (Length + 0x1FFF) & 0xFFFFE000;
            continue;
        }
    }
    break;
}

一旦我们获得结构,我们就可以通过将结构指针移动给定结构 NextEntryOffset 字段中指定的字节数来枚举它们。如果该值为零,则我们拥有最后一个条目。在用户模式应用程序中显示进程和线程数的代码示例。

SYSTEM_PROCESS_INFORMATION * pi = (SYSTEM_PROCESS_INFORMATION *)p;
UCHAR * end = (UCHAR*)p + Length;
// Check each process informatino structure
while (pi && (UCHAR*)pi < end) {
    WCHAR temp[512] = {0};
    memset(temp,0x00,sizeof(temp));
    Length = pi->ImageName.Length;
    // Copy FileName
    if (pi->ImageName.Buffer && Length) {
        if (Length > sizeof(temp) - 2) {
            Length = sizeof(temp) - 2;
        }
        memcpy_s(temp,sizeof(temp),pi->ImageName.Buffer,Length);
    }
    // Print Output Process Information
    wprintf(L"Process [%d]\t'%s' Threads: %d\n", 
        HandleToUlong(pi->UniqueProcessId),temp, pi->NumberOfThreads);
    // Last entry 
    if (!pi->NextEntryOffset) {
        break;
    }
    // Shift to next structure
    pi = (SYSTEM_PROCESS_INFORMATION *)(((UCHAR*)pi) + pi->NextEntryOffset);
}

您可以在下一张图中看到代码执行的结果。

同样,我们也可以在 C# 中通过封装 NtQuerySystemInformation 函数和 SYSTEM_PROCESS_INFORMATION 结构来实现。首先,我们分配内存。

int Status;
int Length = 0x10000;
IntPtr p = IntPtr.Zero;
// Allocate memory 
while (true)
{
    Status = STATUS_NO_MEMORY;
    int Size = Length;
    p = Marshal.ReAllocCoTaskMem(p, Size);
    if (p != IntPtr.Zero)
    {
        ZeroMemory(p, Size);
        Status = NtQuerySystemInformation(SystemProcessInformation, p, Size, out Length);
        if (Status == STATUS_INFO_LENGTH_MISMATCH)
        {
            // Align Memory
            Length = (int)(((long)Length + 0x1FFF) & 0xFFFFE000);
            continue;
        }
    }
    break;
}

然后,遍历结果进程信息结构。

IntPtr pi = p;
IntPtr end = p + Length;
// Check each process informatino structure
while (pi != IntPtr.Zero && pi.ToInt64() < end.ToInt64())
{
    var info = Marshal.PtrToStructure<SYSTEM_PROCESS_INFORMATION>(pi);
    string temp = "";
    // Copy FileName
    if (info.ImageName.Length > 0 
        && info.ImageName.Length < info.ImageName.MaximumLength)
    {
        temp = info.ImageName.Buffer;
    }
    // Print Output Process Information
    Console.WriteLine("Process [{0}]\t'{1}' Threads: {2}",
        info.UniqueProcessId.ToInt32(), temp, info.NumberOfThreads);
    // Last entry 
    if (info.NextEntryOffset == 0)
    {
        break;
    }
    // Shift to next structure
    pi = pi + info.NextEntryOffset;
}

一旦我们运行代码,结果与 C++ 示例相同。
所以现在我们准备将其集成到驱动程序代码中并初始化上面提到的 s_nThreadsCount 变量。

NTSTATUS Status = STATUS_SUCCESS;
typedef NTSTATUS(NTAPI * PFN_ZwQuerySystemInformation)(ULONG, PVOID, ULONG, PULONG);
UNICODE_STRING Name = { 0 };
RtlInitUnicodeString(&Name, L"ZwQuerySystemInformation");
PFN_ZwQuerySystemInformation ZwQuerySystemInformation = 
            (PFN_ZwQuerySystemInformation)MmGetSystemRoutineAddress(&Name);
if (ZwQuerySystemInformation) {
    ULONG Length = 0x10000;
    PVOID p = NULL;
    while (TRUE) {
        ULONG Size = Length;
        p = ExAllocatePool(NonPagedPool, Size);
        if (p) {
            Status = ZwQuerySystemInformation
                     (SystemProcessInformation, p, Size, &Length);
            if (Status != STATUS_INFO_LENGTH_MISMATCH) {
                break;
            }
            ExFreePool(p);
            p = NULL;
            Length = (Length + 0x1FFF) & 0xFFFFE000;
        }
        else {
            Status = STATUS_NO_MEMORY;
            break;
        }
    }
    if (NT_SUCCESS(Status)) {
        Status = STATUS_NOT_FOUND;
        SYSTEM_PROCESS_INFORMATION * pi = (SYSTEM_PROCESS_INFORMATION *)p;
        UCHAR * end = (UCHAR *)p + Length;
        while (pi && (UCHAR *)pi < end) {
            if (pi->UniqueProcessId == hPID) {
                nCount = pi->NumberOfThreads;
                break;
            }
            pi = (SYSTEM_PROCESS_INFORMATION *)(((UCHAR *)pi) + pi->NextEntryOffset);
            if (!pi->NextEntryOffset) {
                break;
            }
        }
    }
    if (p) {
        ExFreePool(p);
    }
}

我们使用 MmGetSystemRoutineAddress API 动态获取 ZwQuerySystemInformation 函数指针。我们将该实现放入单独的函数中。并调用它在 IOCTL 处理程序上初始化线程数变量。

s_nThreadsCount = GetProcessThreadsCount(pid);

经过这些更改后,我们可以看到驱动程序正确处理了意外的应用程序退出并关闭了内部内核线程。

代码示例

这部分的代码可以下载。如果您想尝试这部分的示例驱动程序,可以从源代码编译。它配置为使用 Visual Studio 中的 WDK 工具集进行编译。在构建过程中,它会创建测试证书 drvN.cer 并对驱动程序进行签名。为了能够使用驱动程序,您需要在您的 PC 上安装该证书并启用系统的测试模式,或禁用驱动程序签名检查。驱动程序测试应用程序只能从管理员身份运行,因为它加载和卸载驱动程序需要管理员权限。

历史

  • 2023年7月5日:初始版本
© . All rights reserved.