Accès aux fichiers depuis un driver WDM

Ce cours dédiée au développement de drivers en kernel mode sans Framework

Moderator: Rick

Post Reply
Hydraxx
Site Admin
Posts: 124
Joined: Mon Jan 12, 2026 4:04 pm
Location: France
Contact:

Accès aux fichiers depuis un driver WDM

Post by Hydraxx »

Accès aux fichiers depuis un driver WDM

Objectif

Ce chapitre présente l’accès aux fichiers depuis le mode noyau Windows dans un driver WDM. Il couvre les contraintes d’exécution, la préparation des noms et des attributs d’objets, l’ouverture et la création de fichiers avec ZwCreateFile, la lecture et l’écriture avec ZwReadFile et ZwWriteFile, la récupération d’informations avec ZwQueryInformationFile, la portée des handles, l’utilisation de OBJ_KERNEL_HANDLE et les précautions liées au moment où les opérations fichier sont réalisées pendant l’initialisation du système.

L’accès fichier depuis le noyau ressemble conceptuellement à l’accès fichier en user mode, mais il s’effectue directement au travers des routines natives du noyau et obéit à des contraintes plus strictes.

1. Pourquoi accéder à un fichier depuis un driver ?

Un driver peut avoir besoin d’utiliser un fichier pour plusieurs raisons :
  • charger une configuration ;
  • charger un firmware ou un microcode ;
  • lire des données nécessaires au périphérique ;
  • écrire un journal technique ;
  • sauvegarder des informations produites par le driver ;
  • charger un fichier auxiliaire appartenant au pilote.
Il faut cependant éviter de considérer l’accès fichier noyau comme une alternative générale aux opérations user mode. Un driver s’exécute dans un contexte privilégié : une erreur peut bloquer le système entier.

La règle générale reste donc :

le driver ne doit accéder directement aux fichiers que lorsqu’il existe une raison technique claire de le faire.


2. Les routines Zw*

Windows fournit plusieurs routines natives préfixées par

Code: Select all

Zw
permettant au code kernel de manipuler des objets du système.

Pour les fichiers, les principales routines étudiées ici sont :
  • Code: Select all

    ZwCreateFile
    : ouvre ou crée un fichier ;
  • Code: Select all

    ZwReadFile
    : lit des données ;
  • Code: Select all

    ZwWriteFile
    : écrit des données ;
  • Code: Select all

    ZwQueryInformationFile
    : récupère des informations sur le fichier ;
  • Code: Select all

    ZwClose
    : ferme un handle.
Elles travaillent avec les mécanismes généraux du noyau Windows :
Ces structures apparaissent dans de nombreuses autres parties de la programmation kernel : registre, processus, objets NT, fichiers, sections, etc.


3. Contraintes d’exécution

L’accès fichier depuis un driver n’est pas autorisé dans n’importe quel contexte.

Les opérations fichier avec les routines Zw* doivent être exécutées à PASSIVE_LEVEL.

Cela signifie qu’elles ne doivent pas être réalisées depuis un contexte où l’IRQL est élevé, par exemple depuis une routine DPC à

Code: Select all

DISPATCH_LEVEL
.

Exemple :

Code: Select all

if (KeGetCurrentIrql() != PASSIVE_LEVEL)
{
    return STATUS_INVALID_DEVICE_STATE;
}
En pratique, il est souvent inutile de tester systématiquement l’IRQL lorsque l’architecture du driver garantit déjà que la fonction est appelée à

Code: Select all

PASSIVE_LEVEL
. L’important est de connaître cette contrainte.

Les opérations fichier peuvent provoquer une attente : le système peut devoir accéder au cache, au système de fichiers ou au périphérique de stockage.

Une routine exécutée à IRQL élevé ne peut pas effectuer arbitrairement ce type d’attente.


4. APC et régions critiques

Les opérations fichier nécessitent également un contexte de thread compatible avec les mécanismes d’attente du noyau.

Une opération fichier ne doit pas être lancée dans une situation où les APC nécessaires à l’I/O sont bloquées.

Par exemple,

Code: Select all

KeEnterCriticalRegion
désactive la livraison de certaines APC kernel normales pour le thread courant.

Le principe à retenir est :

les primitives de synchronisation qui élèvent l’IRQL ou bloquent les APC peuvent être incompatibles avec les opérations fichier.

Il faut donc éviter de conserver une région critique ou un verrou incompatible pendant l’appel à une routine telle que

Code: Select all

ZwReadFile
ou

Code: Select all

ZwWriteFile
.


5. Préparer le nom du fichier

Les routines natives Windows ne prennent généralement pas directement un simple

Code: Select all

wchar_t*
.

Le nom de l’objet est décrit par une structure :

Code: Select all

UNICODE_STRING
Exemple :

Code: Select all

UNICODE_STRING fileName;

RtlInitUnicodeString(
    &fileName,
    L"\\??\\C:\\Temp\\data.bin"
);
Une autre forme fréquente consiste à utiliser un chemin système, par exemple :

Code: Select all

L"\\SystemRoot\\Temp\\data.bin"
Dans le noyau, il faut distinguer le namespace NT des chemins Win32 classiques.

Un chemin tel que :

Code: Select all

C:\Temp\data.bin
n’est pas directement le format d’objet NT traditionnel attendu par de nombreuses routines natives.

Le préfixe :

Code: Select all

\??\
permet d’utiliser une notation correspondant aux noms DOS.

Exemple :

Code: Select all

\??\C:\Temp\data.bin

6. OBJECT_ATTRIBUTES

Après avoir construit le

Code: Select all

