SOMMAIRE
0 périmètre de la frontière
1 pourquoi pas serde
2 le header d'événement
3 le discriminant de type
4 buffers à taille fixe et politique de troncature
5 utf-16 partout
6 les sept types d'événements
7 versioning
──[ 0. Périmètre de la Frontière ]──
Le wire format décrit ici vit à une seule frontière : entre le driver kernel et
l'agent user mode, sur le même hôte Windows. Chaque octet qu'on écrit ici est lu
par un processus agent qui ouvre `\.WazabiEDR` sur la même machine, dans la même
installation OS. Rien sur ce wire ne traverse jamais un réseau.
Cette contrainte dicte presque chaque décision de ce post. Pas besoin que le
format soit self-describing, évolutif entre vendors, ou lisible sans les headers
C/Rust correspondants. Les deux côtés livrent ensemble ; sur un build mismatché,
refuser de parser est strictement mieux que de deviner. Le résultat est un
protocole binaire à layout fixe, stampé en version, qui existe en deux
déclarations miroirs — une dans `WazabiEDR_Driver::events`, une dans
`WazabiEDR_Agent::ipc::events` — construites pour matcher octet par octet.
──[ 1. Pourquoi Pas Serde ]──
Serde (`serde` : le framework de facto de sérialisation dans l'écosystème Rust,
composé d'un cœur runtime-agnostic et de crates par-format comme `serde_json`,
`bincode`, `ciborium`) est le premier réflexe pour toute sérialisation
in-process en Rust. Il ne convient pas ici.
La moitié kernel du driver est `no_std` et opère sous des budgets pool
non-paginé serrés. `serde` lui-même est `no_std`-compatible, mais la plupart des
sérialiseurs binaires utiles construits dessus — `bincode`, CBOR — tirent des
dépendances transitives qui se battent avec `no_std` ou attendent un
comportement d'allocateur qu'on ne veut pas sur le hot path.
Plus important, la frontière, c'est une paire de processus sur un seul hôte. Pas
de négociation de format, pas de compatibilité cross-vendor, pas d'inspection
par des saut intermédiaires. L'agent et le driver sont toujours d'accord sur le
wire parce qu'ils ont été buildés et déployés ensemble. Un format
self-describing ajoute des octets (tags de type, noms de champs) et du CPU
(boucles encodeur/décodeur) sans rien acheter.
Donc le wire format, c'est des structs `#[repr(C, packed)]` à la main. Le driver
les écrit en faisant des `ptr::write` champ par champ dans un buffer pool ;
l'agent les lit en faisant des `ptr::read_unaligned` dans des structs miroirs.
Pas de sérialiseur, pas de parser, pas de framing au-delà d'un header qui porte
la longueur.
──[ 2. Le Header D'Événement ]──
Chaque événement sur le wire commence par le même header de 20 octets :
#[repr(C, packed)]
pub struct EventHeader {
pub version: u16,
pub type_: u16,
pub timestamp: i64,
pub size: u32,
pub drop_count: u32,
}
Chaque champ gagne sa place :
`version` est la version de schéma (`EVENT_VERSION = 3`). L'agent la lit en
premier et rejette l'événement entier sur mismatch — il n'essaie pas de parser
le reste. Vieux driver contre nouvel agent (ou l'inverse) donne une erreur
propre et bruyante plutôt qu'une mauvaise interprétation silencieuse.
`type_` est le discriminant de type d'événement. Il sélectionne laquelle des
sept structs (voir Section 6) le reste des octets représente.
`timestamp` est une valeur FILETIME 64 bits (FILETIME : la représentation de
temps de Windows — entier signé sur 64 bits comptant des ticks de 100
nanosecondes depuis le 1er janvier 1601 UTC). Capturé chez le producteur via
`KeQuerySystemTimePrecise`, qui est sub-microseconde. L'agent ne re-stampe pas ;
l'heure kernel fait foi.
`size` est la taille totale de l'événement en octets, header inclus. L'agent
l'utilise pour avancer à l'événement suivant sans avoir besoin du type.
`drop_count` est le compteur courant d'événements évincés du ring buffer depuis
le dernier événement livré avec succès (Partie 2). Zéro en régime établi ; les
valeurs non nulles sont un signal de gap que l'agent expose en amont.
──[ 3. Le Discriminant de Type ]──
#[repr(u16)]
pub enum EventType {
ProcessCreate = 1,
ProcessExit = 2,
ImageLoad = 3,
RegistryModify = 4,
ThreadCreate = 5,
ThreadExit = 6,
ProcessHandleAccess = 7,
}
Les valeurs du discriminant sont explicites (`= 1`, `= 2`, …) plutôt que
laissées au compilateur. La valeur sur le wire du discriminant survit au code
source — un événement capturé dans un dump de debug ou dans une couche de
journalisation en aval peut être décodé par un build différent de celui qui l'a
écrit. Réordonner les variants en Rust sans spécifier les valeurs déplacerait
silencieusement les numéros sur le wire, cassant le parsing cross-version.
Deux sous-discriminants vivent à l'intérieur des payloads où un type parent
couvre plusieurs opérations :
#[repr(u16)]
pub enum HandleAccessOp {
Create = 1, // OB_OPERATION_HANDLE_CREATE
Duplicate = 2, // OB_OPERATION_HANDLE_DUPLICATE
}
#[repr(u16)]
pub enum RegistryOp {
SetValue = 1,
DeleteValue = 2,
DeleteKey = 3,
RenameKey = 4,
CreateKey = 5,
}
Les deux sont dans le payload de l'événement, pas dans le header. Ils n'ont de
sens que pour leur type d'événement contenant et seraient des octets gaspillés
sur les autres.
──[ 4. Buffers à Taille Fixe et Politique de Troncature ]──
Chaque champ de longueur variable sur le wire est un buffer à taille fixe pairé
à un compagnon `len`. Le `ProcessCreateEvent` est représentatif :
#[repr(C, packed)]
pub struct ProcessCreateEvent {
pub header: EventHeader,
pub process_id: u32,
pub parent_process_id: u32,
pub creating_process_id: u32,
pub image_path: [u16; IMAGE_PATH_MAX], // IMAGE_PATH_MAX = 512
pub image_path_len: u16,
}
Le `[u16; 512]` fixe, c'est 1024 octets partiellement gaspillés sur chaque
événement avec un chemin plus court que 512 unités UTF-16. Le trade est
volontaire.
Un événement à taille variable dans la moitié kernel demanderait un chemin
d'allocation par taille d'événement, une logique de copie multi-étapes dans le
ring buffer, et soit un framing explicite soit des headers de longueur par
événement des deux côtés. Le layout actuel a une allocation, une copie, et
`size_of::<T>()` est connu statiquement à la fois côté producteur et côté
consommateur.
La troncature est uniforme sur chaque champ de longueur variable :
- copier jusqu'à MAX - 1 unités
- enregistrer le compte dans le champ len correspondant
- laisser la queue inutilisée du buffer à zéro
La réservation d'une unité sous `MAX` est la convention de marqueur de
troncature : quand `len == MAX - 1`, la source était plus longue que le buffer ;
quand `len < MAX - 1`, la source rentrait. Sans la réservation, un chemin qui
remplissait exactement le buffer serait indistinguable d'un qui était tronqué.
──[ 5. UTF-16 Partout ]──
Tous les champs de chaîne sont `[u16; N]`, jamais `[u8; N]`. Windows nous passe
des instances `UNICODE_STRING` — buffers UTF-16 little-endian avec longueur en
octets et sans NUL terminal — et toute conversion vers UTF-8 dans le kernel
demanderait :
- un décodeur UTF-16 tournant à un IRQL potentiellement élevé
- un buffer destination dont la taille dépend du contenu
- un réencodage à chaque événement
Tout ça au bénéfice de l'agent, qui a déjà un chemin UTF-16 → UTF-8 parfaitement
bon dans `std`. Le driver expédie de l'UTF-16 brut, l'agent décode une fois à la
frontière.
Le champ `len` compte des unités UTF-16, pas des octets. Les comptes en octets
seraient une petite mais récurrente source de bugs — "est-ce que j'ai divisé par
2 ici ?" — donc le champ est en unités partout. Le décodage correspondant côté
agent se borne par le même compte d'unités.
──[ 6. Les Sept Types D'Événements ]──
Pour l'exhaustivité, l'ensemble complet tel qu'il apparaît dans `events.rs` :
#[repr(C, packed)] pub struct ProcessExitEvent { … pub process_id: u32; }
#[repr(C, packed)] pub struct ImageLoadEvent {
pub header: EventHeader,
pub process_id: u32,
pub image_base: u64, // adresse de load dans la cible / kernel
pub image_size: u64, // octets
pub image_path: [u16; IMAGE_PATH_MAX],
pub image_path_len: u16,
}
#[repr(C, packed)] pub struct ThreadCreateEvent {
pub header: EventHeader,
pub process_id: u32, // propriétaire
pub thread_id: u32,
pub creating_process_id: u32, // != process_id → thread remote
}
#[repr(C, packed)] pub struct ThreadExitEvent { …
pub process_id: u32;
pub thread_id: u32;
}
#[repr(C, packed)] pub struct ProcessHandleAccessEvent {
pub header: EventHeader,
pub source_process_id: u32,
pub target_process_id: u32,
pub desired_access: u32, // possiblement modifié par
// les callbacks OB en amont
pub original_desired_access: u32, // tel que demandé par l'appelant
pub operation: u16, // HandleAccessOp
}
#[repr(C, packed)] pub struct RegistryEvent {
pub header: EventHeader,
pub process_id: u32,
pub operation: u16, // RegistryOp
pub value_type: u32, // REG_SZ / REG_DWORD / …
pub data_size: u32, // taille réelle (non tronquée)
pub key_path: [u16; REGISTRY_KEY_PATH_MAX], // 512
pub key_path_len: u16,
pub value_name: [u16; REGISTRY_VALUE_NAME_MAX], // 128
pub value_name_len: u16,
pub data_preview: [u8; REGISTRY_DATA_PREVIEW_MAX], // 256
pub data_preview_len: u16,
}
`RegistryEvent` est le plus gros. Deux de ses choix de design méritent un regard
plus proche.
`data_preview` porte jusqu'à 256 octets de la valeur en cours d'écriture par un
`RegNtPreSetValueKey`. `data_size` rapporte la longueur réelle, non tronquée :
les petites écritures `REG_DWORD` / `REG_SZ` sont capturées en entier et l'agent
peut le vérifier avec `data_size == data_preview_len`. Pour de gros blobs
`REG_BINARY`, l'agent voit les 256 premiers octets plus la taille réelle, ce qui
suffit pour signaler l'écriture sans faire gonfler la mémoire par-événement.
`ProcessHandleAccessEvent` porte à la fois le `DesiredAccess` courant et
l'original. La chaîne de callbacks OB peut avoir plusieurs drivers enregistrés,
et n'importe lequel d'entre eux — EDR concurrents, produits AV — peut stripper
des bits avant que notre callback ne tourne. Le double champ permet à l'agent
d'observer que la surface d'accès visible pour nous diffère de ce que l'appelant
a demandé, ce qui est en soi du signal.
──[ 7. Versioning ]──
pub const EVENT_VERSION: u16 = 3;
Le driver et l'agent consomment tous les deux la constante. L'agent rejette tout
événement dont `header.version` ne vaut pas le sien ; le driver écrit la sienne.
La politique de bump :
- Tout changement à l'offset, à la taille, ou au sens
sémantique d'un champ demande un bump de version.
- Ajouter un nouveau EventType ne bump PAS la version.
L'agent ignore les types inconnus — il avance en
utilisant header.size et continue.
Forward-compatible par construction.
- Ajouter un champ à une struct existante BUMP. Même si
le nouveau champ est en queue, le size_of::<T> local
de l'agent changerait, et celui du producteur ne
matcherait pas.
Le comportement "reject-on-mismatch" de l'agent est la pièce porteuse de
l'histoire d'évolution de ce protocole. Une VM de dev qui fait tourner un vieux
driver contre un agent neuf expose la différence de version au moment du
handshake — le premier événement que l'agent lit — plutôt que de corrompre
l'interprétation silencieusement. Le coût de cette strictness, c'est un message
d'erreur propre ; le coût de l'alternative, c'est des heures de symptômes mal
diagnostiqués en aval.
Prochain post : le ring buffer qui absorbe les événements en sortie, et l'IOCTL
en appel inversé qui le draine.