Gestion de la mémoire en Kernel Windows

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:

Gestion de la mémoire en Kernel Windows

Post by Hydraxx »

Gestion de la mémoire en Kernel Windows

La gestion mémoire dans un driver Windows est plus contraignante qu'en user mode. Un driver travaille directement dans l'espace noyau, peut s'exécuter à différents niveaux d'IRQL et doit respecter des règles strictes concernant les pages mémoire, les allocations, les buffers utilisateur et la durée de vie des objets.

Une erreur mémoire dans un processus user mode peut généralement faire planter uniquement le processus. Une erreur mémoire dans un driver peut provoquer une corruption globale du système ou un bug check.

Ce cours regroupe les concepts fondamentaux du WDM classique et les API modernes du noyau Windows.

1. Espace d'adressage user mode et kernel mode

Windows utilise la mémoire virtuelle. Un processus manipule des adresses virtuelles qui sont traduites par le système vers des pages physiques.

L'espace d'adressage virtuel contient notamment :
  • une partie accessible au user mode ;
  • une partie réservée au kernel mode.
Chaque processus possède son propre contexte d'adressage user mode, tandis qu'une grande partie de l'espace kernel est commune au système.

Une adresse user mode n'a donc pas les mêmes propriétés qu'une adresse kernel.

Un driver ne doit jamais supposer qu'un pointeur user mode est valide simplement parce qu'il contient une adresse non nulle.

2. Mémoire virtuelle et mémoire physique

Une adresse virtuelle n'est pas directement une adresse physique.

Le processeur et le Memory Manager utilisent des tables de pages pour traduire :

Code: Select all

Adresse virtuelle
        ↓
Table(s) de pages
        ↓
Page physique
Deux adresses virtuelles différentes peuvent référencer des zones physiques différentes, et une même page physique peut parfois être mappée dans plusieurs espaces virtuels.

Dans un driver, il faut donc distinguer clairement :
  • adresse virtuelle ;
  • adresse physique ;
  • mémoire pageable ;
  • mémoire nonpageable.
3. Pages mémoire

Windows organise la mémoire en pages.

Le WDK expose notamment :

Code: Select all

PAGE_SIZE
PAGE_SHIFT
Sur les systèmes x64 classiques, la taille de page courante est généralement de 4 Kio, mais un driver ne doit pas multiplier arbitrairement des constantes partout dans son code lorsqu'une macro existe.

Quelques macros utiles :

Code: Select all

ROUND_TO_PAGES(x)
BYTES_TO_PAGES(x)
BYTE_OFFSET(x)
Elles servent notamment à :
  • arrondir une taille à une frontière de page ;
  • calculer combien de pages couvrent une taille donnée ;
  • obtenir le déplacement d'une adresse dans sa page.
4. Mémoire pageable et nonpageable

Une différence fondamentale en kernel est celle entre mémoire pageable et mémoire nonpageable.

La mémoire pageable peut être retirée de la RAM physique lorsque Windows manque de mémoire et être restaurée plus tard.

La mémoire nonpageable doit rester accessible physiquement tant qu'elle est allouée.

On retrouve historiquement :

Code: Select all

PagedPool
NonPagedPool
Dans du code moderne, on utilise plutôt des flags avec les nouvelles API d'allocation.

La règle générale est :
  • mémoire pageable : utilisable lorsque le code peut tolérer une faute de page ;
  • mémoire nonpageable : obligatoire lorsque le code peut s'exécuter à un IRQL où les fautes de page sont interdites.
5. Pourquoi l'IRQL influence le choix de mémoire

À un IRQL élevé, Windows ne peut pas toujours traiter une faute de page.

Par exemple, du code exécuté à :

Code: Select all

DISPATCH_LEVEL
ne doit pas accéder à de la mémoire pageable.

Sinon, si la page n'est pas actuellement résidente, le système ne pourra pas nécessairement la restaurer et un bug check peut se produire.

On retient donc :
  • PASSIVE_LEVEL : accès possible à de la mémoire pageable ;
  • APC_LEVEL : restrictions plus fortes ;
  • DISPATCH_LEVEL : utiliser uniquement des données et du code résidents/nonpageables pour les accès concernés.
Le type d'allocation doit toujours être choisi en fonction de l'IRQL auquel la mémoire sera utilisée, pas uniquement de l'IRQL auquel elle est allouée.