UNICODE_STRING
, le nom est encapsulé dans une structure :

Code: Select all

OBJECT_ATTRIBUTES
Cette structure décrit notamment :
  • le nom de l’objet ;
  • un éventuel handle de répertoire racine ;
  • les attributs d’ouverture ;
  • la sécurité ;
  • la qualité de service de sécurité.
La macro la plus courante est :

Code: Select all

InitializeObjectAttributes
Exemple :

Code: Select all

OBJECT_ATTRIBUTES attributes;

InitializeObjectAttributes(
    &attributes,
    &fileName,
    OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
    NULL,
    NULL
);

7. OBJ_CASE_INSENSITIVE

L’attribut :

Code: Select all

OBJ_CASE_INSENSITIVE
demande au gestionnaire d’objets d’effectuer la résolution du nom sans tenir compte de la casse lorsque le type d’objet le permet.

Il s’agit d’un attribut très fréquemment utilisé pour les fichiers.


8. OBJ_KERNEL_HANDLE

L’attribut :

Code: Select all

OBJ_KERNEL_HANDLE
est particulièrement important dans un driver.

Un handle normal est associé à la table de handles du processus dans lequel le thread courant s’exécute.

Or un driver peut être appelé dans le contexte de processus très différents.

Si un driver crée un handle sans

Code: Select all

OBJ_KERNEL_HANDLE
, ce handle peut appartenir au processus courant au moment de l’ouverture.

Cela peut provoquer plusieurs problèmes :
  • le handle est lié à un processus utilisateur ;
  • le processus peut terminer ;
  • le handle pourrait être visible ou manipulable dans un contexte qui ne devrait pas y avoir accès ;
  • un thread différent peut ensuite exécuter le code du driver dans un autre processus.
Avec :

Code: Select all

OBJ_KERNEL_HANDLE
le handle est traité comme un handle réservé au mode noyau.

Dans un driver, lorsqu’un handle doit appartenir au driver et non au processus utilisateur courant, OBJ_KERNEL_HANDLE est généralement le choix approprié.


9. IO_STATUS_BLOCK

Les opérations natives d’I/O utilisent souvent :

Code: Select all

IO_STATUS_BLOCK
Cette structure contient principalement deux informations importantes :
  • le statut final de l’opération ;
  • la quantité d’informations transférées.
Exemple :

Code: Select all

IO_STATUS_BLOCK ioStatus;
Après une lecture ou une écriture, le membre :

Code: Select all

ioStatus.Information
peut représenter le nombre d’octets réellement transférés.

Il ne faut donc pas supposer qu’une opération a nécessairement traité exactement la taille demandée.


10. Ouvrir un fichier avec ZwCreateFile

La routine centrale est :

Code: Select all

ZwCreateFile
Malgré son nom, elle ne sert pas uniquement à créer un fichier.

Elle permet également :
  • d’ouvrir un fichier existant ;
  • de créer un nouveau fichier ;
  • d’écraser un fichier ;
  • d’ouvrir ou créer selon l’existence du fichier.
Exemple général :

Code: Select all

HANDLE fileHandle = NULL;
IO_STATUS_BLOCK ioStatus;

NTSTATUS status = ZwCreateFile(
    &fileHandle,
    GENERIC_READ,
    &attributes,
    &ioStatus,
    NULL,
    FILE_ATTRIBUTE_NORMAL,
    FILE_SHARE_READ,
    FILE_OPEN,
    FILE_SYNCHRONOUS_IO_NONALERT,
    NULL,
    0
);
Chaque argument doit être compris séparément.


11. FileHandle

Le premier paramètre reçoit le handle du fichier :

Code: Select all

&fileHandle
Si l’opération réussit, ce handle sera utilisé avec :

Code: Select all

ZwReadFile
ZwWriteFile
ZwQueryInformationFile
ZwClose

12. DesiredAccess

Le deuxième paramètre décrit les droits demandés.

Exemples classiques :

Code: Select all

GENERIC_READ
GENERIC_WRITE
SYNCHRONIZE
Pour une lecture :

Code: Select all

GENERIC_READ
Pour une écriture :

Code: Select all

GENERIC_WRITE
Il est possible de combiner plusieurs droits :

Code: Select all

GENERIC_READ | GENERIC_WRITE
Lorsqu’un mode synchrone exige certains droits supplémentaires,

Code: Select all

SYNCHRONIZE
peut également être utilisé selon le scénario.


13. FileAttributes

Le paramètre

Code: Select all

FileAttributes
décrit les attributs d’un fichier nouvellement créé.

Une valeur simple est :

Code: Select all

FILE_ATTRIBUTE_NORMAL
Pour l’ouverture d’un fichier existant, ce paramètre est souvent peu important comparativement aux paramètres d’ouverture et de partage.


14. ShareAccess

Le paramètre

Code: Select all

ShareAccess
indique ce que les autres ouvertures ont le droit de faire tant que le handle est ouvert.

Exemples :

Code: Select all

FILE_SHARE_READ
FILE_SHARE_WRITE
FILE_SHARE_DELETE
Exemple :

Code: Select all

FILE_SHARE_READ
autorise d’autres utilisateurs à ouvrir le fichier en lecture.

Une valeur de :

Code: Select all

0
demande un accès exclusif vis-à-vis du partage.

Le choix du partage est important : un mauvais masque peut provoquer des erreurs de type conflit de partage.


15. CreateDisposition

Le paramètre

Code: Select all

CreateDisposition
décrit ce que le système doit faire selon l’existence du fichier.

Les valeurs importantes sont :

Code: Select all

