<index> / <wazabiedr> / driver-skeleton
[ en | fr ]
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Partie 1 — Un squelette de driver KMDF en Rust
~ lululufr
SOMMAIRE
  0  l'état cible
  1  cargo.toml — la découpe des crates wdk
  2  no_std, allocateur, panic — et la config de test
  3  driverentry — ordering et unwinding
  4  la table irp_mj
  5  device, symlink, do_buffered_io
  6  driverunload — pointeur device atomique
  7  build, sign, load
  8  la suite

──[ 0. L'État Cible ]──

À la fin de ce post le driver charge, expose `\.WazabiEDR` (un chemin de device 
atteignable depuis le user mode), et ne fait rien d'autre. Aucun callback kernel
n'est enregistré. Les quatre routines de dispatch IRP (*IRP, I/O Request Packet*
: la structure que l'I/O manager de Windows utilise pour acheminer un appel user
mode — `CreateFile`, `ReadFile`, `DeviceIoControl` etc. — jusqu'au driver)
complètent toutes avec succès sans effet de bord. Ouvrir le device depuis le
user mode rend un handle utilisable, le fermer réussit. L'intérêt de s'arrêter ici, c'est d'isoler la plomberie kernel mode — point
d'entrée, table de dispatch, nommage de device, unload — de la logique
EDR-spécifique qui suit. Plomberie fausse, les callbacks de la Partie 2 se
déclencheraient sur un driver à moitié construit, ce qui est la manière la plus
douloureuse de debug un codebase kernel. Plomberie correcte, tout ce qui suit
reste additif. Une note sur le choix de framework. KMDF (*Kernel-Mode Driver Framework* :
l'abstraction de plus haut niveau de Microsoft au-dessus du modèle WDM brut —
*Windows Driver Model* — qui gère un morceau substantiel du boilerplate de durée
de vie d'objet, de cancellation d'IRP et de power management pour vous) est ce
que ce driver cible. L'alternative, WDM brut, donnerait un peu plus de contrôle
au prix d'un boilerplate significatif ; pour un driver dont la surface est "cinq
callbacks plus un IOCTL", l'overhead de KMDF se rembourse plusieurs fois.
──[ 1. Cargo.toml — La Découpe des Crates WDK ]──
[package]
name    = "WazabiEDR_Driver"
version = "0.1.0"
edition = "2024"

[dependencies]
wdk        = "0.4.1"
wdk-alloc  = "0.4.1"
wdk-panic  = "0.4.1"
wdk-sys    = "0.5.1"

[build-dependencies]
wdk-build = "0.5.1"

[lib]
crate-type = ["cdylib"]

[package.metadata.wdk.driver-model]
driver-type              = "KMDF"
kmdf-version-major       = 1
target-kmdf-version-minor = 33

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"
Les quatre crates runtime `wdk-*` partitionnent la surface du WDK (*Windows 
Driver Kit* : headers, libs et outils Microsoft pour le développement de drivers
kernel mode) volontairement :
    wdk-sys     sortie bindgen sur les headers WDK. FFI unsafe, pas
                de wrapping. C'est ce contre quoi on écrit vraiment
                dans un driver de production, parce que les
                wrappers plus haut niveau ne couvrent pas encore ce
                dont un EDR a besoin (formes d'enregistrement de
                callbacks, idiomes de complétion d'IRP, enveloppes
                de callbacks OB).

    wdk         wrappers safe-ish au-dessus de wdk-sys pour les
                patterns que Microsoft a considéré first-class.
                Utile pour un driver USB ou storage. Largement
                tangent ici.

    wdk-alloc  implem `GlobalAlloc` portée par `ExAllocatePool2` —
                le remplacement moderne (Windows 10 2004 et
                ultérieur) de `ExAllocatePoolWithTag`. Le type de
                pool est configurable au site d'allocation via les
                flags.

    wdk-panic  `#[panic_handler]` qui appelle `KeBugCheckEx`. Tiré
                via `extern crate` pour que le symbole existe, même
                si le reste du code n'y fait jamais référence.

`crate-type = ["cdylib"]` (*cdylib* : une bibliothèque liée dynamiquement avec 
ABI C — ce dont `link.exe` a besoin pour produire un `.sys` plutôt qu'un rlib
Rust) combiné au bloc `package.metadata.wdk.driver-model` est ce qui fait
émettre à `wdk-build` la bonne invocation de linker : `/SUBSYSTEM:NATIVE`,
`/DRIVER`, les imports de la bibliothèque KMDF, et le bon point d'entrée. Sans
le bloc metadata, `cargo build` produit un cdylib qui linke mais ne charge pas. `panic = "abort"` dans les deux profils est obligatoire, pas stylistique. Le
build accepte `panic = "unwind"`, mais `wdk-panic` ne fournit pas de
personnalité d'unwind (le code runtime que Rust appellerait pour effectivement
remonter et nettoyer la pile), et même s'il en fournissait une, l'unwind à
travers les frames de pile kernel laisserait des spinlocks à moitié relâchés et
des IRP pendantes en chemin. L'abort sur panic est le seul choix sûr ; le
compilateur l'impose via ces deux lignes.
──[ 2. no_std, Allocateur, Panic — et la Config de Test ]──
#![no_std]

