logo

Introducción AL Desarrollo del Kernel de Windows USA Rust (Parte 1)

Introducción

¡Saludos, valiente lector! Prepárate para un emocionante viaje al reino del desarrollo de controladores, ¡empoderado por el formidable lenguaje de programación Rust!

A medida que te embarcas en esta emocionante aventura, anticipa una próxima expedición donde crearemos un impulsor potente capaz de ejecutar procesos nunca antes vistos. Omitir este artículo sería perder una oportunidad, ya que contiene las respuestas a las preguntas formuladas aquí.

Si Rust es territorio desconocido para ti, no te preocupes, pues yo serviré como tu guía, incluso aventurándonos en los reinos familiares de C++. Todo lo que se necesita es un poco de tiempo libre, competencia en programación C++ y un entendimiento básico de la magia a nivel de kernel.

Pero, ¿por qué Rust, te preguntarás? La respuesta es evidente: Rust fusiona estilo, innovación y una fuerza inquebrantable. Ofrece un rendimiento similar al de C++, fortalecido por medidas de seguridad impenetrables. Gigantes tecnológicos como Microsoft, Google, Mozilla y la Fundación Linux han reconocido su potencial. Incluso Linus Torvalds, el defensor del "C para siempre", respaldó oficialmente la integración de Rust en el núcleo de Linux [1]. Microsoft está reescribiendo el núcleo de Windows con Rust [2], sin embargo, todavía no han otorgado a los desarrolladores el privilegio de usar Rust dentro del núcleo de Windows. No importa; nosotros mismos tomaremos las riendas, superando sus esfuerzos.

En 2015, se lanzó la primera versión del lenguaje, y desde entonces se ha actualizado cada 6 semanas. Tiene 3 canales de lanzamientos: estable, beta y nocturno. (Por cierto, necesitaremos la herramienta nocturna para desarrollar controladores). Además, Rust tiene ediciones (algo así como estándares en C++), a saber, Rust 2015, Rust 2018, y ahora en el momento de escribir - Rust 2021. Es por eso que Rust incluso tiene el comando cargo fix --edition, que ayuda a corregir parcialmente lugares incompatibles para el nuevo estándar.

 

Dependencias

Para preparar el escenario para nuestra búsqueda, debemos reunir las herramientas necesarias y preparar nuestro entorno para los desafíos que tenemos por delante. Aquí tienes una lista de lo que necesitamos:

 


Herramientas Necesarias
1. Instalar Rust: Antes que nada, vamos a aprovechar el poder de Rust instalándolo desde aquí.
2. Instalar SDK: Necesitamos el SDK de Windows para nuestra expedición. Puedes adquirirlo aquí.
3. Instalar WDK: The Windows Driver Kit (WDK) es nuestro compañero de confianza para el desarrollo de controladores. Puedes obtenerlo aquí.
4. Instalar Sysinternals: La Suite Sysinternals será nuestro conjunto de herramientas invaluable. Consíguelo aquí.

NOTA: La terminal debe ejecutarse con privilegios de administrador.

 

Desarrollo

Configuración
Después de crear el proyecto, navega hacia el directorio hello-win-kernel. Encontrarás la siguiente estructura:

cargo new hello-win-kernel --lib

Después de crear el proyecto, navega al directorio hello-win-kernel . Encontrarás la siguiente estructura:

# hello-win-kernel folder
srclib.rs
.gitignore
Cargo.toml

En Rust, normalmente nombramos el archivo que contiene el punto de entrada para aplicaciones como main.rs, mientras que para las bibliotecas, es lib.rs. El archivo Cargo.toml contiene las dependencias y configuraciones del proyecto, mientras que .gitignore excluye sabiamente el directorio target, donde se almacenan los binarios.
Ahora, vamos a mejorar la configuración de nuestro proyecto editando el archivo Cargo.toml. Primero, añade una sección [lib], especificando la ruta del archivo de punto de entrada y designándolo como una "biblioteca dinámica".