FILE_OPEN
FILE_CREATE
FILE_OPEN_IF
FILE_OVERWRITE
FILE_OVERWRITE_IF
FILE_SUPERSEDE
FILE_OPEN

Ouvre un fichier existant.

Si le fichier n’existe pas, l’opération échoue.

FILE_CREATE

Crée un nouveau fichier.

Si le fichier existe déjà, l’opération échoue.

FILE_OPEN_IF

Ouvre le fichier s’il existe, sinon le crée.

FILE_OVERWRITE

Écrase un fichier existant.

Si le fichier n’existe pas, l’opération échoue.

FILE_OVERWRITE_IF

Écrase le fichier s’il existe, sinon le crée.

FILE_SUPERSEDE

Remplace le fichier existant avec des sémantiques plus fortes de substitution.


16. CreateOptions

Le paramètre

Code: Select all

CreateOptions
permet de préciser le comportement de l’ouverture.

Une option très courante dans un driver est :

Code: Select all

FILE_SYNCHRONOUS_IO_NONALERT
Elle demande une utilisation synchrone du handle.

Avec un handle synchrone, la position courante du fichier peut être maintenue par le système.

Cela permet par exemple d’effectuer plusieurs lectures successives sans calculer manuellement un offset différent à chaque fois.


17. Ouvrir un fichier existant en lecture

Exemple complet :

Code: Select all

UNICODE_STRING fileName;
OBJECT_ATTRIBUTES attributes;
IO_STATUS_BLOCK ioStatus;
HANDLE fileHandle = NULL;

RtlInitUnicodeString(
    &fileName,
    L"\\??\\C:\\Temp\\input.bin"
);

InitializeObjectAttributes(
    &attributes,
    &fileName,
    OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
    NULL,
    NULL
);

NTSTATUS status = ZwCreateFile(
    &fileHandle,
    GENERIC_READ,
    &attributes,
    &ioStatus,
    NULL,
    FILE_ATTRIBUTE_NORMAL,
    FILE_SHARE_READ,
    FILE_OPEN,
    FILE_SYNCHRONOUS_IO_NONALERT,
    NULL,
    0
);

if (!NT_SUCCESS(status))
{
    return status;
}
La combinaison :

Code: Select all

GENERIC_READ
FILE_OPEN
FILE_SYNCHRONOUS_IO_NONALERT
représente un scénario classique d’ouverture synchrone en lecture.


18. Créer ou remplacer un fichier

Exemple :

Code: Select all

NTSTATUS status = ZwCreateFile(
    &fileHandle,
    GENERIC_WRITE,
    &attributes,
    &ioStatus,
    NULL,
    FILE_ATTRIBUTE_NORMAL,
    0,
    FILE_OVERWRITE_IF,
    FILE_SYNCHRONOUS_IO_NONALERT,
    NULL,
    0
);
Avec :

Code: Select all

FILE_OVERWRITE_IF
le fichier sera :
  • créé s’il n’existe pas ;
  • remplacé ou tronqué s’il existe.

19. Vérifier le résultat de ZwCreateFile

Comme la plupart des routines natives,

Code: Select all

ZwCreateFile
retourne un :

Code: Select all

NTSTATUS
Le test classique est :

Code: Select all

if (!NT_SUCCESS(status))
{
    return status;
}
Il ne faut jamais utiliser le handle comme s’il était valide sans avoir vérifié l’état retourné.


20. Lire avec ZwReadFile

Une fois le fichier ouvert, la lecture se fait avec :

Code: Select all

ZwReadFile
Exemple :

Code: Select all

CHAR buffer[512] = {};

status = ZwReadFile(
    fileHandle,
    NULL,
    NULL,
    NULL,
    &ioStatus,
    buffer,
    sizeof(buffer),
    NULL,
    NULL
);
Dans ce scénario synchrone :
  • le handle du fichier est fourni ;
  • aucun événement n’est utilisé ;
  • aucune APC de fin d’I/O n’est utilisée ;
  • Code: Select all

    ioStatus
    reçoit le résultat ;
  • Code: Select all

    buffer
    reçoit les données ;
  • Code: Select all

    sizeof(buffer)
    indique la quantité maximale demandée ;
  • aucun offset explicite n’est fourni.

21. Offset de lecture

Le paramètre

Code: Select all

ByteOffset
permet de préciser la position de lecture.

Exemple :

Code: Select all

LARGE_INTEGER offset;
offset.QuadPart = 1024;
Puis :

Code: Select all

status = ZwReadFile(
    fileHandle,
    NULL,
    NULL,
    NULL,
    &ioStatus,
    buffer,
    sizeof(buffer),
    &offset,
    NULL
);
La lecture commence alors à l’octet 1024.

Lorsque le handle est synchrone et qu’aucun offset n’est fourni, la position courante associée au handle peut être utilisée.


22. Nombre d’octets réellement lus

Après une lecture réussie :

Code: Select all

ULONG_PTR bytesRead = ioStatus.Information;
Il faut utiliser cette valeur plutôt que de supposer que :

Code: Select all

bytesRead == sizeof(buffer)
Une lecture peut retourner moins de données, notamment à l’approche de la fin du fichier.


23. Écrire avec ZwWriteFile

L’écriture suit un modèle très proche.

Exemple :

Code: Select all

const CHAR data[] = "Kernel log\r\n";

status = ZwWriteFile(
    fileHandle,
    NULL,
    NULL,
    NULL,
    &ioStatus,
    (PVOID)data,
    sizeof(data) - 1,
    NULL,
    NULL
);
Le nombre d’octets réellement écrits peut être obtenu avec :

