苹果在 iOS 9.2 和 iOS 9.2.1 中陆续修补了大量漏洞,其中Google Project Zero团队的Ian Beer报告了多个内核漏洞,并且在苹果修补后给出了 漏洞细节 。
通过查看公告可以发现除了熟知的UAF类型漏洞外(例如Pangu9中使用的即是UAF漏洞),还包含了多个条件竞争类型的漏洞。通过分析漏洞的细节可以发现苹果在许多情况下都没有考虑用户态多线程调用导致的竞争问题,因此不排除在别的模块也有类似的漏洞(例如在未开源的内核扩展中)。
此次被修补的漏洞包含数个能被实际利用的漏洞,其中有一个漏洞能够绕过地址随机等保护机制完全攻破内核(可被用于越狱)。下面会简单分析两个漏洞的细节,讨论编写利用的一些思路。
通过查看 542报告 可以知道漏洞的主要原因在于IOFreeAligned释放dataQueue后没有置空。虽然大多数情况下随后的initWithEntries函数会对dataQueue重新赋值,但是如果initWithEntries失败的话,dataQueue并不会被赋值。如果再次调用start函数就会导致double free的问题。
void IOHIDEventQueue::start() { ... if (dataQueue) { IOFreeAligned(dataQueue, round_page_32(getQueueSize() + DATA_QUEUE_MEMORY_HEADER_SIZE)); } if (_descriptor) { _descriptor->release(); _descriptor = 0; } // init the queue again. This will allocate the appropriate data. if ( !initWithEntries(_numEntries, _maxEntrySize) ) { goto START_END; }
如何使IOHIDEventQueue::initWithEntries失败?如果满足”numEntries > UINT32_MAX / entrySize”即能导致函数失败。而其中_numEntries是我们在创建IOHIDEventQueue时可以指定的。_maxEntrySize则会在IOHIDEventQueue::addElement函数中根据event的size来修改,也是可控的(通过创建设备时输入特殊的ReportDescriptor)。如果将_maxEntrySize设置成非常大的值即能导致函数失败。
Boolean IOHIDEventQueue::initWithEntries(UInt32 numEntries, UInt32 entrySize) { UInt32 size = numEntries*entrySize; if ( numEntries > UINT32_MAX / entrySize ) return false; if ( size < MIN_HID_QUEUE_CAPACITY ) size = MIN_HID_QUEUE_CAPACITY; return super::initWithCapacity(size); }
由于实际上dataQueue的分配是页面对齐的,因此两次释放很难直接利用来控制PC。通过进一步分析IOHIDEventQueue::start的调用者IOHIDLibUserClient可以发现该类重写了clientMemoryForType方法并允许映射queue到用户态(type设置为_createQueue函数返回的token),从而能够直接在用户态下读写dataQueue指向的内核内存区域。因此当dataQueue被释放后需要想办法用内核对象来占位,但由于需要分配较大的对象(页面对齐),可以考虑通过OSArray来分配一组内核对象尝试占位。从而在用户态可以修改OSArray中的内核对象的地址,从而控制vtable来控制PC执行代码。
IOReturn IOHIDLibUserClient::clientMemoryForTypeGated( UInt32 token, IOOptionBits * options, IOMemoryDescriptor ** memory ) { IOReturn ret = kIOReturnNoMemory; IOMemoryDescriptor *memoryToShare = NULL; IOHIDEventQueue *queue = NULL; // if the type is element values, then get that if (token == kIOHIDLibUserClientElementValuesType) { // if we can get an element values ptr if (fValid && fNub && !isInactive()) memoryToShare = fNub->getMemoryWithCurrentElementValues(); } // otherwise, the type is token else if (NULL != (queue = getQueueForToken(token))) { memoryToShare = queue->getMemoryDescriptor(); } ...
查看 598报告 中提到IORegistryIterator对象由于没有线程互斥的保护,导致对成员进行操作的时候可能会出错。例如两个线程同时调用exitEntry可能会触发double free。显然通过条件竞争触发二次释放来控制PC的稳定性不高,也没有办法绕过内核地址随机化的保护。
bool IORegistryIterator::exitEntry( void ) { IORegCursor * gone; if( where->iter) { where->iter->release(); where->iter = 0; if( where->current)// && (where != &start)) where->current->release(); } if( where != &start) { gone = where; where = gone->next; IOFree( gone, sizeof(IORegCursor)); return( true); } else return( false); }
是否存在其它稳定的条件竞争利用并且能够泄露内核地址?观察IORegistryIterator中其它操作成员的函数,enterEntry函数中会分配一个新的where,并将这个where的next指向之前的where(在单向列表的头部插入一个结点)。而之前分析的exitEntry函数则会尝试释放where->iter并释放where,之后将next指向的结点赋值给新的where(把单向列表的头部结点移除并释放)。
void IORegistryIterator::enterEntry( const IORegistryPlane * enterPlane ) { IORegCursor * prev; prev = where; where = (IORegCursor *) IOMalloc( sizeof(IORegCursor)); assert( where); if( where) { where->iter = 0; where->next = prev; where->current = prev->current; plane = enterPlane; } }
在两个线程中分别调用exitEntry和enterEntry可能导致新创建的where的next指针指向一个已经被释放了的内存区域。执行序列:
再次调用exitEntry后where将会指向被释放的内存区域,而该区域的内容我们可以通过堆风水控制。进一步获取代码执行相对容易,”where->iter->release();”这个虚函数调用完全可以控制。内核信息泄露则可以通过占位再释放后,再用一个同样的大小的object去占位,然后读出vtable来获取内核地址。
值得注意的是这个漏洞可以在iOS的沙盒内触发,因此在APP内就可以直接攻击内核,获取内核代码执行权限。建议用户尽快升级到最新版本,并且避免安装来历不明的APP。