
Популярность Rust неуклонно растёт, а с ней и сопутствующая экосистема. И оно не удивительно, ведь это единственный современный язык, который обеспечивает безопасность памяти и многопоточной обработки на этапе компиляции, предоставляя мощную и богатую систему сборки (cargo), а также всё больше различных пакетов (крейтов).
В своей повседневной работе я по-прежнему придерживаюсь C++, так как основная её часть связана с низкоуровневым программированием систем и ядра, а из этого языка легко задействовать написанный на С Windows API и COM API.
Rust — это язык для системного программирования, то есть он может справляться с теми же задачами, что и C/C++. Основное неудобство при этом создаёт громоздкий синтаксис, необходимый для преобразования типов С в типы Rust. Но это неудобство можно преодолеть, используя подобающие обёртки и макросы.
Короче говоря, я решил попробовать написать простой и полезный драйвер WDM. Это будет Rust-версия драйвера «Booster», о котором я пишу в своей книге (Windows Kernel Programming), позволяющего изменять приоритет любого потока на любое значение.
▍ Подготовка
Информация о всём необходимом для написания драйверов есть в репозитории windows-drivers-rs. В первую очередь у вас должна быть установлена WDK (стандартная или EWDK). Кроме того, документация требует установки LLVM для получения доступа к компилятору Clang. Далее я буду исходить из предположения, что всё это у вас установлено. Можно начать с создания проекта библиотеки Rust (ведь в техническом смысле драйвер — это DLL-файл, загружаемый в пространство ядра):
cargo new --lib booster
Откроем каталог Booster в VS Code и приступим к написанию кода. Но для его успешной компиляции и компоновки сначала нужно сделать так, чтобы файл 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()
}
Теперь добавьте в 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
Самые важные здесь — это зависимости крейтов.
Теперь перейдём к фактическому коду в 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 {
Те, кто знаком с драйверами ядра, частично узнают эту сигнатуру функции. Согласно правилу именования функций Rust в форме snake_case
, называется она driver_entry
, но так как компоновщик ищет 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!
ещё не поддерживает UNICODE_STRING
, поэтому напишем функцию unicode_to_string
для преобразования 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
, далее мы создаём объект устройства с именем DeviceBooster:
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_success
аналогичен макросу NT_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;
}
Осталось инициализировать объект устройства с поддержкой буферизованного ввода/вывода (для простоты сделаем это с помощью 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
}
Обратите внимание на использование типа Rust Option<>
для обозначения присутствия обратного вызова.
Процедура выгрузки выглядит так:
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
. Похоже на ошибку, так как в union
с Pointer
должна быть Information
(а не Status
).
Как бы то ни было, этот код обращается к члену Status
через «автоматически сгенерированное» объединение и выглядит убого. Сей момент определённо ещё нужно будет доработать. Но самое главное, что оно работает. Действительно же интересна функция обработчика IRP_MJ_WRITE
, который и отвечает за фактическую смену приоритета потоков. Сначала определим структуру запроса к драйверу:
#[repr(C)]
struct ThreadData {
pub thread_id: u32,
pub priority: i32,
}
Важно использовать repr(C)
, чтобы поля были выстроены в памяти также, как в случае C/C++. Это позволит обращаться к драйверу клиентам, написанным не на Rust. Собственно, далее я протестирую этот драйвер с имеющимся у меня клиентом, использующим его версию на 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;
Поскольку мы реализовали поддержку буферизации ввода/вывода, первым делом получаем указатель данных из SystemBuffer
в IRP. Это копия буфера клиента из пространства ядра. Затем проверяем ошибки:
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, но этот драйвер не использует INF. Значит, нужно перед развёртыванием подписать его вручную. Для этого выполните в корне проекта следующую команду:
signtool sign /n wdk /fd sha256 targetdebugbooster.dll
Параметр /n wdk
определяет использование тестового сертификата WDK, обычно создаваемого автоматически Visual Studio при сборке драйверов. Я просто беру из хранилища первый, начинающийся с «wdk» и использую его.
Самая нелепая загвоздка здесь в расширении файла — это DLL, и на данный момент нет способа его автоматического изменения с помощью cargo
в процессе сборки. В случае использования INF/INX это расширение меняется на SYS. Как бы то ни было, расширения не столь значимы — вполне можно оставлять DLL, ну или менять вручную.
▍ Установка драйвера
Созданный драйвер можно установить стандартным способом, например, использовать инструмент sc.exe
(из командной строки с правами администратора) на машине, где включены тестовые подписи. После этого загрузить драйвер в систему можно будет с помощью sc 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: %un", 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 преимущества. Заметьте, что в этой простой реализации я мог какие-то обёртки упустить.
Автор: Bright_Translate