Code: Select all

ioStatus.Information

24. Écriture à un offset donné

Exemple :

Code: Select all

LARGE_INTEGER offset;
offset.QuadPart = 4096;

status = ZwWriteFile(
    fileHandle,
    NULL,
    NULL,
    NULL,
    &ioStatus,
    buffer,
    bufferSize,
    &offset,
    NULL
);
Cela permet d’écrire à une position précise dans le fichier.


25. Opérations synchrones et asynchrones

Les routines

Code: Select all

ZwReadFile
et

Code: Select all

ZwWriteFile
peuvent être utilisées dans des scénarios synchrones ou asynchrones.

Avec :

Code: Select all

FILE_SYNCHRONOUS_IO_NONALERT
l’utilisation est simplifiée.

L’appel peut attendre la fin de l’opération avant de retourner.

Un mode asynchrone peut retourner avant l’achèvement réel de l’I/O et demande une gestion supplémentaire.

Pour de petites opérations occasionnelles dans un driver, le mode synchrone est souvent plus simple à comprendre.

Cependant :

une opération synchrone peut bloquer le thread courant.

Elle ne doit donc pas être placée arbitrairement sur un chemin critique de performance.


26. Récupérer la taille du fichier

Pour lire un fichier entier, il est utile de connaître sa taille.

La routine :

Code: Select all

ZwQueryInformationFile
permet de récupérer différentes classes d’informations.

Pour la taille du fichier, on peut utiliser :

Code: Select all

FILE_STANDARD_INFORMATION
Exemple :

Code: Select all

FILE_STANDARD_INFORMATION info;

status = ZwQueryInformationFile(
    fileHandle,
    &ioStatus,
    &info,
    sizeof(info),
    FileStandardInformation
);
La taille logique du fichier se trouve dans :

Code: Select all

info.EndOfFile
Il s’agit d’un :

Code: Select all

LARGE_INTEGER
On peut récupérer la taille 64 bits avec :

Code: Select all

LONGLONG fileSize = info.EndOfFile.QuadPart;

27. Allocation d’un buffer pour lire un fichier entier

Une fois la taille obtenue, un buffer peut être alloué.

Sur les versions modernes de Windows, on privilégie les routines modernes d’allocation de pool.

Conceptuellement :

Code: Select all

PVOID buffer = ExAllocatePool2(
    POOL_FLAG_PAGED,
    (SIZE_T)fileSize,
    'eliF'
);
Pour une opération exécutée à

Code: Select all

PASSIVE_LEVEL
, de la mémoire paginée peut convenir si le buffer ne sera pas utilisé à IRQL élevé.

Le driver doit ensuite vérifier :

Code: Select all

if (buffer == NULL)
{
    ...
}
Puis libérer la mémoire lorsque le traitement est terminé.


28. Séquence complète pour lire un fichier entier

La logique générale est :
  • initialiser le nom ;
  • initialiser

    Code: Select all

    OBJECT_ATTRIBUTES
    ;
  • ouvrir le fichier ;
  • demander

    Code: Select all

    FILE_STANDARD_INFORMATION
    ;
  • récupérer

    Code: Select all

    EndOfFile
    ;
  • allouer un buffer ;
  • lire le contenu ;
  • traiter les données ;
  • libérer le buffer ;
  • fermer le handle.
Exemple condensé :

Code: Select all

HANDLE fileHandle = NULL;
UNICODE_STRING fileName;
OBJECT_ATTRIBUTES attributes;
IO_STATUS_BLOCK ioStatus;
FILE_STANDARD_INFORMATION info;
PVOID buffer = NULL;

RtlInitUnicodeString(
    &fileName,
    L"\\??\\C:\\Temp\\input.bin"
);

InitializeObjectAttributes(
    &attributes,
    &fileName,
    OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
    NULL,
    NULL
);

NTSTATUS status = ZwCreateFile(
    &fileHandle,
    GENERIC_READ,
    &attributes,
    &ioStatus,
    NULL,
    FILE_ATTRIBUTE_NORMAL,
    FILE_SHARE_READ,
    FILE_OPEN,
    FILE_SYNCHRONOUS_IO_NONALERT,
    NULL,
    0
);

if (!NT_SUCCESS(status))
{
    return status;
}

status = ZwQueryInformationFile(
    fileHandle,
    &ioStatus,
    &info,
    sizeof(info),
    FileStandardInformation
);

if (!NT_SUCCESS(status))
{
    ZwClose(fileHandle);
    return status;
}

if (info.EndOfFile.QuadPart <= 0)
{
    ZwClose(fileHandle);
    return STATUS_END_OF_FILE;
}

SIZE_T size = (SIZE_T)info.EndOfFile.QuadPart;

buffer = ExAllocatePool2(
    POOL_FLAG_PAGED,
    size,
    'eliF'
);

if (buffer == NULL)
{
    ZwClose(fileHandle);
    return STATUS_INSUFFICIENT_RESOURCES;
}

status = ZwReadFile(
    fileHandle,
    NULL,
    NULL,
    NULL,
    &ioStatus,
    buffer,
    (ULONG)size,
    NULL,
    NULL
);

ExFreePool(buffer);
ZwClose(fileHandle);

return status;
Cet exemple est volontairement simplifié.

Dans un code de production, il faut également :
  • vérifier les conversions de tailles ;
  • gérer les fichiers supérieurs à

    Code: Select all

    ULONG_MAX
    si nécessaire ;
  • éviter les allocations excessives ;
  • valider

    Code: Select all

    ioStatus.Information
    ;
  • prévoir tous les chemins de nettoyage.

