<index> / <wazabiedr> / kernel-callbacks
[ en | fr ]
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Partie 2 — Les cinq callbacks kernel
~ lululufr
SOMMAIRE
  0  les cinq surfaces
  1  le pattern producteur partagé
  2  process create / exit
  3  image load
  4  mutations du registre
  5  thread create / exit
  6  accès aux handles de processus
  7  packed structs et addr_of_mut!

──[ 0. Les Cinq Surfaces ]──

Windows expose un petit ensemble d'APIs d'enregistrement de callbacks in-kernel 
qu'un EDR peut utiliser pour observer l'activité système en temps réel. Ce
driver en câble cinq. Chacun se déclenche synchronement quand une opération
particulière se passe n'importe où dans le système, et nous donne une vue
structurée de l'opération avant qu'elle ne commit :
   callback                      enregistré via                          se déclenche sur
   ───────────────────────────   ─────────────────────────────────────   ───────────────────────────
   process_notify                PsSetCreateProcessNotifyRoutineEx       process create / exit
   image_load_notify             PsSetLoadImageNotifyRoutine             chaque load DLL / EXE / driver
   registry_notify               CmRegisterCallback                      opérations de registre
   thread_notify                 PsSetCreateThreadNotifyRoutine          thread create / exit
   process_object_notify         ObRegisterCallbacks(PsProcessType)      OpenProcess / DuplicateHandle
Ces cinq couvrent ensemble l'essentiel de la surface des techniques offensives 
auxquelles un EDR s'intéresse : injection de processus (`process_object_notify`
+ `thread_notify`), credential dumping (`process_object_notify`), persistance
via registre (`registry_notify`), chargement de module in-process
(`image_load_notify`), et création de processus enfants (`process_notify`). Les
autres surfaces — I/O fichier, réseau — demandent des mécanismes
d'enregistrement supplémentaires (minifilters de système de fichiers, callouts
Windows Filtering Platform) qui sont hors du périmètre de ce driver. Par la Partie 0, chaque callback est *en observation seule* : on lit les
paramètres de l'opération et on émet un événement de télémétrie, puis on
retourne un statut qui autorise l'opération à se poursuivre sans modification.
──[ 1. Le Pattern Producteur Partagé ]──

Chaque module de callback suit le même rituel en quatre étapes :
let size = core::mem::size_of::<SomeEvent>() as u32;
let buf  = alloc_event(size);
if buf.is_null() {
    return;
}
// zéro du buffer, remplissage de la struct, puis…
submit_event(buf, size);
`alloc_event` taille un bloc de `size` octets dans le pool non-paginé (rendant 
`null` sur échec d'allocation). `submit_event` (Partie 4) passe le bloc à la
queue, qui le copie soit directement dans une IRP agent parkée, soit le pousse
dans le ring buffer. Trois comportements sont partagés par chaque callback :
    - Le buffer est mis à zéro d'abord via ptr::write_bytes(buf,
      0, size). Les octets de queue non utilisés (slack de
      chemin tronqué, padding) partent alors comme zéros plutôt
      que de fuir de la mémoire pool non initialisée vers le
      user mode.

    - Les champs sont écrits via core::ptr::addr_of_mut! et
      ptr::write, jamais via &mut. Les structs d'événements
      sont repr(C, packed), et former une référence normale
      dans une struct packed peut produire une référence
      désalignée, ce qui est de l'undefined behaviour en Rust
      même sans déréférencement. La section 7 plus bas couvre
      la règle en détail.

    - Sur échec d'allocation, le callback retourne en silence.
      Logger est en soi une allocation ; une pression
      d'allocation récursive aggraverait l'échec. L'agent
      apprend le gap par le compteur de drops dans le header
      du prochain événement livré avec succès (voir ci-dessous).

L'`EventHeader` est stampé par un helper partagé :
pub unsafe fn make_header(type_: u16, size: u32) -> EventHeader {
    let mut ts = LARGE_INTEGER { QuadPart: 0 };
    unsafe { KeQuerySystemTimePrecise(&mut ts) };
    EventHeader {
        version:    EVENT_VERSION,
        type_,
        timestamp:  unsafe { ts.QuadPart },
        size,
        drop_count: DROP_COUNT.swap(0, Ordering::Relaxed),
    }
}
`KeQuerySystemTimePrecise` rend l'heure système courante comme un 
`LARGE_INTEGER` (entier signé sur 64 bits dans `QuadPart`) comptant des ticks de
100 nanosecondes depuis le 1er janvier 1601 UTC — l'époque FILETIME. Résolution
sub-microseconde, c'est la résolution qu'on expédie ; l'agent ne re-stampe pas. Le `DROP_COUNT.swap(0, …)` lit atomiquement le compte de drops courant et le
reset. Chaque événement livré avec succès porte donc le nombre exact
d'événements que le driver a dû évincer depuis la dernière livraison. L'agent
reconstruit un signal de gap à partir de ce seul champ, sans avoir besoin d'un
type d'événement "perte" séparé.
──[ 2. Process Create / Exit ]──