6. Placement du code dans des sections

Un driver peut placer certaines fonctions dans des sections spécifiques.

Exemple classique :

Code: Select all

#pragma alloc_text(INIT, DriverEntry)
#pragma alloc_text(PAGE, MyPagedFunction)
La section :

Code: Select all

INIT
est utilisée pour du code qui n'est nécessaire que pendant l'initialisation.

Une fois l'initialisation terminée, cette mémoire peut être libérée par le système.

La section :

Code: Select all

PAGE
est pageable.

Une routine pageable doit être appelée uniquement dans un contexte compatible avec la pagination.

7. PAGED_CODE()

La macro :

Code: Select all

PAGED_CODE();
est souvent placée au début d'une routine pageable.

Exemple :

Code: Select all

#pragma alloc_text(PAGE, MyFunction)

void MyFunction()
{
    PAGED_CODE();

    // traitement
}
Cette macro aide à détecter certaines utilisations incorrectes pendant le développement.

Elle ne transforme pas magiquement une fonction en fonction pageable : le placement de section reste distinct.

8. Sections INIT

Les fonctions uniquement nécessaires au chargement du driver peuvent être placées dans une section d'initialisation.

Exemple :

Code: Select all

#pragma alloc_text(INIT, DriverEntry)
C'est pertinent lorsqu'une routine ne sera jamais appelée après la phase d'initialisation.

Il faut évidemment éviter d'y placer une fonction encore utilisée après le chargement.

9. Verrouillage dynamique de code pageable

Windows fournit des mécanismes permettant de verrouiller temporairement une section pageable en mémoire.

On rencontre notamment :

Code: Select all

MmLockPagableCodeSection()
MmUnlockPagableImageSection()
Exemple conceptuel :

Code: Select all

PVOID handle =
    MmLockPagableCodeSection(MyPagedFunction);

// la section reste résidente

MmUnlockPagableImageSection(handle);
Ce mécanisme peut être utile lorsqu'un groupe de routines normalement pageable doit rester temporairement résident.

Il ne faut pas l'utiliser sans raison, car cela réduit la quantité de mémoire que le système peut paginer.

10. Anciennes API d'allocation de pool

Les anciens drivers utilisent souvent :

Code: Select all

ExAllocatePool()
ExAllocatePoolWithTag()
ExAllocatePoolWithTagPriority()
Exemple historique :

Code: Select all

PVOID p = ExAllocatePoolWithTag(
    NonPagedPool,
    1024,
    'gaTM'
);
Ces API sont importantes à connaître pour lire du code ancien.

Cependant, les API modernes sont préférables pour du nouveau code.

11. ExAllocatePool2

Pour du code moderne, l'une des API principales est :

Code: Select all

ExAllocatePool2
Exemple :

Code: Select all

PVOID buffer = ExAllocatePool2(
    POOL_FLAG_NON_PAGED,
    4096,
    'gaTM'
);
Il faut toujours vérifier le résultat :

Code: Select all

if (buffer == nullptr)
{
    return STATUS_INSUFFICIENT_RESOURCES;
}
Un exemple pageable :

Code: Select all

PVOID buffer = ExAllocatePool2(
    POOL_FLAG_PAGED,
    4096,
    'gaTM'
);
12. ExAllocatePool3

La fonction :

Code: Select all

ExAllocatePool3
permet d'utiliser des paramètres étendus.

Elle est utile lorsqu'un simple choix paged/nonpaged + taille + tag ne suffit pas.

Elle peut notamment accepter des paramètres supplémentaires liés à certaines propriétés d'allocation.

Dans un driver classique, `ExAllocatePool2` suffit souvent.

13. POOL_FLAGS

Les API modernes utilisent des flags.

Exemples importants :

Code: Select all

POOL_FLAG_PAGED
POOL_FLAG_NON_PAGED
POOL_FLAG_UNINITIALIZED
POOL_FLAG_CACHE_ALIGNED
Le choix doit être cohérent avec :
  • l'IRQL d'utilisation ;
  • la durée de vie de la mémoire ;
  • les contraintes d'alignement ;
  • les besoins de sécurité ;
  • la nécessité éventuelle d'exécution de code.
14. Mémoire initialisée et non initialisée