[package]
name = "hello-win-kernel"
version = "0.1.0"
edition = "2021"

[lib]
path = "src/lib.rs"
crate-type = ["cdylib"]

También crearemos las secciones [profile.dev] y [profile.release] para configurar el tipo de panic como "abortar" para mejorar el manejo de errores.
A continuación, incluye una sección [build-dependencies], introduciendo el crate winreg para operaciones con el registro de Windows:

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

[build-dependencies]
winreg = "0.50.0"

Para asegurar una compilación fluida del proyecto, crea un archivo rust-toolchain en el directorio hello-win-kernel, especificando el uso de la herramienta nightly, lo que permite el acceso a banderas especiales:

herramientas para Rust

nightly

Para compilar el controlador, necesitarás agregar rutas al WDK. Esto se puede lograr utilizando un script build.rs. El script build.rs es ejecutado por cargo antes de construir el paquete. Te permite localizar las bibliotecas instaladas e incluir su información en Cargo. Después de usar la construcción #[link(name = "libname")] en tu código, el proceso de construcción buscará las rutas agregadas y enlazará las bibliotecas, similar a C o C++. Crea un archivo build.rs en el directorio hello-win-kernel. Llena este archivo con el código para obtener el WDK. Puedes basar tu script en el que se encuentra en este repositorio de GitHub.

build.rs

// A.k.a #include  in the C++
extern crate winreg;

// Imports the need features. A.k.a using namespace std in the C++
use std::env::var;
use std::error::Error;
use std::fmt;
use std::path::{Path, PathBuf};
use winreg::enums::*;
use winreg::RegKey;

// Windows Kits registry key
const WIN_KITS_KEY: &str = r"SOFTWAREMicrosoftWindows KitsInstalled Roots";

// Custom error type
#[derive(Debug)]
struct BuildError {
    msg: String,
}

impl Error for BuildError {}

impl fmt::Display for BuildError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "BuildError: {}", self.msg)
    }
}

// Get the Windows Kits directory from the registry
fn get_windows_kits_dir() -> Result {
    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
    match hklm.open_subkey(WIN_KITS_KEY) {
        Ok(key) => match key.get_value::("KitsRoot10") {
            Ok(dir) => Ok(dir.into()),
            Err(_) => Err(BuildError {
                msg: format!("Can not get value: {}", WIN_KITS_KEY),
            }),
        },
        Err(_) => Err(BuildError {
            msg: format!("Can not open sub_key: {}", WIN_KITS_KEY),
        }),
    }
}

// Get the latest km directory from the Windows Kits directory
fn get_km_dir(windows_kits_dir: &PathBuf) -> Result {
    match Path::new(windows_kits_dir).join("lib").read_dir() {
        Ok(read_dir) => {
            match read_dir
                .filter_map(|dir| dir.ok())
                .map(|dir| dir.path())
                .filter(|dir| {
                    dir.components()
                        .last()
                        .and_then(|c| c.as_os_str().to_str())
                        .map(|c| c.starts_with("10.") && dir.join("km").is_dir())
                        .unwrap_or(false)
                })
                .max()
                .ok_or_else(|| format!("Can not find a valid km dir in `{:?}`", windows_kits_dir))
            {
                Ok(max_lib_dir) => Ok(max_lib_dir.join("km")),
                Err(msg) => Err(BuildError { msg }),
            }
        }
        Err(_) => Err(BuildError {
            msg: format!("Can not read dir: {:?}", windows_kits_dir),
        }),
    }
}

fn main() {
    let windows_kits_dir = get_windows_kits_dir().unwrap();
    let km_dir = get_km_dir(&windows_kits_dir).unwrap();
    let target = var("TARGET").unwrap();
    let arch = if target.contains("x86_64") {
        "x64"
    } else if target.contains("i686") {
        "x86"
    } else {
        panic!("Only support x64 and x86!");
    };
    let wdk_lib_dir = km_dir.join(arch);

    // link
    println!(
        "cargo:rustc-link-search=native={}",
        wdk_lib_dir.to_str().unwrap()
    );
}