Enregistré via `PsSetCreateProcessNotifyRoutineEx`. Le kernel appelle le 
callback avec une `PPS_CREATE_NOTIFY_INFO` non nulle pour create et `null` pour
exit :
pub unsafe extern "C" fn process_notify(
    _process: PEPROCESS,
    process_id: wdk_sys::HANDLE,
    create_info: PPS_CREATE_NOTIFY_INFO,
) {
    let pid = process_id as usize as u32;
    unsafe {
        if create_info.is_null() {
            emit_process_exit(pid);
        } else {
            emit_process_create(pid, create_info);
        }
    }
}
`PEPROCESS` est un pointeur vers la structure `EPROCESS` du kernel pour le 
processus concerné. On ne s'en sert pas ici — `process_id` suffit — mais ce
serait le point d'entrée si on avait jamais besoin de remonter au token du
processus ou à son espace d'adressage. Sur create on capture trois identifiants depuis la structure
`PS_CREATE_NOTIFY_INFO` :
let parent  = (*info).ParentProcessId as usize as u32;
let creator = (*info).CreatingThreadId.UniqueProcess as usize as u32;
`ParentProcessId` est le PID que Windows considère comme parent. 
`CreatingThreadId.UniqueProcess` est le PID du processus qui a effectivement
appelé `CreateProcess`. Normalement ils sont d'accord. Quand ils diffèrent, le
créateur utilise l'attribut `PROC_THREAD_ATTRIBUTE_PARENT_PROCESS` pour altérer
le parent apparent — une technique utilisée à la fois par des chemins
d'élévation légitimes (un processus qui hérite du shell explorer) et par le
parent-PID spoofing offensif. Le driver ne tranche pas ; il expose les deux et
laisse l'agent (ou une règle en aval) décider. Le chemin d'image est un chemin NT (par exemple `DeviceHarddiskVolume3…foo.exe`,
dans le namespace kernel plutôt que celui DOS) copié jusqu'à `IMAGE_PATH_MAX -
1` unités UTF-16. La réservation d'une unité sous `MAX` est la convention de
marqueur de troncature utilisée dans tout le wire format (Partie 3) : le champ
de longueur ne peut égaler `MAX - 1` que quand le chemin était plus long que le
buffer.
──[ 3. Image Load ]──

`PsSetLoadImageNotifyRoutine` se déclenche chaque fois qu'une image PE (Portable 
Executable : le format binaire Windows utilisé pour `.exe`, `.dll`, `.sys`,
`.cpl` etc.) est mappée dans un processus. La couverture inclut :
    - DLL chargées dans des processus user mode (attrape
      l'injection, LoadLibrary, le search-order hijacking)
    - L'EXE principal d'un processus fraîchement créé
    - Drivers et modules en kernel mode (pertinent rootkit)

Les chargements côté kernel arrivent avec `process_id == 0` (la valeur NULL 
`HANDLE`). On forwarde ça verbatim ; le user mode gère la distinction.
pub unsafe extern "C" fn image_load_notify(
    full_image_name: PUNICODE_STRING,
    process_id: HANDLE,
    image_info: PIMAGE_INFO,
) {
    let pid = process_id as usize as u32;
    unsafe { emit_image_load(pid, full_image_name, image_info) };
}
`PIMAGE_INFO` porte `ImageBase` et `ImageSize`. Les deux sont capturés. La paire 
permet à l'agent de calculer la plage d'adresses de l'image chargée et de
corréler des valeurs d'instruction-pointer ultérieures (par exemple depuis des
traces de pile dans des événements suivants) contre le module qui les contenait
:
let base = (*image_info).ImageBase as usize as u64;
let img_size = (*image_info).ImageSize as u64;
ptr::write(addr_of_mut!((*evt).image_base), base);
ptr::write(addr_of_mut!((*evt).image_size), img_size);
MSDN documente que `full_image_name` et `ImageInfo` peuvent être `null` dans des 
cas limites (chargements kernel très précoces, certaines sections mappées). Le
callback gère les deux en laissant les champs de sortie correspondants à zéro —
le reste de l'événement est tout de même émis.
──[ 4. Mutations du Registre ]──

`CmRegisterCallback` (où `Cm` est pour Configuration Manager, le sous-système 
kernel qui possède le registre) est le plus bruyant des cinq s'il est utilisé
sans filtre. Chaque opération de registre — lectures incluses — passe par le
callback, sur chaque thread du système. La télémétrie côté lecture est à fort
volume et faible valeur à cette couche, donc le callback drop les lectures à
l'entrée et n'émet d'événements que pour les cinq opérations mutantes :
   RegNtPreSetValueKey      → valeur en cours d'écriture
   RegNtPreDeleteValueKey   → valeur en cours de suppression
   RegNtPreDeleteKey        → clé en cours de suppression
   RegNtPreRenameKey        → clé en cours de renommage
   RegNtPreCreateKeyEx      → nouvelle sous-clé en cours de
                              création (avec disposition de
                              création)
Le préfixe "Pre" veut dire que la notification se déclenche *avant* que le 
configuration manager ne commit l'opération, ce qui est le contrat qu'on veut —
le callback pourrait refuser l'opération en retournant un `NTSTATUS` d'échec.
Par la posture observation seule, on retourne toujours `STATUS_SUCCESS`,
laissant l'opération se dérouler sans modification. Un mécanisme spécifique au callback registre mérite mention. Le champ `Object`
de la notification est un `PVOID` opaque (pointeur vers void) qui doit être
résolu en nom complet de la clé via `CmCallbackGetKeyObjectIDEx`. Cet appel
alloue internement un buffer pour le nom ; le `CmCallbackReleaseKeyObjectIDEx`
correspondant doit être invoqué une fois qu'on a fini de copier, sinon le
configuration manager fuit du pool internement — silencieusement mais
indéfiniment :
let status = CmCallbackGetKeyObjectIDEx(
    &mut cookie, object, &mut object_id, &mut name, 0,
);
if status < 0 || name.is_null() {
    return 0;
}
let written = copy_unicode_into(name, dst, max);
CmCallbackReleaseKeyObjectIDEx(name);   // paire obligatoire
L'appel de release est un ajout d'une ligne qui n'ajoute rien au comportement 
fonctionnel et retire une fuite lente qui sinon s'accumule sur des heures
d'uptime — facile à omettre, cher à diagnostiquer après.
──[ 5. Thread Create / Exit ]──

`PsSetCreateThreadNotifyRoutine` est le callback le moins cher qu'on enregistre 
: trois champs `u32` par événement, pas de copie de chemin, pas de round-trip
Configuration Manager. La valeur de détection est dans le troisième champ du
chemin create :
unsafe fn emit_thread_create(pid: u32, tid: u32) {
    let creator = PsGetCurrentProcessId() as usize as u32;
    // pid     = processus dans lequel le nouveau thread tourne
    //           (son propriétaire)
    // tid     = ID kernel du nouveau thread
    // creator = processus qui a appelé CreateThread /
    //           CreateRemoteThread
}
Trois identifiants, trois relations. Quand `pid == creator` le thread est un 
thread in-process normal. Quand `pid != creator`, le processus créateur a spawné
un thread à l'intérieur d'un autre processus — `CreateRemoteThread`, ou une de
ses équivalents `Nt*`. Ce pattern couvre les primitives standard d'injection de
DLL et d'exécution de shellcode, plus les patterns inter-processus légitimes
(CSRSS qui initialise un processus, debuggers). Le driver ne classifie pas ; il
expose les trois IDs et laisse l'agent ou le moteur de règles décider. Le chemin exit est symétrique et encore moins cher — seulement `pid` et `tid`.
Il n'y a pas de "raison d'exit" fournie par le kernel ni d'identité du thread
terminant à capturer.
──[ 6. Accès aux Handles de Processus ]──

`ObRegisterCallbacks` est l'enregistrement le plus lourd des cinq, mais la 
surface qu'il expose est la plus directement weaponisable dans les techniques
offensives. Chaque `OpenProcess` (`NtOpenProcess` à la couche syscall) et
`DuplicateHandle` contre un objet Process passe par `process_object_notify`
avant que le handle ne soit rendu à l'appelant. Quelques exemples concrets de ce que ça attrape :
    - mimikatz qui lit la mémoire de LSASS :
        OpenProcess(LSASS, PROCESS_VM_READ | PROCESS_QUERY_INFORMATION)
    - des injecteurs de DLL qui préparent une cible :
        OpenProcess(target, PROCESS_VM_OPERATION | PROCESS_VM_WRITE
                            | PROCESS_CREATE_THREAD)
    - des process killers (tampering AV) :
        OpenProcess(av_pid, PROCESS_TERMINATE)
    - blanchiment de handle :
        DuplicateHandle(source, h, target, …, DUPLICATE_SAME_ACCESS)

Le volume par défaut sur cette surface est ingérable — chaque 
`OpenProcess(QUERY_LIMITED_INFORMATION)` de chaque service système passerait par
le callback. Deux filtres le dompte :
pub const DANGEROUS_PROCESS_MASK: u32 = PROCESS_TERMINATE
    | PROCESS_CREATE_THREAD
    | PROCESS_VM_OPERATION
    | PROCESS_VM_READ
    | PROCESS_VM_WRITE
    | PROCESS_DUP_HANDLE
    | PROCESS_SUSPEND_RESUME;
Premier filtre : laisser tomber toute requête dont `DesiredAccess` n'intersecte 
pas ce mask. Les bits omis (`PROCESS_QUERY_LIMITED_INFORMATION`, `SYNCHRONIZE`,
les divers `STANDARD_RIGHTS_*`) sont suffisamment courants à la couche kernel
pour être effectivement du bruit. Second filtre : laisser tomber les opens same-process (`source_pid ==
target_pid`). Le démarrage de processus implique que `ntdll` et le kernel
ouvrent répétitivement des self-handles ; les logger n'ajoute aucun signal. L'original `DesiredAccess` et celui (potentiellement modifié) courant sont tous
les deux émis sur le wire. D'autres callbacks OB installés dans la chaîne — EDR
concurrents, produits AV — peuvent stripper des droits avant que notre callback
ne tourne, et l'agent peut détecter ce stripping en comparant les deux valeurs. La fonction retourne `OB_PREOP_SUCCESS` inconditionnellement — elle ne modifie
pas le mask d'accès, ne refuse pas l'opération. Le hook est câblé comme un EDR
bloquant le câblerait, et le choix de bloquer est à un flag de distance.
Aujourd'hui le choix est "toujours autoriser".
──[ 7. Packed Structs et addr_of_mut! ]──

Une forme récurrente dans tous les callbacks qui mérite d'être exposée en un 
seul endroit. Les structs du wire format (Partie 3) sont `#[repr(C, packed)]`
(un attribut Rust qui désactive le padding de structure — tous les champs sont
disposés à des offsets en octets consécutifs sans insertion d'octets
d'alignement). La conséquence, c'est qu'un champ arbitraire dans une struct
packed peut être à une adresse désalignée par rapport à l'alignement naturel de
son type. Rust traite la formation d'une référence désalignée (`&T` ou `&mut T`) comme de
l'undefined behaviour, même si la référence n'est jamais déréférencée. rustc
moderne émet un lint `safe_packed_borrows` sur les cas évidents, mais le lint
est best-effort et la règle sous-jacente s'applique quel que soit. La manière cohérente d'écrire un champ packed — utilisée partout dans le code
des callbacks — c'est de prendre son pointeur brut avec `addr_of_mut!` et
d'écrire au travers :
ptr::write(addr_of_mut!((*evt).process_id), pid);
La forme qui se comporte mal, même si elle a l'air idiomatique, c'est :
let r = &mut (*evt).process_id;   // ref désalignée → UB
*r = pid;
L'exception dans les callbacks, c'est `thread_notify`, qui construit tout 
l'événement en un seul `ptr::write` d'un literal de struct entièrement
construit. Ça marche parce que la struct est petite et que chaque champ se
trouve tomber sur un offset aligné de par le fait d'avoir le même type et la
même largeur. Si on ajoutait un `u16` entre les `u32` existants, le même chemin
de code deviendrait UB. Un commentaire dans `thread_notify` signale ça pour que
le prochain contributeur ne répète pas le pattern aveuglément. Prochain post : le wire format lui-même — `EventHeader`, les buffers à taille
fixe, la négociation de version, et la raison pour laquelle serde n'est pas
impliqué.