Selon l'API et les flags utilisés, une allocation peut être initialisée à zéro ou laissée non initialisée.

Une mémoire non initialisée peut contenir des données précédemment présentes dans cette zone.

Lorsqu'une allocation est destinée à contenir des données qui seront exposées au user mode, il faut éviter de divulguer du contenu ancien.

Exemple :

Code: Select all

RtlZeroMemory(buffer, size);
si le contexte nécessite explicitement une remise à zéro.

15. Pool tags

Les pool tags servent à identifier l'origine des allocations kernel.

Exemple :

Code: Select all

'gaTM'
Un tag comporte généralement quatre caractères.

Exemple :

Code: Select all

#define DRIVER_TAG 'gaTM'
Puis :

Code: Select all

PVOID p = ExAllocatePool2(
    POOL_FLAG_NON_PAGED,
    512,
    DRIVER_TAG
);
Les tags sont très utiles pour :
  • identifier les allocations d'un driver ;
  • rechercher des fuites ;
  • analyser le pool ;
  • utiliser Driver Verifier ;
  • utiliser certains outils de debugging mémoire.
16. Little-endian et affichage des tags

Sur une machine little-endian, l'ordre visuel d'un tag peut sembler inversé dans certains outils.

Par exemple :

Code: Select all

'gaTM'
peut apparaître sous une forme visuellement différente selon la manière dont les octets sont affichés.

Cela ne signifie pas que le tag est incorrect.

17. Choisir de bons tags

Un bon tag doit être :
  • reconnaissable ;
  • stable ;
  • distinct pour différents types d'allocations importantes ;
  • éviter les tags trop génériques.
Exemple :

Code: Select all

#define TAG_DEVICE 'veDM'
#define TAG_BUFFER 'ffuB'
#define TAG_ENTRY  'rtnE'
Cette séparation facilite énormément le debugging.

18. Libération avec ExFreePool

Une allocation obtenue depuis le pool doit être libérée lorsqu'elle n'est plus utilisée.

Exemple :

Code: Select all

PVOID p = ExAllocatePool2(
    POOL_FLAG_NON_PAGED,
    1024,
    DRIVER_TAG
);

if (p == nullptr)
{
    return STATUS_INSUFFICIENT_RESOURCES;
}

// utilisation

ExFreePool(p);
Selon le code et les conventions utilisées, on peut aussi rencontrer :

Code: Select all

ExFreePoolWithTag()
19. Ownership

Une règle fondamentale est de savoir qui possède la mémoire.

Pour chaque allocation, il faut pouvoir répondre à :
  • qui l'alloue ?
  • qui la libère ?
  • quand doit-elle être libérée ?
  • peut-elle être partagée entre plusieurs threads ?
  • peut-elle survivre jusqu'à DriverUnload ?
Un ownership flou mène rapidement à :
  • fuites ;
  • double free ;
  • use-after-free ;
  • corruptions.
20. Double free

Exemple incorrect :

Code: Select all

ExFreePool(buffer);
ExFreePool(buffer);
Le second appel libère une zone qui ne nous appartient plus.

Cela peut entraîner une corruption du pool et un bug check.

Une bonne habitude après une libération, lorsque le pointeur reste accessible, est :

Code: Select all

ExFreePool(buffer);
buffer = nullptr;
Cela n'élimine pas tous les bugs de durée de vie, mais réduit certaines erreurs simples.

21. Use-after-free

Exemple :

Code: Select all

ExFreePool(data);

data->Value = 10;
Après `ExFreePool`, la mémoire peut être réutilisée pour autre chose.

Le pointeur ne doit plus être déréférencé.

Les use-after-free kernel sont particulièrement dangereux.

22. Fuites mémoire

Une fuite se produit lorsqu'une allocation n'est jamais libérée.

Exemple :

Code: Select all

PVOID p = ExAllocatePool2(
    POOL_FLAG_NON_PAGED,
    4096,
    DRIVER_TAG
);

// aucun ExFreePool()
Une fuite dans un driver peut s'accumuler pendant des heures ou des jours.

Le système peut finir par manquer de ressources kernel.

23. Gestion d'un échec d'allocation

Une allocation peut échouer.

Il faut donc tester :

Code: Select all

if (buffer == nullptr)
{
    return STATUS_INSUFFICIENT_RESOURCES;
}
Il ne faut pas écrire :

Code: Select all