Para dar paso a nuestro controlador, primero debemos crear un archivo de configuración. No temas, pues te guiaré por este camino hacia el triunfo.

1. Crea un directorio llamado .cargo en tu dominio actual—un reino de posibilidades.

2. Dentro de este nuevo dominio, crea un archivo llamado config. Aquí, grabaremos nuestras directivas.

 

Inscribe estas banderas dentro del archivo config, cada una como un faro que nos guía a través de las profundidades laberínticas de la compilación de controladores. Para un entendimiento más profundo de estas banderas, puedes adentrarte en los anales de sabiduría encontrados en MSDN [5].

.cargo/config

[build]
target = "x86_64-pc-windows-msvc"

rustflags = [
    # Pre Link Args
    "-Z", "pre-link-arg=/NOLOGO",
    "-Z", "pre-link-arg=/NXCOMPAT",
    "-Z", "pre-link-arg=/NODEFAULTLIB",
    "-Z", "pre-link-arg=/SUBSYSTEM:NATIVE",
    "-Z", "pre-link-arg=/DRIVER",
    "-Z", "pre-link-arg=/DYNAMICBASE",
    "-Z", "pre-link-arg=/INCREMENTAL:NO",
    "-Z", "pre-link-arg=/MANIFEST:NO",
    "-Z", "pre-link-arg=/PDBALTPATH:none",

    # Post Link Args
    "-C", "link-arg=/OPT:REF,ICF",
    "-C", "link-arg=/ENTRY:driver_entry",
    "-C", "link-arg=/MERGE:.edata=.rdata",
    "-C", "link-arg=/MERGE:.rustc=.data",
    "-C", "link-arg=/INTEGRITYCHECK"
]

 

Implementación

¡Uf! ¡Hurra, lo hemos logrado! ¿Puedes creerlo? Es hora de empezar a programar. Sí, podemos sumergirnos en el desarrollo del controlador. Al igual que C++, Rust no admite la biblioteca estándar en modo kernel debido a las diferencias entre el modo usuario y el modo kernel. Por lo tanto, necesitamos desactivarla usando el atributo #![no_std], que debe especificarse al inicio del archivo lib.rs.

A continuación, debemos declarar la función __CxxFrameHandler3 y la variable _fltused para resolver errores de enlace. Adicionalmente, necesitamos implementar nuestro panic_handler personalizado ya que la biblioteca estándar ya no está disponible, puesto que la hemos deshabilitado.

Avanzando, vamos a crear la función driver_entry, que será el punto de entrada para el controlador. Puedes nombrar esta función de manera diferente, pero necesitas especificarlo en el archivo .cargo/config cambiando el campo "-C", "link-arg=/ENTRY:driver_entry", por ejemplo, a "-C", "link-arg=/ENTRY:entry".

Ya que la API de Windows está implementada en C, necesitamos usar FFI (Interfaz de Función Extranjera). En términos simples, es un mecanismo que nos permite llamar a APIs desde otro lenguaje.

Para comenzar, necesitamos desactivar la ofuscación de nombres utilizando el atributo #[no_mangle] y especificar que se usa la convención de llamadas del sistema (stdcall) para las llamadas a funciones con extern "system". Para mayor comodidad, creemos algunos tipos. En lugar de PDRIVER_OBJECT y PUNICODE_STRING, usaremos PVOID por ahora, ya que no necesitamos esas estructuras de datos en este momento.

Entonces, agreguemos el siguiente código a src/lib.rs.

src/lib.rs

// Disables the standard library
#![no_std]

// A.k.a #include  in C++
mod dbg;
mod ntstatus;
mod types;

// Imports the need features. A.k.a using namespace std in the C++
use dbg::*;
use ntstatus::*;
use types::*;