#[cfg(not(test))]
extern crate wdk_panic;

#[cfg(not(test))]
use wdk_alloc::WdkAllocator;

#[cfg(not(test))]
#[global_allocator]
static GLOBAL_ALLOCATOR: WdkAllocator = WdkAllocator;
`no_std` (*no_std* : attribut de crate Rust qui sort de `std`, laissant 
seulement `core` — le sous-ensemble portable — et `alloc` — les collections à
allocation, une fois un allocateur enregistré) est obligatoire parce que la
stdlib Rust suppose un environnement user mode qu'on n'a pas : pas de `std::fs`,
pas de `std::thread`, pas de surface syscall. On travaille exclusivement contre
`core` et `alloc`. Les trois portes `#[cfg(not(test))]` existent pour une raison pratique. `cargo
test` build la crate pour le triplet hôte, l'hôte a déjà son propre
`#[global_allocator]` et `#[panic_handler]`, et enregistrer les nôtres
inconditionnellement produit une erreur de link duplicate-symbol au test. Les
gates les sortent de la config de test sans affecter le build de production. `WdkAllocator` est par défaut sur le pool non-paginé. Pour rappel, Windows split
la mémoire kernel en deux pools : *paginé* (évictable sur le disque sous
pression mémoire) et *non-paginé* (verrouillé en mémoire physique, obligatoire
pour le code qui tourne au-dessus de `PASSIVE_LEVEL` — voir la note IRQL
ci-dessous). Toute allocation faite via l'allocateur global peut donc être
touchée à n'importe quel IRQL. IRQL (*Interrupt Request Level* : niveau de priorité d'ordonnancement imposé par
le kernel ; le code à `DISPATCH_LEVEL` ou au-dessus ne peut pas bloquer ni
page-fault) est la contrainte qui pilote ce choix. Le ring buffer en Partie 4
est écrit par des callbacks kernel dont on ne contrôle pas entièrement l'IRQL —
en particulier le callback OB preoperation peut tourner jusqu'à
`DISPATCH_LEVEL`. Tout ce que ces callbacks touchent doit être en mémoire
non-paginée. Faire du non-paginé le défaut pour tout le driver garde la règle
uniforme et retire une classe de bugs. Le coût, c'est que le pool non-paginé est une ressource partagée et finie. Le
ring buffer est à taille fixe (4096 slots) précisément pour que l'empreinte
mémoire worst-case du driver soit connue statiquement.
──[ 3. DriverEntry — Ordering et Unwinding ]──
#[unsafe(export_name = "DriverEntry")]
pub unsafe extern "system" fn driver_entry(
    driver: PDRIVER_OBJECT,
    _registry_path: PCUNICODE_STRING,
) -> NTSTATUS
`PDRIVER_OBJECT` est un pointeur vers la structure `DRIVER_OBJECT` allouée par 
le kernel qui représente ce driver auprès de l'I/O manager. On la mute en place
— installer les routines de dispatch, la routine d'unload, le pointeur vers la
liste de devices — parce qu'il n'y a pas d'idiome "retourner un objet
initialisé" à cette couche ; le kernel nous file une struct partiellement
remplie et attend qu'on remplisse le reste. `PCUNICODE_STRING` est un pointeur vers un `UNICODE_STRING` (*UNICODE_STRING* :
le descripteur de chaîne standard du kernel — un triplet `(Length,
MaximumLength, Buffer)` sur un buffer UTF-16, avec `Length` en octets, sans NUL
terminal). Le `_registry_path` pointe ici sur le chemin de registre de notre clé
de service sous `HKLMSYSTEMCurrentControlSetServices`. On l'ignore
volontairement : le driver n'a pas de configuration par-déploiement, tous les
tunables vivent dans l'`agent.json` de l'agent (Partie 5). `NTSTATUS` est un code de retour signé sur 32 bits où le bit de poids fort
signale l'échec. La raison pour laquelle ça vaut le coup de le signaler, c'est
que l'échec de `DriverEntry` n'a pas de récupération côté kernel : si on
retourne un statut négatif, l'I/O manager n'appellera pas notre `DriverUnload`.
Tout ce qu'on a acquis avant d'échouer — objets device, symlinks, callbacks
enregistrés — doit être relâché inline dans le même corps de `DriverEntry`.
Chaque étape faillible dans la fonction est donc pairée à un unwind inline de
chaque étape précédente. Le source `lib.rs` est répétitif pour cette raison, et
la répétition est porteuse. La toute première ligne du corps :
(*driver).DriverUnload = Some(driver_unload);
`DriverUnload` est câblé avant tout autre travail parce que l'échelle d'unwind 
inline ne couvre que l'échec de `DriverEntry`. Une fois qu'on retourne
`STATUS_SUCCESS`, le kernel prend le relais du chemin de nettoyage, et ce chemin
passe par ce champ. Le câbler en dernier laisserait une fenêtre dans laquelle
une faute entre le dernier effet de bord réussi et l'assignation du champ laisse
le kernel sans moyen de nous rappeler.
──[ 4. La Table IRP_MJ ]──
let mj = (*driver).MajorFunction.as_mut_ptr();
for i in 0..=IRP_MJ_MAXIMUM_FUNCTION as usize {
    *mj.add(i) = Some(dispatch_invalid);
}