PVOID buffer = ExAllocatePool2(...);

RtlZeroMemory(buffer, size);
sans vérifier `buffer`.

Sinon une allocation échouée conduit immédiatement à un accès invalide.

24. Anciennes allocations qui lèvent une exception

Certaines anciennes variantes ou certains flags historiques pouvaient demander un comportement dans lequel un échec d'allocation lève une exception au lieu de retourner `NULL`.

Cette approche existe surtout dans du code ancien.

Pour du code moderne, un comportement explicite avec contrôle du résultat est généralement plus facile à maintenir.

25. Allocation et IRQL

Le choix du type de pool dépend du contexte.

À haut IRQL, une allocation pageable est interdite.

De manière générale :

Code: Select all

PASSIVE_LEVEL
    -> paged ou nonpaged selon besoin

DISPATCH_LEVEL
    -> nonpaged uniquement pour les données accessibles
Il faut également vérifier les contraintes propres à l'API appelée.

26. Pool NX et mémoire exécutable

Une allocation de données n'a généralement aucune raison d'être exécutable.

Les versions modernes de Windows favorisent des pools non exécutables.

Le principe de sécurité est simple :

si une zone contient des données, elle ne doit pas être exécutable sans nécessité précise.

Cela réduit les possibilités d'exploitation en cas de corruption mémoire.

27. Alignement mémoire

Certaines structures ou certains périphériques exigent un alignement particulier.

On rencontre notamment des besoins :
  • d'alignement naturel ;
  • d'alignement cache ;
  • d'alignement matériel ;
  • d'alignement DMA.
Les allocations standards respectent les garanties documentées du noyau.

Il ne faut pas ajouter arbitrairement des casts pour contourner un problème d'alignement.

28. Secure Pools

Les versions modernes de Windows introduisent également des mécanismes de secure pools dans certains contextes.

Ils permettent de renforcer la gestion d'allocations sensibles et peuvent être associés à des environnements protégés comme VBS.

Le principe général est :
  • création d'un pool sécurisé ;
  • allocation depuis ce pool ;
  • validation ou mise à jour contrôlée ;
  • destruction lorsque le pool n'est plus utilisé.
Ces mécanismes sont plus spécialisés que les allocations de pool classiques et ne sont pas nécessaires à la majorité des drivers simples.

29. Pool priorities

Des API historiques comme :

Code: Select all

ExAllocatePoolWithTagPriority
permettent d'indiquer une priorité d'allocation.

On rencontre par exemple :

Code: Select all

LowPoolPriority
NormalPoolPriority
HighPoolPriority
Ce mécanisme est surtout intéressant pour comprendre du code plus ancien.

Dans un driver moderne, les nouvelles API de pool et une bonne gestion des erreurs sont généralement prioritaires.

30. Surcharge de operator new en kernel

Un driver C++ peut définir son propre `operator new` pour rediriger les allocations vers le pool kernel.

Exemple conceptuel :

Code: Select all

void* operator new(
    size_t size,
    ULONG tag
)
{
    return ExAllocatePool2(
        POOL_FLAG_NON_PAGED,
        size,
        tag
    );
}
Puis :

Code: Select all

MyObject* obj =
    new(DRIVER_TAG) MyObject();
Il faut également fournir une stratégie cohérente pour `delete`.

31. Placement new

Le placement new construit un objet à une adresse déjà allouée.

Exemple :

Code: Select all

PVOID buffer = ExAllocatePool2(
    POOL_FLAG_NON_PAGED,
    sizeof(MyObject),
    DRIVER_TAG
);

if (buffer == nullptr)
{
    return STATUS_INSUFFICIENT_RESOURCES;
}

MyObject* obj =
    new(buffer) MyObject();
Cela ne réalise pas une seconde allocation.

Il construit simplement l'objet dans le bloc existant.

32. Lookaside lists

Lorsqu'un driver alloue et libère très fréquemment des objets de taille fixe, le coût des allocations de pool peut devenir important.

Les lookaside lists servent de cache d'objets.

Principe :

Code: Select all

allocation demandée
      |
      v
objet disponible dans la lookaside ?
      |
   oui|non
      |
      +----> retour direct
             ou allocation pool
Lorsqu'un objet est libéré, il peut être conservé dans la lookaside pour une future utilisation.