// Need for fix linker error.
#[no_mangle]
pub extern "system" fn __CxxFrameHandler3(_: *mut u8, _: *mut u8, _: *mut u8, _: *mut u8) -> i32 {
    unimplemented!()
}

// Need for fix linker error. Floating point calculations aren`t allowed when running in the Windows Kernel.
#[export_name = "_fltused"]
static _FLTUSED: i32 = 0;

// Panic hanlder
#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    // The purpose of the loop construct in this context is to create an infinite loop.
    // When a panic occurs, the panic function is invoked, and instead of unwinding
    // the stack or aborting the program, it enters an infinite loop.
    // This behavior is intentional to prevent the program from terminating or propagating
    // the panic further. By using an infinite loop in the panic handler, the program effectively
    // hangs or stalls at the point of panic, allowing for potential diagnostics or
    // debugging to be performed.
    loop {}
}

// The entry point of the driver, set as global (pub).
#[no_mangle]
pub extern "system" fn driver_entry(_driver: PVOID, _path: PVOID) -> NTSTATUS {
    kd_print!("Hello, world!
");
    STATUS_SUCCESS
}

A continuación, crearemos src/types.rs y añadiremos tipos al estilo C usando alias (conocidos también como typedef en C). Adicionalmente, en el archivo src/ntstatus.rs, añadiremos estados tales como STATUS_SUCCESS.

src/types.rs

// Aliases for Windows types
pub type NTSTATUS = u32;
pub type PCSTR = *const u8;
pub type PVOID = *mut core::ffi::c_void;

src/ntstatus.rs

// Imports the need features. A.k.a using namespace std in the C++
use crate::types::*;

pub const STATUS_SUCCESS: NTSTATUS = 0x00000000;

Genial, ahora vamos a añadir la API DbgPrint, que se utiliza para registrar información de depuración cuando se desarrollan controladores. Para hacer esto, crearemos un archivo llamado src/dbg.rs, enlazamos a ntoskrnl, y añadimos la API DbgPrint. Adicionalmente, para trabajar más cómodamente, crearemos una macro kd_print ya que necesitamos añadir constantemente el carácter \0 al final de una oración debido a la programación específica en C (en C, las cadenas terminan con un carácter \0).

Envolveremos DbgPrint en un bloque unsafe porque es una API de otro idioma, y Rust no puede ofrecer sus garantías para ella.

src/dbg.rs

// Imports the need features. A.k.a using namespace std in the C++
use crate::types::*;

// Imports the `DbgPrint` function from `ntoskrnl.exe`, and set as a global function (pub),
// that can be called from anywhere module in the crate.
#[link(name = "ntoskrnl")]
extern "C" {
	  #[allow(dead_code)]
    pub fn DbgPrint(format: PCSTR, ...) -> NTSTATUS;
}

// Macro that calls the `DbgPrint` function, and prepends the string with the name of the driver,
// and a null terminator. The `unsafe` is used because the `DbgPrint` function is written in a different language,
// and Rust cannot guarantee safety.
#[macro_export]
#[cfg(debug_assertions)]
macro_rules! kd_print {
    ($string: expr) => {
        unsafe {
            $crate::DbgPrint(concat!("[hello_win_kernel.sys] ", $string, "�").as_ptr())
        }
    };

    ($string: expr, $($x:tt)*) => {
        unsafe {
            $crate::DbgPrint(concat!("[hello_win_kernel.sys] ", $string, "�").as_ptr(), $($x)*)
        }
    };
}

// Empty macro for release
#[macro_export]
#[cfg(not(debug_assertions))]
macro_rules! kd_print {
	  ($string: expr) => {};
    ($string: expr, $($x:tt)*) => {};
}

Ahora podemos construir nuestro controlador con éxito. Sin embargo, esto no es el final, porque a continuación, necesitaremos implementar el mecanismo de firma y despliegue del controlador.

El ejemplo de controlador en C++ a continuación demuestra que escribir un controlador en C o C++ es más fácil comparado con Rust. Pero hasta que haya un soporte oficial de Microsoft, lo cual todos esperan con ansias (de hecho, hasta que tomemos cartas en el asunto y lo implementemos nosotros mismos, como en el caso de C++), obtenemos las garantías de Rust para nuestro nivel de desarrollo, permitiéndonos construir un controlador más seguro, junto con ASM en línea completo, emparejamiento de patrones, y muchas otras características geniales que Rust ofrece.

Algunas startups ya están utilizando Rust para desarrollar controladores para dispositivos virtuales en el contexto de la ciberseguridad.

Ejemplo de controlador en C++ del proyecto "hello-win-kernel-cpp":

#include 

extern "C"
{
    DRIVER_INITIALIZE DriverEntry;
}


#ifdef ALLOC_PRAGMA
#pragma alloc_text(INIT, DriverEntry)
#endif

// The entry point of the driver
_Use_decl_annotations_
NTSTATUS DriverEntry([[maybe_unused]] PDRIVER_OBJECT DriverObject,
                     [[maybe_unused]] PUNICODE_STRING RegistryPath)
{
    DbgPrint("[hello-win-kernel-cpp.sys] Hello, World!
");
    return STATUS_SUCCESS;
}

Implementación y pruebas

Para probar nuestro controlador, primero necesitamos cambiar su extensión de .dll a .sys y firmarlo con un certificado de prueba. Para automatizar todo esto, utilizaremos cargo-make. Para hacerlo, ejecuta el siguiente comando en la terminal.

cargo install cargo-make

A continuación, vamos a crear un middleware llamado WIN_SDK_TOOLS, que puede estar relacionado con las herramientas del SDK de Windows. Para quien firmes los siguientes pasos:

1. Abra Ver configuración avanzada del sistema buscando y lanzando esta opción.

2. En la ventana de Propiedades del Sistema , selecciona la pestaña Avanzada.

3. Haz clic en el botón Variables de Entorno.

4. En la ventana de Variables de Entorno, desplázate hacia abajo hasta la sección de Variables del Sistema y haz clic en el botón Nuevo.

5. En el campo Nombre de la variable, introduce el nombre de la variable como WIN_SDK_TOOLS.

6. En el campo Valor de la variable, proporciona la ruta hacia la carpeta que contiene las herramientas del SDK de Windows.

7. Haz clic en OK para guardar los cambios.

A continuación, necesitamos crear un makefile.toml en el que definiremos las reglas de construcción.

Cargo-make es similar al make regular, pero las reglas se definen en un formato ligeramente diferente, más sencillo. Para elaborar, en este script, creamos tareas para construir el controlador, renombrando la extensión a .sys, firmando el controlador usando las utilidades makecer.exe y signtool.exe, desplegando el controlador (moviendo el controlador y el certificado a una carpeta bin separada), y limpiando la construcción.

Makefile.toml

[env.development]
TARGET_PATH = "target/x86_64-pc-windows-msvc/debug"

[env.release]
TARGET_PATH = "target/x86_64-pc-windows-msvc/release"
BUILD_FLAGS = "--release"

[tasks.build-driver]
script = [
    "cargo build %BUILD_FLAGS%"
]

[tasks.rename-driver]
dependencies = ["build-driver"]
ignore_errors = true
script = [
    "cd %TARGET_PATH%",
    "rename hello_win_kernel.dll hello_win_kernel.sys",
]

[tasks.sign-driver]
dependencies = ["build-driver", "rename-driver"]
script = [
    ""%WIN_SDK_TOOLS%\makecert.exe" -r -pe -ss PrivateCertStore -n "CN=hello_win_kernel_cert" hello_win_kernel_cert.cer",
    ""%WIN_SDK_TOOLS%\signtool.exe" sign /fd sha256 /v /s PrivateCertStore /n "hello_win_kernel_cert" /t http://timestamp.digicert.com /a %TARGET_PATH%/hello_win_kernel.sys",
]

[tasks.deploy]
dependencies = ["sign-driver"]
script = [
    "mkdir bin",
    "move hello_win_kernel_cert.cer bin\hello_win_kernel_cert.cer",
    "move %TARGET_PATH%\hello_win_kernel.sys bin\hello_win_kernel.sys"
]

[tasks.cleanup]
script = [
    "rmdir /s /q bin",
    "cargo clean",
]

Para construir el proyecto, cambiar la extensión y firmar el controlador, necesitas ejecutar el siguiente comando (No uses el terminal integrado de VSCode, ya que pueden surgir problemas durante la firma del controlador. Recomiendo usar Windows Terminal):

cargo make deploy

Para limpiar el proyecto, ejecute el siguiente comando:

cargo make cleanup

También puedes ejecutar pasos individuales por separado. Por ejemplo, si necesitas solo firmar el controlador, ejecuta el siguiente comando:

cargo make sign-driver

A continuación, puedes proceder a realizar pruebas. Para hacer esto, inicia una máquina virtual con Windows 10 x64 o Windows 11, con el modo de prueba y el modo de depuración activados. Si estos modos están desactivados, necesitas ejecutar los siguientes comandos y reiniciar el sistema operativo:

NOTA: El terminal debe ejecutarse con privilegios de administrador

# Enable Test mode
bcdedit -set TESTSIGNING ON

# Enable Debug kernel
bcdedit /debug on

Copia la utilidad **DebugView** del paquete **sysinternals**, que deberías haber descargado de los servidores de Microsoft, a la máquina virtual. Crea una carpeta de prueba en el escritorio y transfiere tu controlador y certificado de la máquina anfitriona a esta carpeta.

Luego, ejecuta **DebugView**, haz clic en **Captura**, y marca la casilla junto a **Capturar Kernel** y Enable Verbose Kernel Output. Esto te permitirá ver la salida de depuración del kernel.

Después de estas manipulaciones, puedes proceder a registrarte y comenzar el controlador ejecutando los siguientes comandos en la terminal:

NOTA: El terminal debe ejecutarse con privilegios de administrador al registrar e iniciar un controlador en Windows

Para registrar su conductor:

sc create hello_win_kernel binPath= "FullPathhello_win_kernel.sys" type= kernel start= demand

Iniciar Conductor

sc start hello_win_kernel 

Resultado

¡Genial! Cuando te hayas registrado correctamente y hayas iniciado tu controlador, deberías poder ver el mensaje "¡Hola, mundo!" de tu controlador en DebugView.

Resumen

¡Eso es! Has aprendido cómo construir tu propio controlador utilizando el lenguaje de programación Rust. Como puedes ver, es posible escribir un controlador en Rust, aunque requiere algo de esfuerzo. De hecho, si configuras todo correctamente, el resultado será muy impresionante. Así que, te espero en el segundo artículo, donde avanzaremos hacia la escritura de un controlador real.

Si tienes más preguntas o necesitas más ayuda en el futuro, no dudes en contactarnos. ¡Buena suerte con el desarrollo de tu controlador!

Referencia

1. Microsoft está ocupado reescribiendo el código central de Windows en Rust, un lenguaje seguro en cuanto a la memoria

    https://www.theregister.com/2023/04/27/microsoft_windows_rust/

2. Linus Torvalds: Rust se incorporará en Linux 6.1

    https://www.zdnet.com/article/linus-torvalds-rust-will-go-into-linux-6-1/

3. Crear Scripts

    https://doc.rust-lang.org/cargo/reference/build-scripts.html

4. Crear script para el controlador

    https://github.com/memN0ps/rootkit-rs/blob/master/driver/build.rs

5. Banderas de MSVC

    https://learn.microsoft.com/en-us/cpp/build/reference/linker-options?view=msvc-170