Partager via


Accesseurs en mode utilisateur

Les accesseurs en mode utilisateur (UMA) sont un ensemble d’API conçues pour accéder et manipuler en toute sécurité la mémoire en mode utilisateur à partir du code en mode noyau. Ces DDIs traitent les vulnérabilités de sécurité courantes et les erreurs de programmation qui peuvent se produire lorsque les pilotes en mode noyau accèdent à la mémoire en mode utilisateur.

Le code en mode noyau qui accède à la mémoire en mode utilisateur sera bientôt nécessaire pour utiliser UMA.

Problèmes possibles lors de l’accès à la mémoire en mode utilisateur à partir du mode noyau

Lorsque le code en mode noyau doit accéder à la mémoire en mode utilisateur, plusieurs défis surviennent :

  • Les applications en mode utilisateur peuvent passer des pointeurs malveillants ou non valides au code en mode noyau. Le manque de validation appropriée peut entraîner une altération de la mémoire, des blocages ou des vulnérabilités de sécurité.

  • Le code en mode utilisateur est multithreadé. Par conséquent, différents threads peuvent modifier la même mémoire en mode utilisateur entre des accès distincts en mode noyau à celui-ci, ce qui peut entraîner une corruption de la mémoire du noyau.

  • Les développeurs en mode noyau oublient souvent de sonder la mémoire en mode utilisateur avant de l’accéder, ce qui est un problème de sécurité.

  • Les compilateurs supposent une exécution monothread et peuvent optimiser en éliminant les accès à la mémoire redondants. Les programmeurs qui ignorent ces optimisations peuvent écrire du code non sécurisé.

Les extraits de code suivants illustrent ces problèmes.

Exemple 1 : Corruption possible de la mémoire en raison du multithreading en mode utilisateur

Le code en mode noyau qui doit accéder à la mémoire en mode utilisateur doit le faire dans un __try/__except bloc pour garantir que la mémoire est valide. L’extrait de code suivant montre un modèle classique pour accéder à la mémoire en mode utilisateur :

// User-mode structure definition
typedef struct _StructWithData {
    ULONG Size;
    CHAR* Data[1];
} StructWithData;

// Kernel-mode call that accesses user-mode memory
void MySysCall(StructWithData* Ptr) {
    __try {
        // Probe user-mode memory to ensure it's valid
        ProbeForRead(Ptr, sizeof(StructWithData), 1);

        // Allocate memory in the kernel
        PVOID LocalData = ExAllocatePool2(POOL_FLAG_PAGED, Ptr->Size);
        
        // Copy user-mode data into the heap allocation
        RtlCopyMemory(LocalData, Ptr->Data, Ptr->Size);
    } __except (…) {
        // Handle exceptions
    }
}

Cet extrait de code sonde d’abord la mémoire, qui est une étape importante mais fréquemment ignorée.

Toutefois, un problème qui peut se produire dans ce code est dû au multithreading en mode utilisateur. Plus précisément, Ptr->Size peut changer après l’appel à ExAllocatePool2 , mais avant l’appel à RtlCopyMemory, ce qui peut entraîner une altération de la mémoire dans le noyau.

Exemple 2 : Problèmes possibles en raison des optimisations du compilateur

Une tentative de résolution du problème de multithreading dans l’exemple 1 peut être de copier Ptr->Size dans une variable locale avant l’allocation et la copie :

void MySysCall(StructWithData* Ptr) {
    __try {
        // Probe user-mode memory to ensure it's valid
        ProbeForRead(Ptr, sizeof(StructWithData), 1);
        
        // Read Ptr->Size once to avoid possible memory change in user mode
        ULONG LocalSize = Ptr->Size;
        
        // Allocate memory in the kernel
        PVOID LocalData = ExAllocatePool2(POOL_FLAG_PAGED, LocalSize);
        
        //Copy user-mode data into the heap allocation
        RtlCopyMemory(LocalData, Ptr, LocalSize);
    } __except (…) {}
}

Bien que cette approche atténue le problème provoqué par le multithreading, il n’est toujours pas sécurisé, car le compilateur ne connaît pas plusieurs threads et suppose donc un seul thread d’exécution. En guise d’optimisation, le compilateur peut voir qu’il a déjà une copie de la valeur pointée par Ptr->Size dans sa pile, et donc ne pas effectuer la copie vers LocalSize.

Solution d’accesseurs en mode utilisateur

L’interface UMA résout les problèmes rencontrés lors de l’accès à la mémoire en mode utilisateur à partir du mode noyau. UMA fournit :

  • Détection automatique : la détection explicite (ProbeForRead/ProbeForWrite) n’est plus nécessaire, car toutes les fonctions UMA garantissent la sécurité des adresses.

  • Accès volatile : toutes les DDIS UMA utilisent une sémantique volatile pour empêcher les optimisations du compilateur.

  • Facilité de portabilité : l’ensemble complet des DDIS UMA permet aux clients de porter facilement leur code existant afin d’utiliser les DDIS UMA, ce qui garantit que la mémoire en mode utilisateur est accessible en toute sécurité et correctement.