33. Anciennes lookaside lists

On rencontre dans du code ancien :

Code: Select all

NPAGED_LOOKASIDE_LIST
PAGED_LOOKASIDE_LIST
ainsi que des fonctions d'initialisation, allocation, libération et destruction associées.

Ces API restent importantes à connaître pour lire d'anciens drivers.

34. LOOKASIDE_LIST_EX

Une API plus moderne utilise :

Code: Select all

LOOKASIDE_LIST_EX
avec notamment :

Code: Select all

ExInitializeLookasideListEx()
ExAllocateFromLookasideListEx()
ExFreeToLookasideListEx()
ExDeleteLookasideListEx()
Elle permet également de fournir des fonctions personnalisées d'allocation et de libération.

35. Exemple conceptuel de lookaside moderne

Code: Select all

LOOKASIDE_LIST_EX Lookaside;

NTSTATUS status =
    ExInitializeLookasideListEx(
        &Lookaside,
        nullptr,
        nullptr,
        POOL_FLAG_NON_PAGED,
        0,
        sizeof(MY_ENTRY),
        DRIVER_TAG,
        0
    );
Allocation :

Code: Select all

PMY_ENTRY entry =
    (PMY_ENTRY)ExAllocateFromLookasideListEx(
        &Lookaside
    );
Libération :

Code: Select all

ExFreeToLookasideListEx(
    &Lookaside,
    entry
);
Destruction :

Code: Select all

ExDeleteLookasideListEx(
    &Lookaside
);
36. Quand utiliser une lookaside list

Une lookaside list est adaptée lorsque :
  • les objets ont une taille fixe ;
  • les allocations sont fréquentes ;
  • les objets sont souvent libérés puis recréés ;
  • la réduction du coût d'allocation est utile.
Elle est moins intéressante pour :
  • des allocations rares ;
  • des tailles variables ;
  • des objets très gros ;
  • des objets dont la durée de vie est très longue.
37. LIST_ENTRY

Le kernel Windows utilise beaucoup les listes chaînées.

La structure classique est :

Code: Select all

LIST_ENTRY
Exemple :

Code: Select all

typedef struct _MY_ENTRY
{
    LIST_ENTRY Link;
    LONG Value;

} MY_ENTRY, *PMY_ENTRY;
Le `LIST_ENTRY` est intégré directement dans la structure.

38. Liste doublement chaînée circulaire

Une liste `LIST_ENTRY` est généralement circulaire.

Une tête de liste vide référence elle-même :

Code: Select all

Head.Flink == &Head
Head.Blink == &Head
Initialisation :

Code: Select all

LIST_ENTRY Head;
InitializeListHead(&Head);
Insertion :

Code: Select all

InsertTailList(
    &Head,
    &entry->Link
);
39. CONTAINING_RECORD

Lorsqu'on possède un pointeur vers le champ `LIST_ENTRY`, on peut récupérer la structure contenant ce champ avec :

Code: Select all

CONTAINING_RECORD
Exemple :

Code: Select all

PMY_ENTRY entry =
    CONTAINING_RECORD(
        current,
        MY_ENTRY,
        Link
    );
C'est un mécanisme extrêmement fréquent dans les structures kernel.

40. SINGLE_LIST_ENTRY

Pour certaines structures plus simples, Windows fournit également des listes simplement chaînées.

On rencontre :

Code: Select all

SINGLE_LIST_ENTRY
Le choix dépend des opérations nécessaires.

Une liste doublement chaînée permet une navigation et des suppressions plus flexibles, au prix de deux pointeurs par élément.

41. Durée de vie d'un objet kernel

Une structure allouée dans un driver doit avoir une durée de vie clairement définie.

Exemple :

Code: Select all

typedef struct _DEVICE_EXTENSION
{
    PVOID Buffer;
    ULONG BufferSize;

} DEVICE_EXTENSION;
Si `Buffer` est alloué au chargement :

Code: Select all

extension->Buffer =
    ExAllocatePool2(
        POOL_FLAG_NON_PAGED,
        4096,
        DRIVER_TAG
    );
il doit être libéré au moment approprié, par exemple lors de la suppression du périphérique ou du déchargement.

42. Exemple de nettoyage dans DriverUnload

Code: Select all

