用Rust编写简单win驱动程序


Rust 语言生态系统每天都在发展,其受欢迎程度也日益提高,这是有原因的。它是唯一一种在编译时提供内存和并发安全性的主流语言,具有强大而丰富的构建系统 (cargo) 和越来越多的软件包 (crates)。

我每天使用的驱动程序仍然是 C++,因为我的大部分工作都是关于低级系统和内核编程,其中 Windows C 和 COM API 很容易使用。然而,Rust 是一种系统编程语言,这意味着它可以在与 C/C++ 相同的环境中运行,或者至少可以运行。

主要的障碍是将 C 类型转换为 Rust 时需要冗长。这种“冗长”可以通过适当的包装器和宏来缓解。

我决定尝试编写一个并非无用的简单 WDM 驱动程序——它是我在我的书(Windows 内核编程)中演示的“Booster”驱动程序的 Rust 版本,它允许将任何线程的优先级更改为任何值。

入门
要准备构建驱动程序,请查阅Windows Drivers-rs,但基本上你应该安装 WDK(普通或EWDK)。此外,文档要求安装L​​LVM,才能访问Clang编译器。如果您想自己尝试以下步骤,我假设您已经安装了这些。

我们可以从创建一个新的 Rust 库项目开始(因为驱动程序在技术上是一个加载到内核空间的 DLL):

cargo new --lib booster

我们可以在 VS Code 中打开 booster 文件夹,然后开始编码。首先,需要做一些准备工作,以便实际代码能够成功编译和链接。我们需要一个build.rs文件来告诉 Cargo 静态链接到 CRT。将build.rs文件添加到根 booster 文件夹,其中包含以下代码:

fn main() -> Result<(), wdk_build::ConfigError> {
    std::env::set_var("CARGO_CFG_TARGET_FEATURE", "crt-static");
    wdk_build::configure_wdk_binary_build()
}

(语法高亮不完善,因为我使用的 WordPress 编辑器不支持 Rust 的语法高亮)

接下来,我们需要编辑cargo.toml并添加各种依赖项。以下是我能做到的最低限度:

[package]
name = "booster"
version =
"0.1.0"
edition =
"2021"
 
[package.metadata.wdk.driver-model]
driver-type =
"WDM"
 
[lib]
crate-type = [
"cdylib"]
test = false
 
[build-dependencies]
wdk-build =
"0.3.0"
 
[dependencies]
wdk =
"0.3.0"       
wdk-macros =
"0.3.0"
wdk-alloc =
"0.3.0" 
wdk-panic =
"0.3.0" 
wdk-sys =
"0.3.0"   
 
[features]
default = []
nightly = [
"wdk/nightly", "wdk-sys/nightly"]
 
[profile.dev]
panic =
"abort"
lto = true
 
[profile.release]
panic =
"abort"
lto = true

重要的部分是 WDK 包依赖项。现在是时候了解lib.rs中的实际代码了。

代码
我们首先删除标准库,因为它在内核中不存在:

#![no_std]

接下来,我们将添加一些use语句以使代码不那么冗长:

use core::ffi::c_void;
use core::ptr::null_mut;
use alloc::vec::Vec;
use alloc::{slice, string::String};
use wdk::*;
use wdk_alloc::WdkAllocator;
use wdk_sys::ntddk::*;
use wdk_sys::*;

包wdk_sys提供低级互操作内核函数。包wdk提供高级包装器。alloc::vec::Vec是一个有趣的。由于我们不能使用标准库,您可能会认为像这样的类型std::vec::Vec<>不可用,从技术上讲这是正确的。但是,Vec实际上是在名为的低级模块中定义的alloc::vec,可以在标准库之外使用。这是可行的,因为的唯一要求Vec是有一种分配和释放内存的方法。Rust 通过任何人都可以提供的全局分配器对象公开这一方面。由于我们没有标准库,所以没有全局分配器,所以必须提供一个。然后,Vec(和String)可以正常工作:

#[global_allocator]
static GLOBAL_ALLOCATOR: WdkAllocator = WdkAllocator;

这是 WDK 包提供的全局分配器,它使用ExAllocatePool2 和ExFreePool 管理分配,就像手动操作一样。

接下来,我们添加两个extern包来获得对分配器和恐慌处理程序的支持——这是另一件必须提供的东西,因为标准库不包括在内。Cargo.toml有一个设置,如果任何代码出现恐慌,它会中止驱动程序(使系统崩溃):

extern crate wdk_panic;
extern crate alloc;

现在是时候编写实际代码了。我们从DriverEntry任何 Windows 内核驱动程序的入口点开始:


#[export_name = "DriverEntry"]
pub unsafe extern
"system" fn driver_entry(
    driver: &mut DRIVER_OBJECT,
    registry_path: PUNICODE_STRING,
) -> NTSTATUS {

熟悉内核驱动程序的人会认出函数签名(有点)。函数名称driver_entry符合 rust 函数的 snake_case 命名约定,但由于链接器会查找DriverEntry,因此我们使用属性修饰函数export_name。如果您愿意,可以使用DriverEntry并忽略或禁用编译器的警告。

我们可以使用熟悉的println!宏,该宏通过调用 重新实现DbgPrint,就像使用 C/C++ 一样。DbgPrint请注意,您仍然可以调用 ,但println!这样做更简单:

println!("DriverEntry from Rust! {:p}", &driver);
let registry_path = unicode_to_string(registry_path);
println!(
"Registry Path: {}", registry_path);

不幸的是,它似乎println!还不支持 a UNICODE_STRING,所以我们可以编写一个名为的函数unicode_to_string将 a 转换UNICODE_STRING为普通的 Rust 字符串:

fn unicode_to_string(str: PCUNICODE_STRING) -> String {
    String::from_utf16_lossy(unsafe {
        slice::from_raw_parts((*str).Buffer, (*str).Length as usize / 2)
    })
}

回到DriverEntry,我们的下一个任务是创建一个名为“\Device\Booster”的设备对象:

let mut dev = null_mut();
let mut dev_name = UNICODE_STRING::default();
string_to_ustring("\\Device\\Booster", &mut dev_name);
 
let status = IoCreateDevice(
    driver,
    0,
    &mut dev_name,
    FILE_DEVICE_UNKNOWN,
    0,
    0u8,
    &mut dev,
);

该string_to_ustring函数将 Rust 字符串转换为UNICODE_STRING:

fn string_to_ustring(s: &str, uc: &mut UNICODE_STRING) -> Vec<u16> {
    let mut wstring: Vec<_> = s.encode_utf16().collect();
    uc.Length = wstring.len() as u16 * 2;
    uc.MaximumLength = wstring.len() as u16 * 2;
    uc.Buffer = wstring.as_mut_ptr();
    wstring
}

这看起来可能比我们想象的要复杂,但可以将其视为一次编写即可在各处使用的函数。事实上,也许已经有这样的函数了,只是没有仔细寻找。但它对这个驱动程序来说已经足够了。

如果设备创建失败,我们将返回失败状态:

if !nt_success(status) {
    println!("Error creating device 0x{:X}", status);
    return status;
}

nt_successNT_SUCCESS与WDK 头文件提供的宏类似。

接下来,我们将创建一个符号链接,以便标准CreateFile调用可以打开我们设备的句柄:

let mut sym_name = UNICODE_STRING::default();
let _ = string_to_ustring("\\??\\Booster", &mut sym_name);
let status = IoCreateSymbolicLink(&mut sym_name, &mut dev_name);
if !nt_success(status) {
    println!(
"Error creating symbolic link 0x{:X}", status);
    IoDeleteDevice(dev);
    return status;
}

剩下要做的就是初始化支持缓冲 I/O 的设备对象(IRP_MJ_WRITE为简单起见我们将使用它),设置驱动程序卸载例程,以及我们打算支持的主要功能:

    (*dev).Flags |= DO_BUFFERED_IO;
 
    driver.DriverUnload = Some(boost_unload);
    driver.MajorFunction[IRP_MJ_CREATE as usize] = Some(boost_create_close);
    driver.MajorFunction[IRP_MJ_CLOSE as usize] = Some(boost_create_close);
    driver.MajorFunction[IRP_MJ_WRITE as usize] = Some(boost_write);
 
    STATUS_SUCCESS
}

注意使用 RustOption<>类型来指示回调的存在。

卸载例程如下所示:

unsafe extern "C" fn boost_unload(driver: *mut DRIVER_OBJECT) {
    let mut sym_name = UNICODE_STRING::default();
    string_to_ustring(
"\\??\\Booster", &mut sym_name);
    let _ = IoDeleteSymbolicLink(&mut sym_name);
    IoDeleteDevice((*driver).DeviceObject);
}

我们只需调用IoDeleteSymbolicLink和IoDeleteDevice,就像普通内核驱动程序一样。

处理请求
我们需要处理三种请求类型—— IRP_MJ_CREATE、IRP_MJ_CLOSE和IRP_MJ_WRITE。创建和关闭很简单——只要成功完成 IRP 即可:

unsafe extern "C" fn boost_create_close(_device: *mut DEVICE_OBJECT, irp: *mut IRP) -> NTSTATUS {
    (*irp).IoStatus.__bindgen_anon_1.Status = STATUS_SUCCESS;
    (*irp).IoStatus.Information = 0;
    IofCompleteRequest(irp, 0);
    STATUS_SUCCESS
}

是IoStatus一个,IO_STATUS_BLOCK但它的定义包含一个union和Status。Pointer这似乎是不正确的,Information应该在 with 中union(Pointer而不是Status)。无论如何,代码Status通过“自动生成”联合访问成员,看起来很丑陋。绝对值得进一步研究。但它有效。

