crt0.me
/notes

Triggering kernel OOB write with a crafted HID device

Kernel heap-based buffer overflow in IOHIDFamily (CVE-2025-43302)

About half a year ago, Apple released major software updates for watchOS 26, visionOS 26, macOS 26, tvOS 26, and iOS and iPadOS 26. These updates fixed a kernel heap-based buffer overflow vulnerability I reported in IOHIDFamily. This note describes the vulnerability.

The Bug

IOHIDFamily accepts the creation of a virtual HID device through its own IOHIDResourceUserClient. It parses an HID report descriptor retrieved from userland on the kernel side, then creates and keeps a hierarchy structure so that it can accept additional requests from the user to handle the previously inserted HID device.

When I started looking for vulnerabilities in IOHIDFamily, I found that the HID report descriptor specification is complex, while the parser implementation does not appear to be maintained frequently. The parser implementation in IOHIDFamily can be found under IOHIDSystem/IOHIDDescriptorParser.

After IOHIDDescriptorParser finishes parsing a given HID report descriptor, it creates an HID element hierarchy in IOHIDElementContainer. The hierarchy consists of an array whose elements are IOHIDElementContainerPrivate, and it is stored in IOHIDElementContainer::_elements.

First, look at IOHIDElementContainerPrivate::createReport before the patch:

bool IOHIDElementPrivate::createReport( UInt8           reportID,
                                 void *		 reportData,  // this report should be alloced outisde this method.
                                 UInt32 *        reportLength,
                                 IOHIDElementPrivate ** next )
    if ( reportData )
    {
        writeReportBits( _elementValue->value,   	/* source buffer      */
                    (UInt8 *) reportData,  	      /* destination buffer */
                    (_reportBits * _reportCount), /* bits to copy       */
                    _reportStartBit);       	    /* dst start bit      */                           
 
        handled = true;
    
        // Clear the transaction state
        _transactionState = kIOHIDTransactionStateIdle;
    }
}
static void writeReportBits( const UInt32 * src,
                           UInt8 *        dst,
                           UInt32         bitsToCopy,
                           UInt32         dstStartBit = 0)
{
    UpdateByteOffsetAndShift( dstStartBit, dstOffset, dstShift );
 
    if (dstStartBit % 8 == 0 && bitsToCopy % 8 == 0) {
        memcpy((dst + dstOffset), (src + srcOffset), bitsToCopy / 8);
        return;
    }

The code above is the key to this vulnerability. It copies _elementValue->value to reportData with _reportBits * _reportCount / 8 bytes using memcpy, assuming that _reportBits * _reportCount / 8 is less than the length of reportData. This condition is normally preserved by the implementation, but some code breaks the assumption. First, see how reportData is allocated:

IOReturn IOHIDDevice::runElementValues(IOHIDElementCookie * cookies, UInt32 cookieCount, IOOptionBits options, UInt32 completionTimeout, IOHIDCompletion * completion, IOBufferMemoryDescriptor * elementData, bool update)
{
    IOByteCount maxReportLength = max(_maxOutputReportSize, max(_maxFeatureReportSize, _maxInputReportSize));
    ...
    __Require_Action((report = IOBufferMemoryDescriptor::withOptions(kIODirectionOutIn | kIOMemoryKernelUserShared | kIOMemoryThreadSafe, maxReportLength)), exit, ret = kIOReturnNoMemory);
}

The report variable in the code above is ultimately stored as reportData. Its length is the maximum value of _maxOutputReportSize, _maxFeatureReportSize, and _maxInputReportSize. The code below shows where these variables are retrieved from.

IOReturn IOHIDDevice::parseReportDescriptor( IOMemoryDescriptor * report,
                                             IOOptionBits         options __unused)
{
    _maxInputReportSize = _elementContainer->getMaxInputReportSize();
    _maxOutputReportSize = _elementContainer->getMaxOutputReportSize();
    _maxFeatureReportSize = _elementContainer->getMaxFeatureReportSize();
}
IOReturn IOHIDElementContainer::createElementHierarchy(HIDPreparsedDataRef parseData)
{
    // Get a summary of device capabilities.
    status = HIDGetCapabilities(parseData, &caps);
    __Require_noErr_Action(status, exit, {
        DescriptorLog("createElementHierarchy HIDGetCapabilities failed: 0x%x", (unsigned int)status);
    });
    
    _maxInputReportSize = (UInt32)caps.inputReportByteLength;
    _maxOutputReportSize = (UInt32)caps.outputReportByteLength;
    _maxFeatureReportSize = (UInt32)caps.featureReportByteLength;
} 
OSStatus HIDGetCapabilities(HIDPreparsedDataRef preparsedDataRef, HIDCapabilitiesPtr ptCapabilities)
{
  for (i=0; i<(int)ptPreparsedData->reportCount; i++)
  {
      ptReport = &ptPreparsedData->reports[i];
      if (ptCapabilities->inputReportByteLength < (IOByteCount)ptReport->inputBitCount)
        ptCapabilities->inputReportByteLength = ptReport->inputBitCount;
      if (ptCapabilities->outputReportByteLength < (IOByteCount)ptReport->outputBitCount)
        ptCapabilities->outputReportByteLength = ptReport->outputBitCount;
      if (ptCapabilities->featureReportByteLength < (IOByteCount)ptReport->featureBitCount)
        ptCapabilities->featureReportByteLength = ptReport->featureBitCount;
  }
  ptCapabilities->inputReportByteLength = (ptCapabilities->inputReportByteLength + 7) /8;
  ptCapabilities->outputReportByteLength = (ptCapabilities->outputReportByteLength + 7)/8;
  ptCapabilities->featureReportByteLength = (ptCapabilities->featureReportByteLength + 7)/8;
}

Now we can see that these variables come from the maximum values of ptReport->inputBitCount, ptReport->outputBitCount, and ptReport->featureBitCount, respectively. Looking further into the implementation shows where ptReport->outputBitCount comes from.

OSStatus HIDProcessReportItem(HIDReportDescriptor *ptDescriptor, HIDPreparsedDataPtr ptPreparsedData)
{
	  // Check for overflow in multiplication
	  IOByteCount totalBits;
	  if (os_mul_overflow(ptReportItem->globals.reportSize, ptReportItem->globals.reportCount, &totalBits)) {
		  return kHIDBufferTooSmallErr;
	  }
	
	  // Check if the result fits in an int
	  if (totalBits > 0x7FFFFFFF) {
		  return kHIDBufferTooSmallErr;
	  }
	
	  iBits = (int)totalBits;
	  switch (ptDescriptor->item.tag)
	  {
      	case kHIDTagOutput:
            ptReportItem->reportType = kHIDOutputReport;
            ptReportItem->startBit = ptReport->outputBitCount;
            if (os_add_overflow(ptReport->outputBitCount, iBits, &ptReport->outputBitCount)) {
                return kHIDBufferTooSmallErr;
            }
            break;
	  }

This shows where ptReport->outputBitCount comes from. It is accumulated from the multiplied value of ptReportItem->globals.reportSize and ptReportItem->globals.reportCount. Considering the implicit assumption in IOHIDElementPrivate::createReport, ptReport->outputBitCount must be smaller than _reportBits * _reportCount.

Next, see where _reportBits and _reportCount come from. I chose a value element rather than a button element for reproducibility:

IOHIDElementPrivate *
IOHIDElementPrivate::valueElement( IOHIDElementContainer *     owner,
                            IOHIDElementType  type,
                            HIDValueCapabilitiesPtr   value,
                            IOHIDElementPrivate *    parent )
{
    element->_reportBits     = value->bitSize;
    element->_reportCount    = value->reportCount;
 
    if ( value->isRange )
    {
        element->_usageMin = value->u.range.usageMin;
        element->_usageMax = value->u.range.usageMax;
    
        element->_reportCount = 1;
    }
}
OSStatus HIDGetSpecificValueCapabilities(HIDReportType reportType,
									  HIDUsage usagePage,
									  UInt32 iCollection,
									  HIDUsage usage,
									  HIDValueCapabilitiesPtr valueCaps,
									  UInt32 *piValueCapsLength,
									  HIDPreparsedDataRef preparsedDataRef)
{
    ptCapability->bitSize = (UInt32)ptReportItem->globals.reportSize;
    if (ptUsageItem->isRange)
    {
		  iCount = ptUsageItem->usageMaximum - ptUsageItem->usageMinimum;
		  iCount++;		// Range count was off by one.
    }
    ptCapability->reportCount = iCount;
}

Interestingly, if a value element has a range, its count is automatically changed to 1. This is problematic because the parser accepts a zero report count, so element->_reportCount might be zero. That parser previously accepted only non-zero values, but was later changed to allow zero for backward compatibility. Because of this, _reportBits * _reportCount can be greater than the length of reportData, triggering a kernel out-of-bounds write.

Exploitability

I do not think this is useful for compromising the operating system, but I also want to show how to trigger the vulnerability from a userland application. IOHIDElementPrivate::createReport is called from IOHIDDevice::runElementValues:

IOReturn IOHIDDevice::runElementValues(IOHIDElementCookie * cookies, UInt32 cookieCount, IOOptionBits options, UInt32 completionTimeout, IOHIDCompletion * completion, IOBufferMemoryDescriptor * elementData, bool update)
{
    cookiesRef = OSBoundedArrayRef<IOHIDElementCookie>(&(cookies[0]), cookieCount);
    for ( IOHIDElementCookie cookie : cookiesRef ) {
        if (!(element = GetElement(cookie)) || element->getTransactionState() != kIOHIDTransactionStatePending || !element->getReportType(&reportType) || (!update && reportType != kIOHIDReportTypeOutput && reportType != kIOHIDReportTypeFeature)) {
            continue;
        }
 
        reportID = element->getReportID();
        if (cookieCount > 1 && reportMap[reportID] & (1 << reportType)) {
            continue;
        } else {
            reportMap[reportID] |= (1 << reportType);
        }
 
        if (!update) {
            _elementContainer->createReport(reportType, reportID, report);
        } else {
            report->setLength(maxReportLength);
        }
    }
}

To trigger the heap-buffer-overflow vulnerability with arbitrary data, we need to know the exact cookie of the element where the vulnerability is located. When we provide the following HID report descriptor to the virtual HID device:

	0xa1, 0x03,      // COLLECTION (Report)
	0x19, 0x00,      // USAGE_MINIMUM (0)
	0x29, 0x00,      // USAGE_MAXIMUM (0)
	0x85, 0x01,      // REPORT_ID (1)
	0x95, 0x00,      // REPORT_COUNT (0)
	0x77, 0x00, 0x0, // REPORT_SIZE
	0x0c, 0x00,      // (655360)
	0x91, 0x02,      // OUTPUT (Var)
	0xc0,            // END_COLLECTION

the element hierarchy looks like this: The HID element hierarchy

There are two Null and ArrayHandler elements. They are automatically inserted by IOHIDFamily during construction of the HID element hierarchy. We need to skip these elements, as well as the collection element from the provided report descriptor. In summary, the elements in the hierarchy are ordered by cookie as follows:

    1. Root collection (virtually created by IOHIDFamily)
    1. Collection (non-virtual)
    1. Null (size = 1, parent = 0)
    1. ArrayHandler (size = 0, parent = 0)
    1. Null (size = 1, parent = 0)
    1. ArrayHandler (size = 0, parent = 0)
    1. Value (size = 786432, parent = 1)

The vulnerable element is 6. Value, so we can use cookie 6 when calling IOHIDLibUserClient::postElementValues, which ultimately calls IOHIDDevice::runElementValues.

Apple’s Fix

Apple fixed the vulnerability by inserting additional checks in multiple places in IOHIDFamily: apple_hid_fix_01.png apple_hid_fix_02.png