void DriverUnload(
    PDRIVER_OBJECT DriverObject
)
{
    PDEVICE_OBJECT device =
        DriverObject->DeviceObject;

    while (device != nullptr)
    {
        PDEVICE_EXTENSION ext =
            (PDEVICE_EXTENSION)
            device->DeviceExtension;

        if (ext->Buffer != nullptr)
        {
            ExFreePool(ext->Buffer);
            ext->Buffer = nullptr;
        }

        PDEVICE_OBJECT next =
            device->NextDevice;

        IoDeleteDevice(device);

        device = next;
    }
}
Le principe est de nettoyer toutes les ressources détenues par le driver.

43. Mémoire et concurrence

Une allocation peut être valide mais quand même utilisée de manière incorrecte si plusieurs threads y accèdent simultanément.

Exemple :

Code: Select all

struct DATA
{
    LONG Counter;
};
Si plusieurs threads modifient `Counter` sans synchronisation, une race condition peut apparaître.

Les solutions possibles comprennent selon le contexte :
  • interlocked operations ;
  • spinlocks ;
  • mutex ;
  • ERESOURCE ;
  • autres primitives de synchronisation.
44. Mémoire user mode vue depuis un driver

Un pointeur fourni par une application doit être considéré comme non fiable.

Il peut :
  • être invalide ;
  • changer entre deux accès ;
  • pointer vers une page retirée ;
  • être mal aligné ;
  • pointer vers une région dont les permissions changent.
Il faut donc utiliser les mécanismes d'I/O appropriés plutôt que d'accéder aveuglément à l'adresse.

45. ProbeForRead et ProbeForWrite

Pour certains scénarios utilisant des pointeurs user mode directs, on rencontre :

Code: Select all

ProbeForRead()
ProbeForWrite()
Ces fonctions peuvent lever une exception.

Elles doivent être utilisées dans un bloc SEH lorsqu'elles sont nécessaires :

Code: Select all

__try
{
    ProbeForRead(
        UserBuffer,
        Length,
        1
    );

    // accès
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
    return GetExceptionCode();
}
Une validation ne garantit pas que le pointeur restera valide indéfiniment.

46. TOCTOU

TOCTOU signifie :

Code: Select all

Time Of Check To Time Of Use
Exemple :

Code: Select all

vérifier le buffer
      ↓
le contexte change
      ↓
utiliser le buffer
Entre la vérification et l'utilisation, le programme user mode peut éventuellement modifier certaines conditions.

Il faut donc concevoir le code pour réduire cette fenêtre et utiliser les mécanismes d'I/O adaptés.

47. Buffered I/O

Avec le buffered I/O, l'I/O Manager fournit typiquement un buffer système.

Le driver travaille avec :

Code: Select all

Irp->AssociatedIrp.SystemBuffer
Cela simplifie énormément de nombreux échanges user/kernel.

L'I/O Manager effectue les copies nécessaires entre l'application et le buffer kernel selon le type de requête.

48. Direct I/O

Avec le direct I/O, Windows utilise notamment une MDL pour décrire les pages du buffer.

On retrouve :

Code: Select all

Irp->MdlAddress
Le driver peut obtenir une adresse système associée aux pages verrouillées.

Le direct I/O évite certaines copies pour de gros buffers, mais demande une bonne compréhension des MDL.

49. METHOD_NEITHER

Avec `METHOD_NEITHER`, l'I/O Manager fournit moins de protection automatique.

Le driver peut recevoir directement des adresses user mode.

C'est donc un mode beaucoup plus délicat.

Il faut tenir compte de :
  • validation ;
  • SEH ;
  • contexte processus ;
  • durée de vie du buffer ;
  • synchronisation ;
  • TOCTOU.
50. MDL — Memory Descriptor List

Une MDL décrit un ensemble de pages mémoire.

Elle permet notamment de représenter les pages physiques correspondant à un buffer virtuel.

Les MDL sont essentielles pour :
  • Direct I/O ;
  • verrouillage de pages ;
  • mapping kernel ;
  • certaines opérations DMA ;
  • accès sûr à des buffers.
51. Verrouillage de pages

Lorsqu'un buffer doit rester accessible pendant une opération asynchrone, les pages correspondantes peuvent devoir être verrouillées.

Le verrouillage empêche leur disparition physique pendant l'opération.

Une MDL peut être utilisée pour décrire ces pages.