29. Pourquoi il faut vérifier les tailles

La taille d’un fichier est exprimée sur 64 bits avec :

Code: Select all

LARGE_INTEGER
Mais le paramètre

Code: Select all

Length
de

Code: Select all

ZwReadFile
est un :

Code: Select all

ULONG
Par conséquent, convertir directement une taille 64 bits arbitraire en

Code: Select all

ULONG
peut tronquer la valeur.

Exemple dangereux :

Code: Select all

(ULONG)info.EndOfFile.QuadPart
pour un fichier de plusieurs gigaoctets.

Une implémentation robuste lit les gros fichiers par blocs.


30. Lire un fichier par blocs

Une stratégie plus robuste consiste à utiliser un buffer fixe.

Exemple logique :

Code: Select all

UCHAR buffer[4096];

for (;;)
{
    status = ZwReadFile(
        fileHandle,
        NULL,
        NULL,
        NULL,
        &ioStatus,
        buffer,
        sizeof(buffer),
        NULL,
        NULL
    );

    if (!NT_SUCCESS(status))
    {
        break;
    }

    if (ioStatus.Information == 0)
    {
        break;
    }

    // traiter ioStatus.Information octets
}
Cette approche évite de charger un fichier entier en mémoire.


31. Fermer le handle avec ZwClose

Tout handle ouvert doit être fermé.

La routine est :

Code: Select all

ZwClose
Exemple :

Code: Select all

if (fileHandle != NULL)
{
    ZwClose(fileHandle);
    fileHandle = NULL;
}
Une fuite de handle dans un driver est particulièrement problématique si l’opération se répète.

Par exemple, si une requête utilisateur ouvre un fichier à chaque appel sans fermer le handle, la table de handles du système peut progressivement se remplir.


32. Portée des handles

Chaque processus possède normalement une table de handles.

Lorsqu’un handle est créé dans le contexte du processus courant sans précaution particulière, ce handle peut être associé à cette table.

Un driver ne doit donc pas considérer qu’un handle créé dans un contexte arbitraire pourra être utilisé de façon sûre depuis n’importe quel autre contexte.

C’est une des raisons pour lesquelles :

Code: Select all

OBJ_KERNEL_HANDLE
est important.

Un handle kernel est créé dans un espace réservé au noyau et n’est pas destiné à être manipulé depuis le mode utilisateur.


33. Contexte de processus dans un driver

Un driver n’est pas exécuté dans un unique processus.

Selon la source de l’appel, le thread courant peut être attaché au contexte :
  • d’un processus utilisateur ;
  • du processus System ;
  • d’un thread worker ;
  • d’un autre processus.
Il est donc dangereux de stocker un handle dépendant d’un processus particulier sans comprendre exactement son origine et sa durée de vie.

Principe :

un driver doit faire la différence entre le contexte d’exécution du thread courant et la durée de vie logique de ses propres ressources.


34. Accès aux fichiers pendant le démarrage

Le moment où le driver tente d’accéder à un fichier est important.

Lors des premières phases du démarrage de Windows, tous les systèmes de fichiers et tous les volumes ne sont pas nécessairement disponibles de la même manière qu’après l’initialisation complète.

Un driver initialisé très tôt peut donc ne pas pouvoir accéder immédiatement à :

Code: Select all

C:\MonDossier\fichier.bin
même si ce fichier est normalement disponible une fois le système démarré.


35. IRP_MN_START_DEVICE et disponibilité du stockage

Un driver Plug and Play peut vouloir accéder à un fichier au moment de l’initialisation de son périphérique.

Le livre souligne qu’un accès peut être réalisé en réaction à :

Code: Select all

IRP_MN_START_DEVICE
mais la disponibilité réelle des chemins dépend du stade du démarrage et de la pile de stockage concernée.

Un driver doit éviter de créer une dépendance circulaire.

Exemple conceptuel :
  • un driver nécessaire au montage du disque tente de lire un fichier sur ce même disque ;
  • le fichier n’est accessible qu’une fois le disque monté ;
  • le montage dépend du driver.
Cette architecture serait problématique.


36. \SystemRoot

Le chemin :

Code: Select all

\SystemRoot
est un alias important du namespace NT.

Il correspond au répertoire Windows du système installé.

Exemple :

Code: Select all

\SystemRoot\System32\Drivers\config.bin
Il peut être utile dans certains scénarios où un chemin lié au système doit être indépendant de la lettre de volume.

L’utilisation de

Code: Select all

\SystemRoot
évite notamment de supposer que Windows est toujours installé sur

Code: Select all

C:
.


37. Chemins NT et chemins Win32

Il est important de distinguer :

Code: Select all

C:\Temp\file.txt
qui est un chemin Win32, de :

Code: Select all

\??\C:\Temp\file.txt
qui est une forme utilisable dans le namespace NT pour certaines routines natives.

Autre exemple :

Code: Select all

\SystemRoot\Temp\file.txt
Le noyau manipule des objets nommés dans le namespace de l’Object Manager.

Cette différence devient importante lorsque l’on travaille avec :
  • fichiers ;
  • devices ;
  • symbolic links ;
  • events ;
  • mutex ;
  • sections ;
  • objets du registre.

38. Exemple d’écriture d’un fichier de log

Exemple simple :

Code: Select all