*mj.add(IRP_MJ_CREATE as usize)         = Some(dispatch_create_close);
*mj.add(IRP_MJ_CLOSE as usize)          = Some(dispatch_create_close);
*mj.add(IRP_MJ_CLEANUP as usize)        = Some(dispatch_cleanup);
*mj.add(IRP_MJ_DEVICE_CONTROL as usize) = Some(dispatch_device_control);
`MajorFunction` est un tableau de pointeurs de fonctions, indexé par *code de 
fonction majeure* (`IRP_MJ_CREATE`, `IRP_MJ_READ`, `IRP_MJ_DEVICE_CONTROL`, …).
L'I/O manager prend la case qui matche le code majeur de l'IRP entrante et
appelle la fonction qui y est. Une entrée `NULL` cause un bug-check (crash kernel mode, l'écran bleu) sur la
première IRP de ce type. Certains samples laissent le tableau à zéro et comptent
sur le fait de ne jamais recevoir d'IRP inattendue — bien jusqu'à ce que quelque
chose d'inattendu arrive. On installe `dispatch_invalid` (retourne
`STATUS_INVALID_DEVICE_REQUEST`) sur tout le tableau, puis on écrase les quatre
cases qu'on sert vraiment. Les quatre handlers écrasés sont des stubs à ce stade :
    dispatch_create_close   — IRP_MJ_CREATE + IRP_MJ_CLOSE. Retourne
                              STATUS_SUCCESS. Le CreateFile et le
                              CloseHandle de l'agent réussissent
                              tous les deux.

    dispatch_cleanup        — IRP_MJ_CLEANUP. Envoyé par l'I/O
                              manager quand le dernier handle sur
                              le file object se ferme. Dans le
                              driver final ce handler doit annuler
                              toute IRP qu'on a parkée dans
                              PENDING_IRP (Partie 4) ; pour
                              l'instant il retourne STATUS_SUCCESS.

    dispatch_device_control — IRP_MJ_DEVICE_CONTROL. Retourne
                              STATUS_INVALID_DEVICE_REQUEST pour
                              n'importe quel code IOCTL, puisque
                              IOCTL_WEDR_GET_EVENT n'est pas encore
                              câblé. La Partie 4 remplace ce stub
                              par le vrai handler.

