Udostępnij przez


Metody dostępu w trybie użytkownika

Metody dostępu w trybie użytkownika (UMA) to zestaw interfejsów DDI zaprojektowanych do bezpiecznego dostępu do pamięci w trybie użytkownika i jej manipulacji z poziomu kodu w trybie jądra. Te identyfikatory DDI dotyczą typowych luk w zabezpieczeniach i błędów programowania, które mogą wystąpić, gdy sterowniki trybu jądra uzyskują dostęp do pamięci w trybie użytkownika.

Kod trybu jądra, który uzyskuje dostęp do pamięci w trybie użytkownika lub manipuluje nimi wkrótce będzie wymagany do korzystania z usługi UMA.

Możliwe problemy podczas uzyskiwania dostępu do pamięci trybu użytkownika z trybu jądra

Gdy kod trybu jądra musi uzyskać dostęp do pamięci w trybie użytkownika, pojawia się kilka wyzwań:

  • Aplikacje w trybie użytkownika mogą przekazywać złośliwe lub nieprawidłowe wskaźniki do kodu trybu jądra. Brak prawidłowej weryfikacji może prowadzić do uszkodzenia pamięci, awarii lub luk w zabezpieczeniach.

  • Kod trybu użytkownika jest wielowątkowy. W związku z tym różne wątki mogą modyfikować tę samą pamięć trybu użytkownika między oddzielnymi dostępami do trybu jądra, co może prowadzić do uszkodzenia pamięci jądra.

  • Deweloperzy trybu jądra często zapominają o sondowaniu pamięci w trybie użytkownika przed uzyskaniem do niej dostępu, co jest problemem z zabezpieczeniami.

  • Kompilatory zakładają wykonywanie w trybie jednowątkowym i mogą zoptymalizować to, co wydaje się nadmiernym dostępem do pamięci. Programiści nieświadomi takich optymalizacji mogą pisać niebezpieczny kod.

Poniższe fragmenty kodu ilustrują te problemy.

Przykład 1: Możliwe uszkodzenie pamięci z powodu wielowątkowości w trybie użytkownika

Kod, który działa w trybie jądra i musi uzyskać dostęp do pamięci w trybie użytkownika, powinien to zrobić w bloku __try/__except, aby upewnić się, że pamięć jest prawidłowa. Poniższy fragment kodu przedstawia typowy wzorzec uzyskiwania dostępu do pamięci w trybie użytkownika:

// 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
    }
}

Ten fragment kodu najpierw sonduje pamięć, co jest ważnym, ale często pomijanym krokiem.

Jednak jednym z problemów, które mogą wystąpić w tym kodzie, jest wielowątkowość w trybie użytkownika. W szczególności Ptr->Size może ulec zmianie po wywołaniu funkcji ExAllocatePool2, ale przed wywołaniem funkcji RtlCopyMemory, co potencjalnie prowadzi do uszkodzenia pamięci w jądrze.

Przykład 2: Możliwe problemy z powodu optymalizacji kompilatora

Próba rozwiązania problemu z wielowątkowością w przykładzie 1 mogłoby polegać na skopiowaniu Ptr->Size do zmiennej lokalnej przed alokacją i kopiowaniem.

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 (…) {}
}

Chociaż takie podejście ogranicza problem spowodowany przez wielowątkowość, nadal nie jest bezpieczne, ponieważ kompilator nie zna wielu wątków, a tym samym zakłada jeden wątek wykonywania. W ramach optymalizacji kompilator może zauważyć, że na jego stosie znajduje się już kopia wartości, na którą wskazuje Ptr->Size, i dlatego nie wykonuje kopiowania do LocalSize.

Rozwiązanie dostępu w trybie użytkownika

Interfejs UMA rozwiązuje problemy występujące podczas uzyskiwania dostępu do pamięci trybu użytkownika z trybu jądra. Usługa UMA zapewnia:

  • Automatyczne sondowanie: Sondowanie jawne (ProbeForRead/ProbeForWrite) nie jest już wymagane, ponieważ wszystkie funkcje UMA zapewniają bezpieczeństwo adresów.

  • Nietrwały dostęp: wszystkie UMA DDI używają semantyki zmiennej, aby zapobiec optymalizacjom kompilatora.

  • Łatwość przenoszenia: kompleksowy zestaw UMA DDIs ułatwia klientom przeniesienie istniejącego kodu, aby korzystać z UMA DDIs, zapewniając bezpieczny i prawidłowy dostęp do pamięci użytkownika.

Przykład użycia UMA DDI