Il faut toujours libérer ou déverrouiller les ressources correspondantes lorsqu'elles ne sont plus nécessaires.

52. Mapping d'une MDL

Une MDL peut être mappée dans l'espace d'adressage système.

Dans les scénarios adaptés, on peut ensuite travailler avec une adresse virtuelle kernel représentant les pages décrites.

Il faut respecter les API documentées et les protections attendues.

53. Mémoire physiquement contiguë

Certaines opérations matérielles peuvent nécessiter une mémoire physiquement contiguë.

Ce besoin est beaucoup plus rare que l'utilisation d'une mémoire virtuelle classique.

On l'associe notamment à :
  • certains périphériques ;
  • DMA ;
  • contraintes matérielles spécifiques.
Une grande allocation virtuelle normale n'est pas nécessairement physiquement contiguë.

54. Adresse virtuelle != adresse physique

Exemple :

Code: Select all

PVOID buffer = ...
`buffer` est une adresse virtuelle.

Il ne faut pas la transmettre à un périphérique qui attend une adresse physique sans utiliser les mécanismes appropriés.

Les opérations DMA modernes passent généralement par l'abstraction DMA du système.

55. Pool tracking

Windows fournit plusieurs outils pour analyser les allocations kernel.

Les pool tags permettent de regrouper les allocations par origine.

Pendant le debugging, on peut rechercher :
  • nombre d'allocations ;
  • nombre de libérations ;
  • octets actuellement utilisés ;
  • croissance anormale d'un tag.
Cela permet de repérer une fuite.

56. Driver Verifier

Driver Verifier peut surveiller de nombreux comportements dangereux d'un driver.

Pour la mémoire, il peut notamment aider à détecter :
  • mauvaises allocations ;
  • mauvaises libérations ;
  • utilisation incorrecte du pool ;
  • certaines violations d'IRQL ;
  • corruptions mémoire ;
  • fuites selon le contexte.
Il doit être utilisé dans un environnement de test.

57. Special Pool

Le Special Pool est particulièrement utile pour détecter des dépassements de buffer.

Principe simplifié :

Code: Select all

page accessible
buffer
frontière de page
page protégée
Si le driver dépasse la limite du buffer et touche la page protégée, l'erreur est détectée beaucoup plus rapidement.

Cela aide à trouver :
  • buffer overflow ;
  • buffer underrun ;
  • écritures hors limites ;
  • certaines corruptions du pool.
58. GFlags et outils de diagnostic

Des outils de diagnostic Windows peuvent aider à activer ou analyser certains comportements de pool.

Le but est de transformer des corruptions silencieuses en erreurs détectées au plus près de leur origine.

Plus une corruption est détectée tard, plus il est difficile de retrouver l'instruction responsable.

59. Erreurs classiques de mémoire kernel

Les erreurs suivantes doivent être considérées comme critiques :
  • accéder à de la mémoire pageable à un IRQL incompatible ;
  • oublier de tester une allocation ;
  • double free ;
  • use-after-free ;
  • fuite mémoire ;
  • mauvaise taille d'allocation ;
  • mauvais calcul d'offset ;
  • dépasser un buffer ;
  • utiliser un pointeur user non validé ;
  • garder trop longtemps une adresse user ;
  • libérer une allocation encore utilisée par un autre thread ;
  • détruire une lookaside alors qu'elle est encore utilisée ;
  • mauvaise durée de vie d'une MDL ;
  • exécuter du code placé dans une section pageable à un IRQL trop élevé.
60. Exemple complet : allocation dans une device extension

Code: Select all

#define DRIVER_TAG 'gaTM'

typedef struct _DEVICE_EXTENSION
{
    PVOID Buffer;
    SIZE_T BufferSize;

} DEVICE_EXTENSION, *PDEVICE_EXTENSION;
Allocation :

Code: Select all

PDEVICE_EXTENSION ext =
    (PDEVICE_EXTENSION)
    DeviceObject->DeviceExtension;

ext->BufferSize = 4096;

ext->Buffer = ExAllocatePool2(
    POOL_FLAG_NON_PAGED,
    ext->BufferSize,
    DRIVER_TAG
);

if (ext->Buffer == nullptr)
{
    return STATUS_INSUFFICIENT_RESOURCES;
}

RtlZeroMemory(
    ext->Buffer,
    ext->BufferSize
);
Nettoyage :