真正有趣的函数是IRP_MJ_WRITE处理程序,它执行实际的线程优先级更改。首先,我们将声明一个结构来表示对驱动程序的请求:

#[repr(C)]
struct ThreadData {
    pub thread_id: u32,
    pub priority: i32,
}

使用repr(C)很重要,以确保字段在内存中的布局与 C/C++ 中的布局一样。这允许非 Rust 客户端与驱动程序通信。事实上,我将使用我拥有的 C++ 客户端来测试驱动程序,该客户端使用了驱动程序的 C++ 版本。驱动程序接受要更改的线程 ID 和要使用的优先级。现在我们可以开始boost_write:

unsafe extern "C" fn boost_write(_device: *mut DEVICE_OBJECT, irp: *mut IRP) -> NTSTATUS {
    let data = (*irp).AssociatedIrp.SystemBuffer as *const ThreadData;

首先,我们从 IRP 中获取数据指针SystemBuffer,因为我们请求缓冲 I/O 支持。这是客户端缓冲区的内核副本。接下来,我们将进行一些错误检查:

let status;
loop {
    if data == null_mut() {
        status = STATUS_INVALID_PARAMETER;
        break;
    }
    if (*data).priority < 1 || (*data).priority > 31 {
        status = STATUS_INVALID_PARAMETER;
        break;
    }

该loop语句创建一个无限块,可以使用 退出break。一旦我们验证优先级在范围内,就该找到线程对象了:

let mut thread = null_mut();
status = PsLookupThreadByThreadId(((*data).thread_id) as *mut c_void, &mut thread);
if !nt_success(status) {
    break;
}

PsLookupThreadByThreadId是要使用的那个。如果失败,则意味着线程 ID 可能不存在,我们会中断。剩下要做的就是设置优先级并使用我们拥有的任何状态完成请求:

        KeSetPriorityThread(thread, (*data).priority);
        ObfDereferenceObject(thread as *mut c_void);
        break;
    }
    (*irp).IoStatus.__bindgen_anon_1.Status = status;
    (*irp).IoStatus.Information = 0;
    IofCompleteRequest(irp, 0);
    status
}

就是这样!

剩下的唯一一件事就是对驱动程序进行签名。如果存在 INF 或 INX 文件,则 crate 似乎支持对驱动程序进行签名,但此驱动程序未使用 INF。因此,我们需要在部署之前手动对其进行签名。可以从项目的根文件夹中使用以下内容:

signtool sign /n wdk /fd sha256 target\debug\booster.dll

使用/n wdk通常由 Visual Studio 在构建驱动程序时自动创建的 WDK 测试证书。我只需从商店中获取第一个以“wdk”开头的证书并使用它。

愚蠢的部分是文件扩展名——它是一个 DLL,目前没有办法在 Cargo Build 过程中自动更改它。如果使用 INF/INX,文件扩展名会更改为 SYS。无论如何,文件扩展名实际上并没有那么重要——我们可以手动重命名它,或者只是将其保留为 DLL。

安装驱动程序
生成的文件可以以“正常”方式安装软件驱动程序,例如在启用测试签名的计算机上使用sc.exesc start工具(从提升的命令窗口)。然后可用于将驱动程序加载到系统中:

sc.exe sc create booster type= kernel binPath= c:\path_to_driver_file
sc.exe start booster

测试驱动程序
我使用了一个现有的 C++ 应用程序,它与驱动程序对话并期望传递正确的结构。它看起来像这样:

#include <Windows.h>
#include <stdio.h>
 
struct ThreadData {
    int ThreadId;
    int Priority;
};
 
int main(int argc, const char* argv[]) {
    if (argc < 3) {
        printf("Usage: boost <tid> <priority>\n");
        return 0;
    }
 
    int tid = atoi(argv[1]);
    int priority = atoi(argv[2]);
 
    HANDLE hDevice = CreateFile(L
"\\\\.\\Booster",
        GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0,
        nullptr);
 
    if (hDevice == INVALID_HANDLE_VALUE) {
        printf(
"Failed in CreateFile: %u\n", GetLastError());
        return 1;
    }
 
    ThreadData data;
    data.ThreadId = tid;
    data.Priority = priority;
    DWORD ret;
    if (WriteFile(hDevice, &data, sizeof(data),
        &ret, nullptr))
        printf(
"Success!!\n");
    else
        printf(
"Error (%u)\n", GetLastError());
 
    CloseHandle(hDevice);
 
    return 0;
}

以下是将线程优先级更改为 26 (ID 9408) 时的结果:


结论
用 Rust 编写内核驱动程序是可行的,我相信对此的支持将很快得到改善。WDK 包的版本为 0.3,这意味着还有很长的路要走。为了在这个领域充分利用 Rust,应该创建安全的包装器,以便代码不那么冗长,没有unsafe块,并享受 Rust 可以提供的好处。请注意,在这个简单的实现中我可能遗漏了一些包装器。

这篇文章的代码可以在https://github.com/zodiacon/Booster找到。