Les routines de dispatch vivent dans `src/ipc/dispatch.rs`. Les garder dans leur 
propre module dès le jour 1 est gratuit et fait que l'extension de la Partie 4
s'insère proprement.
──[ 5. Device, Symlink, DO_BUFFERED_IO ]──
let mut device_name: UNICODE_STRING = core::mem::zeroed();
let device_name_buf: [u16; 18] = wstr16(b"\\Device\\WazabiEDR\0");
RtlInitUnicodeString(&mut device_name, device_name_buf.as_ptr());

let mut device: PDEVICE_OBJECT = ptr::null_mut();
let status = IoCreateDevice(
    driver,
    0,                       // DeviceExtensionSize
    &mut device_name,
    FILE_DEVICE_UNKNOWN,
    0,                       // DeviceCharacteristics
    0,                       // Exclusive = FALSE
    &mut device,
);
`DeviceWazabiEDR` est le nom du device dans le namespace d'objets kernel. Le 
namespace kernel est l'arbre global qu'arbitre l'Object Manager — c'est là que
vivent les objets kernel nommés (devices, events, mutexes, sections). Le user
mode ne peut pas l'atteindre par Win32 directement, d'où le symlink qui suit. `Exclusive = FALSE` (pas de `FILE_DEVICE_SECURE_OPEN`, pas de flags de
characteristics) parce qu'on sérialise l'accès client à une autre couche.
L'agent est le seul client prévu, et le slot single-pending-IRP en Partie 4
impose la sémantique single-client depuis le dessus ; rendre le device exclusif
ajouterait une porte redondante et compliquerait le comportement de
`IRP_MJ_CREATE` pour les scénarios de test. `DeviceExtensionSize = 0` parce qu'on n'utilise pas la *device extension* (un
buffer opaque alloué par le kernel qu'un driver utilise typiquement comme état
par-device-object). Notre état global vit dans `state.rs` comme statics
`AtomicPtr` et `SyncCell`, ce qui est plus aimable avec le modèle d'emprunt de
Rust qu'une struct d'extension auto-référentielle et retire la comptabilité de
durée de vie par-device-object. Le symlink :
let mut symlink: UNICODE_STRING = core::mem::zeroed();
let symlink_buf: [u16; 22] = wstr16(b"\\DosDevices\\WazabiEDR\0");
RtlInitUnicodeString(&mut symlink, symlink_buf.as_ptr());
let status = IoCreateSymbolicLink(&mut symlink, &mut device_name);
`DosDevicesWazabiEDR` est le chemin visible côté user mode. Le namespace DOS dev
ice est ce à travers quoi `CreateFileW(L"\\.\WazabiEDR", …)` de Win32 résout : l
e préfixe `\.` dit à Win32 d'aller chercher le reste sous `DosDevices` (dans le 
Windows moderne c'est un namespace scopé par session, mais un symlink créé par u
n driver kernel atterrit dans la session globale, ce qu'on veut).