NTSTATUS WriteLog()
{
    UNICODE_STRING fileName;
    OBJECT_ATTRIBUTES attributes;
    IO_STATUS_BLOCK ioStatus;
    HANDLE fileHandle = NULL;

    RtlInitUnicodeString(
        &fileName,
        L"\\??\\C:\\Temp\\driver.log"
    );

    InitializeObjectAttributes(
        &attributes,
        &fileName,
        OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
        NULL,
        NULL
    );

    NTSTATUS status = ZwCreateFile(
        &fileHandle,
        GENERIC_WRITE,
        &attributes,
        &ioStatus,
        NULL,
        FILE_ATTRIBUTE_NORMAL,
        FILE_SHARE_READ,
        FILE_OPEN_IF,
        FILE_SYNCHRONOUS_IO_NONALERT,
        NULL,
        0
    );

    if (!NT_SUCCESS(status))
    {
        return status;
    }

    const CHAR message[] = "Driver started\r\n";

    status = ZwWriteFile(
        fileHandle,
        NULL,
        NULL,
        NULL,
        &ioStatus,
        (PVOID)message,
        sizeof(message) - 1,
        NULL,
        NULL
    );

    ZwClose(fileHandle);

    return status;
}
Cet exemple n’est pas un système de logging complet.

Un vrai mécanisme de logs doit prendre en compte :
  • la concurrence ;
  • la position d’écriture ;
  • la taille du fichier ;
  • les performances ;
  • la disponibilité du stockage ;
  • les erreurs d’I/O.

39. Écrire à la fin du fichier

Lorsqu’un driver veut ajouter des données à un fichier existant, il faut comprendre la position d’écriture.

Une stratégie est :
  • interroger la taille du fichier ;
  • utiliser cette taille comme offset ;
  • écrire à cet offset.
Exemple conceptuel :

Code: Select all

FILE_STANDARD_INFORMATION info;

status = ZwQueryInformationFile(
    fileHandle,
    &ioStatus,
    &info,
    sizeof(info),
    FileStandardInformation
);

LARGE_INTEGER offset = info.EndOfFile;

status = ZwWriteFile(
    fileHandle,
    NULL,
    NULL,
    NULL,
    &ioStatus,
    buffer,
    length,
    &offset,
    NULL
);
Il faut cependant tenir compte de la concurrence : deux threads peuvent récupérer la même taille et tenter d’écrire au même endroit.

Une synchronisation ou une stratégie atomique adaptée est alors nécessaire.


40. Gestion des erreurs

Un code kernel robuste doit gérer chaque erreur.

Exemple de structure :

Code: Select all

NTSTATUS status = ZwCreateFile(...);

if (!NT_SUCCESS(status))
{
    return status;
}

status = ZwQueryInformationFile(...);

if (!NT_SUCCESS(status))
{
    ZwClose(fileHandle);
    return status;
}
Il faut toujours réfléchir aux ressources déjà acquises.

Exemple :
  • le fichier est ouvert ;
  • le buffer est alloué ;
  • la lecture échoue.
Le driver doit alors :
  • libérer le buffer ;
  • fermer le handle ;
  • retourner le statut approprié.

41. Pattern de nettoyage

Une approche simple est de centraliser le nettoyage.

Exemple :

Code: Select all

NTSTATUS status = STATUS_SUCCESS;
HANDLE fileHandle = NULL;
PVOID buffer = NULL;

// opérations...

Cleanup:

if (buffer != NULL)
{
    ExFreePool(buffer);
}

if (fileHandle != NULL)
{
    ZwClose(fileHandle);
}

return status;
Cette technique évite de dupliquer le code de libération dans chaque branche d’erreur.


42. Attention aux locks

Une opération disque peut être lente.

Il est donc généralement déconseillé de conserver un verrou global important pendant :

Code: Select all

ZwReadFile
ou :

Code: Select all

ZwWriteFile
Exemple de mauvaise architecture :

Code: Select all

KeAcquireSpinLock(...);

ZwWriteFile(...);

KeReleaseSpinLock(...);
C’est incorrect pour plusieurs raisons :
  • un spinlock élève généralement l’IRQL à DISPATCH_LEVEL ;
  • Code: Select all

    ZwWriteFile
    exige un contexte compatible avec PASSIVE_LEVEL ;
  • l’I/O peut attendre.
Il faut restructurer le code : copier ou préparer les données sous verrou, libérer le verrou, puis effectuer l’I/O à

Code: Select all

PASSIVE_LEVEL
.


43. ZwCreateFile et la philosophie de l’Object Manager

L’accès fichier dans le noyau illustre une idée centrale de Windows NT :

de nombreuses ressources système sont représentées sous forme d’objets gérés par l’Object Manager.

Le modèle général est souvent :
  • préparer un nom ;
  • préparer des attributs ;
  • ouvrir ou créer l’objet ;
  • obtenir un handle ;
  • effectuer des opérations ;
  • fermer le handle.
Cette logique se retrouve avec :
  • les fichiers ;
  • le registre ;
  • les processus ;
  • les threads ;
  • les événements ;
  • les sémaphores ;
  • les sections ;
  • les devices.

44. Lien avec les routines Zw du registre

Les routines du registre utilisent des concepts très proches.

Exemples :

Code: Select all

ZwOpenKey
ZwCreateKey
ZwQueryKey
ZwQueryValueKey
ZwEnumerateKey
ZwEnumerateValueKey
ZwClose
Le schéma est similaire :
  • création d’un

    Code: Select all

    UNICODE_STRING
    ;
  • construction de

    Code: Select all

    OBJECT_ATTRIBUTES
    ;
  • obtention d’un handle ;
  • requête d’informations ;
  • fermeture avec

    Code: Select all

    ZwClose
    .

45. KEY_VALUE_BASIC_INFORMATION

Pour les valeurs du registre, une structure telle que :

Code: Select all