Code: Select all

if (ext->Buffer != nullptr)
{
    ExFreePool(ext->Buffer);

    ext->Buffer = nullptr;
    ext->BufferSize = 0;
}
61. Exemple avec structure allouée

Code: Select all

typedef struct _MY_ENTRY
{
    LIST_ENTRY Link;
    ULONG Id;
    LONG Value;

} MY_ENTRY, *PMY_ENTRY;
Allocation :

Code: Select all

PMY_ENTRY entry =
    (PMY_ENTRY)ExAllocatePool2(
        POOL_FLAG_NON_PAGED,
        sizeof(MY_ENTRY),
        'rtnE'
    );

if (entry == nullptr)
{
    return STATUS_INSUFFICIENT_RESOURCES;
}
Initialisation :

Code: Select all

RtlZeroMemory(
    entry,
    sizeof(MY_ENTRY)
);

entry->Id = 1;
entry->Value = 42;
Insertion :

Code: Select all

InsertTailList(
    &Head,
    &entry->Link
);
62. Suppression propre d'un élément de liste

Code: Select all

RemoveEntryList(
    &entry->Link
);

ExFreePool(entry);
Il faut s'assurer qu'aucun autre thread n'utilise encore l'élément.

Dans une vraie liste partagée, une primitive de synchronisation est généralement nécessaire.

63. Exemple avec une lookaside list

Pour des objets de taille fixe souvent créés :

Code: Select all

typedef struct _MY_PACKET
{
    LIST_ENTRY Link;
    ULONG Type;
    UCHAR Data[128];

} MY_PACKET, *PMY_PACKET;
La lookaside peut servir de cache de `MY_PACKET`.

Cela évite de demander une allocation générale au pool à chaque création.

64. Bonnes pratiques modernes

Pour du nouveau code :
  • préférer `ExAllocatePool2` ou `ExAllocatePool3` ;
  • utiliser un pool non exécutable pour les données ;
  • choisir des tags explicites ;
  • vérifier systématiquement les retours d'allocation ;
  • définir clairement l'ownership ;
  • tenir compte de l'IRQL futur d'utilisation ;
  • utiliser les lookaside lists seulement quand elles ont un intérêt réel ;
  • utiliser Driver Verifier pendant les tests ;
  • utiliser Special Pool pour traquer les corruptions ;
  • éviter les API obsolètes dans du nouveau code tout en sachant les reconnaître.
65. Ancien code vs code moderne

Un ancien driver peut contenir :

Code: Select all

ExAllocatePoolWithTag(
    NonPagedPool,
    size,
    tag
);
Un code moderne privilégiera :

Code: Select all

ExAllocatePool2(
    POOL_FLAG_NON_PAGED,
    size,
    tag
);
Il faut connaître les deux formes :
  • ancienne forme : lecture de code historique ;
  • nouvelle forme : développement actuel.
66. Résumé

La gestion mémoire kernel repose sur plusieurs notions fondamentales :
  • mémoire virtuelle ;
  • pages ;
  • paged / nonpaged ;
  • IRQL ;
  • pool kernel ;
  • pool tags ;
  • durée de vie des allocations ;
  • lookaside lists ;
  • listes chaînées ;
  • buffers user ;
  • MDL ;
  • Driver Verifier ;
  • Special Pool.
Le principe le plus important est que la validité d'un pointeur ne suffit pas.

Il faut aussi connaître :
  • son propriétaire ;
  • sa durée de vie ;
  • son contexte d'adressage ;
  • son type de mémoire ;
  • l'IRQL auquel il sera utilisé ;
  • les threads susceptibles d'y accéder.
Dans un driver moderne, les allocations générales doivent privilégier :

Code: Select all

ExAllocatePool2()
ExAllocatePool3()
tandis que les anciennes fonctions comme :

Code: Select all

ExAllocatePoolWithTag()
restent importantes pour comprendre les anciens drivers.

Enfin, le debugging mémoire doit faire partie du développement normal d'un driver :

Code: Select all

Pool Tags
Driver Verifier
Special Pool
WinDbg
Une corruption mémoire kernel peut provoquer des symptômes loin de l'instruction fautive. Plus elle est détectée tôt, plus le problème est facile à corriger.

Who is online

Users browsing this forum: No registered users and 0 guests