Juste après les deux appels réussis :
(*device).Flags |= DO_BUFFERED_IO;
CONTROL_DEVICE.store(device, Ordering::Release);
`DO_BUFFERED_IO` est une des trois méthodes I/O que Windows supporte pour 
déplacer de la donnée entre user mode et un driver. En bref :
    Buffered I/O — l'I/O manager copie le buffer user dans un
                   SystemBuffer alloué côté kernel sur le chemin
                   entrant, et le recopie sur le chemin sortant.
                   Une copie par transfert. Le driver ne touche
                   jamais que de la mémoire kernel.

    Direct I/O   — le buffer user est verrouillé en mémoire
                   physique et mappé dans le kernel pour la durée
                   de l'IRP. Pas de copie, mais le driver doit
                   gérer les MDL (Memory Descriptor List).

    Neither      — le driver reçoit le pointeur user mode brut et
                   doit le valider et le probe lui-même. Le plus
                   rapide, le plus dangereux, utilisé par les
                   drivers qui font déjà leur propre page-locking.

Pour notre trafic IOCTL — un événement copié par appel, taille d'événement crête 
autour de 1 Kio — la copie buffered-I/O est invisiblement pas chère et elle
retire toute la classe d'embrouilles autour de la révocation de buffer user
mode, des page faults à IRQL élevé, et de la modification user mode concurrente
en pleine course. `CONTROL_DEVICE.store(…, Release)` publie le pointeur device au reste du driver.
`Release` se paire avec le `Acquire` load dans `DriverUnload` et dans tout
handler qui remonte vers le device. L'atomique existe au lieu d'un local mis en
cache parce que les routines de dispatch ont besoin d'y accéder et qu'on préfère
ne pas le faire passer à travers chaque signature.
──[ 6. DriverUnload — Pointeur Device Atomique ]──
unsafe extern "C" fn driver_unload(_driver: PDRIVER_OBJECT) {
    unsafe {
        // déregistration des callbacks — Partie 2
        // annulation IRP en attente, drainage queue — Partie 4

        let mut symlink: UNICODE_STRING = core::mem::zeroed();
        let symlink_buf: [u16; 22] = wstr16(b"\\DosDevices\\WazabiEDR\0");
        RtlInitUnicodeString(&mut symlink, symlink_buf.as_ptr());
        let _ = IoDeleteSymbolicLink(&mut symlink);

        let dev = CONTROL_DEVICE.swap(ptr::null_mut(), Ordering::AcqRel);
        if !dev.is_null() {
            IoDeleteDevice(dev);
        }
    }
}
Deux points d'ordering.