KEY_VALUE_BASIC_INFORMATION
peut contenir :

Code: Select all

typedef struct _KEY_VALUE_BASIC_INFORMATION
{
    ULONG TitleIndex;
    ULONG Type;
    ULONG NameLength;
    WCHAR Name[1];

} KEY_VALUE_BASIC_INFORMATION,
 *PKEY_VALUE_BASIC_INFORMATION;
Les champs principaux sont :
  • Code: Select all

    Type
    : type de la valeur ;
  • Code: Select all

    NameLength
    : longueur du nom en octets ;
  • Code: Select all

    Name
    : début du nom de la valeur.
Le tableau :

Code: Select all

WCHAR Name[1]
représente une structure à taille variable.

La mémoire réellement fournie doit être suffisamment grande pour contenir la structure et la chaîne retournée.


46. ZwEnumerateValueKey

La routine :

Code: Select all

ZwEnumerateValueKey
permet d’énumérer les valeurs présentes dans une clé.

Le principe général est d’appeler la fonction avec un index :

Code: Select all

0
1
2
3
...
jusqu’à ce qu’il n’y ait plus d’éléments.

Une classe d’information détermine le format du buffer retourné.

Exemple de format :

Code: Select all

KeyValueBasicInformation
qui conduit à une structure de type :

Code: Select all

KEY_VALUE_BASIC_INFORMATION

47. ZwEnumerateKey

La routine :

Code: Select all

ZwEnumerateKey
permet d’énumérer les sous-clés.

Comme pour les valeurs, plusieurs classes d’informations existent.

Certaines retournent uniquement des informations minimales, d’autres retournent davantage de métadonnées.

Le choix dépend de ce que le driver doit réellement récupérer.


48. Routines ZwQuery*

Les routines :

Code: Select all

ZwQueryKey
ZwQueryValueKey
ZwQueryInformationFile
suivent un modèle comparable :
  • un handle désigne l’objet ;
  • une classe d’information indique le format attendu ;
  • un buffer reçoit les données ;
  • une taille indique la capacité du buffer ;
  • un résultat indique la quantité nécessaire ou effectivement retournée.
Ce pattern est extrêmement fréquent dans les API natives Windows.


49. Pattern de buffer de taille variable

Beaucoup de routines du noyau retournent des structures de taille variable.

Le schéma classique consiste à :
  • appeler une première fois pour connaître la taille ;
  • allouer un buffer ;
  • rappeler la fonction ;
  • interpréter la structure retournée.
Exemple conceptuel :

Code: Select all

ULONG requiredLength = 0;

status = ZwQueryValueKey(
    keyHandle,
    &valueName,
    KeyValueFullInformation,
    NULL,
    0,
    &requiredLength
);
Puis :

Code: Select all

buffer = ExAllocatePool2(
    POOL_FLAG_PAGED,
    requiredLength,
    'geRK'
);
Puis un second appel avec le buffer.


50. Comparaison Win32 / kernel

En user mode, un programme peut utiliser :

Code: Select all

CreateFileW
ReadFile
WriteFile
CloseHandle
GetFileSizeEx
Dans le noyau, les équivalents conceptuels étudiés ici sont :

Code: Select all

ZwCreateFile
ZwReadFile
ZwWriteFile
ZwClose
ZwQueryInformationFile
Il ne s’agit pas d’un simple changement de nom.

Le contexte kernel implique :
  • des IRQL ;
  • des APC ;
  • des handles kernel ;
  • des structures NT ;
  • des contraintes de synchronisation ;
  • une responsabilité plus importante en cas d’erreur.

51. API et structures essentielles à retenir

Routines principales
Structures principales
Constantes importantes

52. Concepts essentiels à retenir
  • Les opérations fichier doivent être réalisées à

    Code: Select all

    PASSIVE_LEVEL
    .
  • Les opérations fichier peuvent attendre.
  • Les APC et certaines primitives de synchronisation peuvent rendre le contexte incompatible avec l’I/O fichier.
  • Code: Select all

    ZwCreateFile
    sert à ouvrir autant qu’à créer.
  • Code: Select all

    CreateDisposition
    définit le comportement selon l’existence du fichier.
  • Code: Select all

    IO_STATUS_BLOCK
    décrit le résultat de l’opération.
  • Code: Select all

    ioStatus.Information
    permet notamment de connaître le nombre d’octets transférés.
  • Code: Select all

    ZwQueryInformationFile
    permet de récupérer des métadonnées.
  • Code: Select all

    FILE_STANDARD_INFORMATION.EndOfFile
    donne la taille logique du fichier.
  • Code: Select all

    OBJ_KERNEL_HANDLE
    protège le handle du contexte utilisateur.
  • Un driver doit tenir compte du contexte de processus courant.
  • Tous les handles doivent être fermés avec

    Code: Select all

    ZwClose
    .
  • Le stockage peut ne pas être totalement disponible pendant certaines phases du boot.
  • Code: Select all

    \SystemRoot
    permet de référencer le répertoire système sans supposer une lettre de lecteur.

53. Pièges classiques

Piège 1 : appeler ZwReadFile à DISPATCH_LEVEL

Une I/O fichier n’est pas compatible avec un contexte arbitraire à IRQL élevé.

Piège 2 : conserver un spinlock pendant une opération fichier

L’opération peut bloquer et le spinlock impose un contexte incompatible.

Piège 3 : oublier OBJ_KERNEL_HANDLE

Le handle peut être associé au mauvais processus.

Piège 4 : oublier ZwClose

Cela crée une fuite de handle.

Piège 5 : supposer que la taille demandée a entièrement été transférée