Exemple utilisant UMA DDI

À l’aide de la structure en mode utilisateur précédemment définie, l’extrait de code suivant montre comment utiliser UMA pour accéder en toute sécurité à la mémoire en mode utilisateur.

void MySysCall(StructWithData* Ptr) {
    __try {

        // This UMA call probes the passed user-mode memory and does a
        // volatile read of Ptr->Size to ensure it isn't optimized away by the compiler.
        ULONG LocalSize = ReadULongFromUser(&Ptr->Size);
        
        // Allocate memory in the kernel.
        PVOID LocalData = ExAllocatePool2(POOL_FLAG_PAGED, LocalSize);
        
        //This UMA call safely copies UM data into the KM heap allocation.
        CopyFromUser(&LocalData, Ptr, LocalSize);
        
        // To be safe, set LocalData->Size to be LocalSize, which was the value used
        // to make the pool allocation just in case LocalData->Size was changed.
        ((StructWithData*)LocalData)->Size = LocalSize;

    } __except (…) {}
}

Implémentation et utilisation UMA

L’interface UMA est fournie dans le cadre du Kit de pilotes Windows (WDK) :

  • Les déclarations de fonction se trouvent dans le fichier d’en-tête usermode_accessors.h .
  • Les implémentations de fonction se trouvent dans une bibliothèque statique nommée umaccess.lib.

UMA fonctionne sur toutes les versions de Windows, pas seulement sur la dernière version. Vous devez utiliser la dernière version du WDK pour obtenir les déclarations de fonction et les implémentations depuis usermode_accessors.h et umaccess.lib, respectivement. Le pilote résultant s’exécutera correctement sur les versions antérieures de Windows.

Umaccess.lib fournit une implémentation sécurisée et de bas niveau pour toutes les DDIS. Sur les versions UMA du noyau Windows, les pilotes auront toutes leurs fonctions redirigées vers une version plus sûre implémentée dans ntoskrnl.exe.

Toutes les fonctions d’accesseur en mode utilisateur doivent être exécutées dans un gestionnaire d’exceptions structurées (SEH) en raison d’exceptions potentielles lors de l’accès à la mémoire en mode utilisateur.

Types d'accesseurs DDIs en mode utilisateur

UMA fournit différentes DDIS pour différents types d’accès à la mémoire en mode utilisateur. La plupart de ces DDIs sont destinées aux types de données fondamentaux, tels que BOOLEAN, ULONG et les pointeurs. En outre, UMA fournit des DDIs pour l’accès en mémoire en bloc, la récupération de longueur de chaîne et les opérations interblocées.

DDIS génériques pour les types de données fondamentaux

UMA fournit six variantes de fonction pour la lecture et l’écriture de types de données simples. Par exemple, les fonctions suivantes sont disponibles pour les valeurs BOOLEAN :

Nom de la fonction Description
ReadBooleanFromUser Lit une valeur à partir de la mémoire en mode utilisateur.
ReadBooleanFromUserAcquire Lisez une valeur de la mémoire en mode utilisateur avec une sémantique d'acquisition pour l'ordre de mémoire.
ReadBooleanFromMode Lisez la mémoire en mode utilisateur ou en mode noyau en fonction d’un paramètre de mode.
WriteBooleanToUser Écrivez une valeur dans la mémoire en mode utilisateur.
WriteBooleanToUserRelease Écrire une valeur dans la mémoire en mode utilisateur avec une sémantique de libération pour l'ordonnancement de la mémoire.
WriteBooleanToMode Écrivez dans la mémoire en mode utilisateur ou en mode noyau en fonction d’un paramètre de mode.

Pour les fonctions ReadXxxFromUser , le paramètre Source doit pointer vers l’espace d’adressage virtuel en mode utilisateur (VAS). La même chose est vraie dans les versions ReadXxxFromMode quand Mode == UserMode.

Pour LireXxxFromMode, quand Mode == KernelMode, le paramètre Source doit pointer vers le VAS en mode noyau. Si la définition de préprocesseur DBG est définie, l’opération échoue rapidement avec le code FAST_FAIL_KERNEL_POINTER_EXPECTED.

Dans les fonctions WriteXxxToUser , le paramètre Destination doit pointer vers le vaS en mode utilisateur. La même chose est vraie dans les versions WriteXxxToMode quand Mode == UserMode.

Manipulation de la copie et de la mémoire par les DDIs