Korzystając z wcześniej zdefiniowanej struktury trybu użytkownika, poniższy fragment kodu pokazuje, jak bezpiecznie uzyskiwać dostęp do pamięci trybu użytkownika za pomocą usługi UMA.

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 (…) {}
}

Implementacja i użycie usługi UMA

Interfejs UMA jest dostarczany jako część zestawu sterowników systemu Windows (WDK):

  • Deklaracje funkcji znajdują się w pliku nagłówka usermode_accessors.h .
  • Implementacje funkcji znajdują się w bibliotece statycznej o nazwie umaccess.lib.

Narzędzie UMA działa we wszystkich wersjach systemu Windows, a nie tylko na najnowszej wersji. Aby uzyskać deklaracje i implementacje funkcji z biblioteki usermode_accessors.h i umaccess.lib, należy użyć najnowszego zestawu WDK. Wynikowy sterownik będzie działać prawidłowo w starszych wersjach systemu Windows.

Biblioteka Umaccess.lib zapewnia bezpieczną implementację na poziomie dolnym dla wszystkich interfejsów DDI. W przypadku wersji jądra systemu Windows obsługujących usługę UMA sterowniki będą miały wszystkie funkcje przekierowane do bezpieczniejszej wersji zaimplementowanej w ntoskrnl.exe.

Wszystkie funkcje dostępu w trybie użytkownika muszą być wykonywane w programie obsługi wyjątków ustrukturyzowanych (SEH) ze względu na potencjalne wyjątki podczas uzyskiwania dostępu do pamięci w trybie użytkownika.

Typy interfejsów dostępu DDI w trybie użytkownika

UMA zapewnia różne interfejsy DDI dla różnych typów dostępu do pamięci w trybie użytkownika. Większość tych identyfikatorów DDI jest dla podstawowych typów danych, takich jak BOOLEAN, ULONG i wskaźniki. Usługa UMA dodatkowo zapewnia interfejsy DDI na potrzeby dostępu do pamięci zbiorczej, pobierania długości ciągów i operacji z synchronizacją.

Ogólne identyfikatory DDI dla podstawowych typów danych

Usługa UMA udostępnia sześć wariantów funkcji do odczytywania i zapisywania prostych typów danych. Na przykład następujące funkcje są dostępne dla wartości typu Boolean:

Nazwa funkcji Description
ReadBooleanFromUser Odczytaj wartość z pamięci trybu użytkownika.
ReadBooleanFromUserAcquire Odczytaj wartość z pamięci w trybie użytkownika przy użyciu semantyki na potrzeby porządkowania pamięci.
ReadBooleanFromMode Odczyt z pamięci w trybie użytkownika lub w trybie jądra na podstawie parametru trybu.
WriteBooleanToUser Zapisz wartość w pamięci w trybie użytkownika.
WriteBooleanToUserRelease Zapisz wartość w pamięci trybu użytkownika, stosując semantykę zwalniającą dla porządkowania pamięci.
WriteBooleanToMode Zapisz w pamięci trybu użytkownika lub trybu jądra w zależności od parametru trybu.

W przypadku funkcji ReadXxxFromUser parametr Source musi wskazywać wirtualną przestrzeń adresową (VAS) w trybie użytkownika. To samo dotyczy wersji ReadXxxFromMode, gdy Mode == UserMode.

W przypadku parametru ReadXxxFromMode, gdy Mode == KernelModeparametr Source musi wskazywać plik w systemie VAS w trybie jądra. Jeśli zdefiniowano definicję preprocesora DBG, operacja szybko kończy się niepowodzeniem z kodem FAST_FAIL_KERNEL_POINTER_EXPECTED.

W funkcjach WriteXxxToUser parametr Destination musi wskazywać na przestrzeń adresową VAS w trybie użytkownika. To samo dotyczy wersji WriteXxxToMode, gdy Mode == UserMode.

Interfejsy DDI do kopiowania i manipulacji pamięcią

Usługa UMA udostępnia funkcje kopiowania i przenoszenia pamięci między trybami użytkownika i jądra, w tym warianty dla kopii nietemporalnych i wyrównanych. Te funkcje są oznaczone adnotacjami wskazującymi potencjalne wyjątki SEH i wymagania IRQL (maksymalna APC_LEVEL).

Przykłady to CopyFromUser, CopyToMode i CopyFromUserToMode.

Makra, takie jak CopyFromModeAligned i CopyFromUserAligned, obejmują sprawdzanie wyrównania dla bezpieczeństwa przed wykonaniem operacji kopiowania.

Makra, takie jak CopyFromUserNonTemporal i CopyToModeNonTemporal, zapewniają kopie nietemporalne, które zapobiegają zanieczyszczeniu pamięci podręcznej.