**Symlink avant device.** Le symlink est le chemin à travers lequel le user mode 
résout, donc le retirer en premier coupe les nouvelles tentatives de
`CreateFile` immédiatement. Retirer le device en premier laisserait une brève
fenêtre durant laquelle le symlink pointe vers un objet device en cours de
démontage — un thread qui résout le chemin pendant cette fenêtre verrait
`STATUS_NO_SUCH_DEVICE` (objet existe mais en démontage) sur un chemin qui
retournait précédemment `STATUS_SUCCESS`. L'ordre inverse fait que le mode
d'échec est `STATUS_OBJECT_NAME_NOT_FOUND` (chemin n'existe pas) depuis le
départ, ce qui est sans ambiguïté. **`swap` au lieu de `load`-puis-`store`.** Les routines de dispatch chargent
`CONTROL_DEVICE` sous leur propre opération atomique. Utiliser `swap` ici est
une étape unique "publier null et lire la valeur précédente" qui est race-free
contre n'importe quel `load(Acquire)` concurrent. Un `load` suivi d'un
`store(null)` aurait une fenêtre entre les deux pendant laquelle une routine de
dispatch pourrait récupérer le pointeur encore valide et continuer au-delà de
notre `IoDeleteDevice`. `swap` rétrécit cette fenêtre à zéro instruction. Les deux blocs commentés sont ce que les Parties 2 et 4 remplissent. L'ordre
qu'ils imposent dans le driver final n'est pas trivial : les callbacks doivent
être déregistrés en premier (pour qu'aucun callback ne puisse se déclencher
après qu'on commence à démonter la queue), puis l'IRP en attente doit être
annulée (pour que l'appel `DeviceIoControl` bloqué de l'agent retourne), puis la
queue drainée (relâchant les allocations pool des événements encore bufferisés),
puis le symlink et le device retirés. Chaque enregistrement a son propre flag
`AtomicBool` dans `state.rs` pour qu'on ne déregistre jamais deux fois — appeler
`PsRemoveCreateThreadNotifyRoutine` contre une routine qui n'a jamais été
enregistrée bug-checke le système, et le même pattern tient pour tout autre
appel `*Remove*` / `*Unregister*`.
──[ 7. Build, Sign, Load ]──

`cargo build --release` produit `target/release/WazabiEDR_Driver.sys`. Le `.inf` 
qui l'accompagne et les étapes post-build de `wdk-build` émettent les fichiers
catalog qu'une installation de production aurait besoin, bien que pour le
chargement local via `sc.exe` ils ne soient pas consultés. Posture de test-signing pour développement local :
bcdedit /set testsigning on
bcdedit /set hypervisorlaunchtype off   # si HVCI est actif
Reboot, puis :
sc.exe create WazabiEDR type= kernel binPath= C:\path\to\WazabiEDR_Driver.sys
sc.exe start  WazabiEDR
`testsigning` autorise le loader kernel à accepter des drivers signés par un 
certificat non-Microsoft, non-WHQL-cross-signé (y compris self-signed, y compris
non signés dans certaines configurations). HVCI (*Hypervisor-Protected Code
Integrity* : fonctionnalité de sécurité Windows qui fait tourner les checks Code
Integrity dans un processus isolé par l'hyperviseur) impose des règles plus
strictes par-dessus le Code Integrity standard et doit être off pour que des
drivers kernel test-signés chargent. Codes d'échec courants depuis `sc.exe start` :
    577  — driver non signé, TESTSIGNING off
    31   — DriverEntry a retourné un NTSTATUS négatif ; vérifier
           DbgView pour le statut spécifique
    1275 — bloqué par Code Integrity (TESTSIGNING off, ou HVCI on)
    1058 — le type de démarrage de service est faux ; type= kernel
           est requis

`DbgView` (utilitaire sysinternals qui capture la sortie `DbgPrint` kernel mode) 
montre les diagnostics du driver. L'instrumentation dans `lib.rs` imprime
`[WazabiEDR] DriverEntry called` à l'entrée, puis `[WazabiEDR] ready (…)` sur
succès, ou une ligne `[WazabiEDR] <étape> failed` identifiant le premier échec.
L'ordre des appels `DbgPrint` dans `DriverEntry` est informatif — il permet de
voir d'un coup d'œil jusqu'où l'initialisation est allée avant d'échouer.
──[ 8. La Suite ]──

Après ce post :
    ✓ device existe à \.WazabiEDR
    ✓ table de dispatch complètement peuplée (4 vrais + 1 handler
      par défaut)
    ✓ le chemin d'unload peut démonter ce qu'on a créé
    ✗ aucun callback kernel enregistré
    ✗ aucun flux d'événements
    ✗ aucun contrat IPC avec l'agent

La Partie 2 enregistre les cinq callbacks kernel 
(`PsSetCreateProcessNotifyRoutineEx`, `PsSetLoadImageNotifyRoutine`,
`CmRegisterCallback`, `PsSetCreateThreadNotifyRoutine`, `ObRegisterCallbacks`
contre `PsProcessType`) et déroule la forme de chaque callback et les morceaux
de télémétrie que chacun expose. L'échelle d'unwind qu'on a posée ici est
exactement ce qui rend l'ajout de cinq enregistrements faillibles dans
`DriverEntry` proprement faisable — chaque nouvelle étape reçoit un
`AtomicBool.store(true)` sur succès et sa propre étape de nettoyage préfixée au
chemin d'unwind.