UMA fournit des fonctions permettant de copier et de déplacer de la mémoire entre les modes utilisateur et noyau, y compris les variantes pour les copies nontemporales et alignées. Ces fonctions sont marquées avec des annotations indiquant les exceptions SEH potentielles et les exigences IRQL (max APC_LEVEL).

Les exemples incluent CopyFromUser, CopyToMode et CopyFromUserToMode.

Les macros telles que CopyFromModeAligned et CopyFromUserAligned incluent une vérification de l'alignement pour garantir la sécurité avant d’effectuer l’opération de copie.

Les macros telles que CopyFromUserNonTemporal et CopyToModeNonTemporal fournissent des copies nontemporales qui évitent la pollution du cache.

Structuration des macros de lecture/écriture

Les macros pour la lecture et l’écriture de structures entre les modes garantissent la compatibilité et l’alignement des types, en appelant des fonctions d’assistance avec des paramètres de taille et de mode. Les exemples incluent WriteStructToMode, ReadStructFromUser et leurs variantes alignées.

Fonctions de remplissage et de mémoire zéro

Les DDI sont fournies pour remplir ou mettre à zéro la mémoire dans des espaces d'adressage en mode utilisateur ou noyau, avec des paramètres spécifiant la destination, la longueur, la valeur de remplissage et le mode. Ces fonctions portent également des annotations SEH et IRQL.

Les exemples incluent FillUserMemory et ZeroModeMemory.

Opérations interblocées

UMA inclut des opérations interblocées pour l’accès à la mémoire atomique, qui sont essentielles pour les manipulations de mémoire sécurisées de threads dans des environnements simultanés. Les DDIs sont fournis pour les valeurs 32 bits et 64 bits, avec des versions ciblant la mémoire utilisateur ou de mode.

Les exemples incluent InterlockedCompareExchangeToUser, InterlockedOr64ToMode et InterlockedAndToUser.

Longueur de chaîne DDIs

Les fonctions permettant de déterminer les longueurs de chaîne en toute sécurité à partir de la mémoire utilisateur ou en mode sont incluses, prenant en charge les chaînes ANSI et large-caractère. Ces fonctions sont conçues pour déclencher des exceptions sur l’accès à la mémoire non sécurisé et sont limitées par IRQL.

Les exemples incluent StringLengthFromUser et WideStringLengthFromMode.

Accesseurs de chaîne Unicode et entiers volumineux

UMA fournit des DDIs pour lire et écrire les types LARGE_INTEGER, ULARGE_INTEGER, et UNICODE_STRING entre la mémoire utilisateur et celle du mode noyau. Les variantes ont une sémantique d'acquisition et de libération avec des paramètres de mode pour assurer la sécurité et l'exactitude.

Les exemples incluent ReadLargeIntegerFromUser, WriteUnicodeStringToMode et WriteULargeIntegerToUser.

Sémantique d’acquisition et de libération

Sur certaines architectures telles que ARM, le processeur peut réorganiser les accès à la mémoire. Les DDIS génériques ont tous une implémentation Acquire/Release si vous avez besoin d’une garantie que les accès mémoire ne sont pas réorganisé pour l’accès en mode utilisateur.

  • Acquérir une sémantique empêche la réorganisation de la charge par rapport à d’autres opérations de mémoire.
  • La sémantique de mise en production empêche la réorganisation du magasin par rapport à d’autres opérations de mémoire.

Les exemples de sémantique d’acquisition et de libération dans UMA incluent ReadULongFromUserAcquire et WriteULongToUserRelease.

Pour plus d’informations, consultez La sémantique d’acquisition et de mise en production.

Meilleures pratiques

  • Utilisez toujours les DDIS UMA lors de l’accès à la mémoire en mode utilisateur à partir du code du noyau.
  • Gérez les exceptions avec les blocs appropriés __try/__except .
  • Utilisez des DDIS en mode quand votre code peut gérer à la fois la mémoire en mode utilisateur et en mode noyau.
  • Envisagez d’acquérir/libérer la sémantique lorsque l’ordre de mémoire est important pour votre cas d’usage.
  • Validez les données copiées après leur copie dans la mémoire du noyau pour garantir la cohérence.

Prise en charge matérielle future

Les accesseurs en mode utilisateur sont conçus pour prendre en charge les futures fonctionnalités de sécurité matérielle telles que :

  • SMAP (Prévention de l’accès en mode superviseur) : empêche le code du noyau d’accéder à la mémoire en mode utilisateur, sauf par le biais de fonctions désignées telles que les DDIS UMA.
  • ARM PAN (Accès jamais privilégié) : protection similaire sur les architectures ARM.

En utilisant des DDIS UMA de manière cohérente, les pilotes seront compatibles avec ces améliorations de sécurité lorsqu’ils sont activés dans les futures versions de Windows.