Makrosy do odczytu/zapisu struktur

Makra programistyczne do odczytywania i zapisywania struktur pomiędzy trybami zapewniają zgodność typów i wyrównanie, wywołując funkcje pomocnicze z parametrami rozmiaru i trybu. Przykłady obejmują WriteStructToMode, ReadStructFromUser i ich wyrównane warianty.

Funkcje wypełniania i zerowania pamięci

DDI są udostępniane w celu wypełnienia lub wyzerowania pamięci w przestrzeniach adresowych trybu użytkownika lub systemu, z parametrami określającymi miejsce docelowe, długość, wartość wypełnienia i tryb. Te funkcje mają również adnotacje SEH i IRQL.

Przykłady obejmują FillUserMemory i ZeroModeMemory.

Operacje zakleszczone

Pakiet UMA obejmuje operacje z blokadą dostępu do pamięci niepodzielnej, które są niezbędne w przypadku manipulowania pamięcią bezpieczną wątkowo w środowiskach współbieżnych. DDI są dostępne zarówno dla wartości 32-bitowych, jak i 64-bitowych, z wersjami dedykowanymi dla pamięci użytkownika lub trybu systemowego.

Przykłady obejmują InterlockedCompareExchangeToUser, InterlockedOr64ToMode i InterlockedAndToUser.

Długość ciągu DDI

Funkcje umożliwiające bezpieczne określanie długości ciągów z pamięci użytkownika lub trybu są uwzględniane, obsługując zarówno ciągi ANSI, jak i ciągi szerokoznakowe. Te funkcje są zaprojektowane do zgłaszania wyjątków podczas niebezpiecznego dostępu do pamięci i są ograniczone przez IRQL.

Przykłady to StringLengthFromUser i WideStringLengthFromMode.

Metody dostępu do dużych liczb całkowitych i ciągów Unicode

UMA udostępnia DDI do odczytu i zapisu typów LARGE_INTEGER, ULARGE_INTEGER i UNICODE_STRING między pamięcią użytkownika a pamięcią trybu jądra. Warianty mają semantykę przejęcia i zwolnienia z parametrami trybu dla zapewnienia bezpieczeństwa i poprawności.

Przykłady obejmują ReadLargeIntegerFromUser, WriteUnicodeStringToMode i WriteULargeIntegerToUser.

Uzyskiwanie i wydawanie semantyki

W przypadku niektórych architektur, takich jak ARM, procesor CPU może zmienić kolejność dostępu do pamięci. Uniwersalne DDI mają implementację mechanizmu Acquire/Release, jeśli potrzebujesz gwarancji, że dostępy do pamięci nie są reorganizowane podczas dostępu w trybie użytkownika.

  • Uzyskiwanie semantyki zapobiega zmienianiu kolejności obciążenia względem innych operacji pamięci.
  • Semantyka zwalniania zapobiega zmianie kolejności operacji zapisu w pamięci względem innych operacji pamięci.

Przykłady semantyki uzyskiwania i wydawania w usłudze UMA obejmują readULongFromUserAcquire i WriteULongToUserRelease.

Aby uzyskać więcej informacji, zobacz Semantyka pozyskiwania i zwalniania.

Najlepsze rozwiązania

  • Zawsze używaj identyfikatorów UMA DDI podczas uzyskiwania dostępu do pamięci trybu użytkownika z kodu jądra.
  • Obsłuż wyjątki za pomocą odpowiednich __try/__except bloków.
  • Użyj trybowych identyfikatorów DDI, gdy kod może obsługiwać pamięć zarówno w trybie użytkownika, jak i jądra.
  • Rozważ semantykę nabycia/zwolnienia kiedy kolejność pamięci jest ważna w twoim scenariuszu użycia.
  • Sprawdź poprawność skopiowanych danych po skopiowaniu ich do pamięci jądra, aby zapewnić spójność.

Przyszła obsługa sprzętu

Metody dostępu w trybie użytkownika są przeznaczone do obsługi przyszłych funkcji zabezpieczeń sprzętowych, takich jak:

  • SMAP (zapobieganie dostępowi w trybie nadzorcy): uniemożliwia dostęp kodu jądra do pamięci trybu użytkownika z wyjątkiem wyznaczonych funkcji, takich jak UMA DDI.
  • ARM PAN (Nigdy dostęp uprzywilejowany): podobna ochrona w architekturach ARM.

Dzięki spójnym używaniu UMA DDI sterowniki będą zgodne z tymi ulepszeniami zabezpieczeń po włączeniu ich w przyszłych wersjach systemu Windows.