Toujours examiner

Code: Select all

IO_STATUS_BLOCK.Information
.

Piège 6 : confondre FILE_OPEN et FILE_OPEN_IF

Code: Select all

FILE_OPEN
échoue si le fichier n’existe pas.

Code: Select all

FILE_OPEN_IF
peut le créer.

Piège 7 : confondre FILE_CREATE et FILE_OVERWRITE_IF

Code: Select all

FILE_CREATE
échoue si le fichier existe.

Code: Select all

FILE_OVERWRITE_IF
peut remplacer un fichier existant.

Piège 8 : utiliser directement un chemin Win32 classique

Il faut utiliser un nom compatible avec le namespace NT attendu.

Piège 9 : charger un très gros fichier en une seule allocation

La lecture par blocs est souvent plus robuste.

Piège 10 : effectuer des I/O disque dans un chemin critique

Les accès disque peuvent être beaucoup plus lents qu’une opération purement mémoire.


54. Exemple final : lecture sûre et structurée

Code: Select all

NTSTATUS ReadFileExample()
{
    NTSTATUS status;
    HANDLE fileHandle = NULL;
    UNICODE_STRING fileName;
    OBJECT_ATTRIBUTES attributes;
    IO_STATUS_BLOCK ioStatus;
    FILE_STANDARD_INFORMATION info;
    PVOID buffer = NULL;

    RtlInitUnicodeString(
        &fileName,
        L"\\??\\C:\\Temp\\data.bin"
    );

    InitializeObjectAttributes(
        &attributes,
        &fileName,
        OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
        NULL,
        NULL
    );

    status = ZwCreateFile(
        &fileHandle,
        GENERIC_READ,
        &attributes,
        &ioStatus,
        NULL,
        FILE_ATTRIBUTE_NORMAL,
        FILE_SHARE_READ,
        FILE_OPEN,
        FILE_SYNCHRONOUS_IO_NONALERT,
        NULL,
        0
    );

    if (!NT_SUCCESS(status))
    {
        goto Cleanup;
    }

    status = ZwQueryInformationFile(
        fileHandle,
        &ioStatus,
        &info,
        sizeof(info),
        FileStandardInformation
    );

    if (!NT_SUCCESS(status))
    {
        goto Cleanup;
    }

    if (info.EndOfFile.QuadPart == 0)
    {
        status = STATUS_SUCCESS;
        goto Cleanup;
    }

    if (info.EndOfFile.QuadPart > MAXULONG)
    {
        status = STATUS_FILE_TOO_LARGE;
        goto Cleanup;
    }

    ULONG fileSize = (ULONG)info.EndOfFile.QuadPart;

    buffer = ExAllocatePool2(
        POOL_FLAG_PAGED,
        fileSize,
        'eliF'
    );

    if (buffer == NULL)
    {
        status = STATUS_INSUFFICIENT_RESOURCES;
        goto Cleanup;
    }

    status = ZwReadFile(
        fileHandle,
        NULL,
        NULL,
        NULL,
        &ioStatus,
        buffer,
        fileSize,
        NULL,
        NULL
    );

    if (!NT_SUCCESS(status))
    {
        goto Cleanup;
    }

    KdPrint((
        "Bytes lus : %llu\n",
        (unsigned long long)ioStatus.Information
    ));

Cleanup:

    if (buffer != NULL)
    {
        ExFreePool(buffer);
    }

    if (fileHandle != NULL)
    {
        ZwClose(fileHandle);
    }

    return status;
}

55. Schéma mental final

L’accès fichier kernel peut être résumé par la chaîne suivante :

Code: Select all

Nom NT
   |
   v
UNICODE_STRING
   |
   v
OBJECT_ATTRIBUTES
   |
   v
ZwCreateFile
   |
   v
HANDLE
   |
   +--> ZwReadFile
   |
   +--> ZwWriteFile
   |
   +--> ZwQueryInformationFile
   |
   v
ZwClose
Les contraintes autour de cette chaîne sont :

Code: Select all

PASSIVE_LEVEL
+
contexte de thread compatible
+
gestion correcte des APC
+
handles kernel
+
nettoyage systématique

56. À retenir en priorité

Pour retenir l’essentiel sans mémoriser tous les détails :
  • Code: Select all

    ZwCreateFile
    ouvre ou crée.
  • Code: Select all

    ZwReadFile
    lit.
  • Code: Select all

    ZwWriteFile
    écrit.
  • Code: Select all

    ZwQueryInformationFile
    récupère les métadonnées.
  • Code: Select all

    ZwClose
    ferme.
  • Code: Select all

    OBJECT_ATTRIBUTES
    décrit l’objet.
  • Code: Select all

    UNICODE_STRING
    contient son nom.
  • Code: Select all

    IO_STATUS_BLOCK
    contient le résultat de l’I/O.
  • Code: Select all

    OBJ_KERNEL_HANDLE
    évite de lier un handle du driver au processus utilisateur courant.
  • Code: Select all

    FILE_STANDARD_INFORMATION.EndOfFile
    permet de récupérer la taille d’un fichier.
  • Les opérations fichier sont réalisées à

    Code: Select all

    PASSIVE_LEVEL
    .
  • Il ne faut pas conserver un spinlock ou un contexte incompatible pendant une I/O.
  • Les chemins noyau appartiennent au namespace NT.
  • Le moment du boot auquel le fichier est utilisé peut déterminer s’il est accessible ou non.
Ce modèle constitue la base de l’accès fichier directement depuis un driver Windows.

Who is online

Users browsing this forum: No registered users and 0 guests