描绘iOS漏洞 - DFSEC研究
2025年3月31日,苹果发布了iOS 18.4,据称修复了76个漏洞。其中一个漏洞存在于IOGPUFamily中,这是一个负责处理与GPU通信的内核驱动。苹果将该问题描述为越界写入:
IOGPUFamily
影响:应用程序可能导致意外系统终止或写入内核内存
描述:通过改进输入验证解决了越界写入问题。
CVE-2025-24257:Cyberserval的王宇
虽然苹果的描述可能有误导性或有时明显错误,但它仍然给出了漏洞可能样貌的大致概念。
发现
这个漏洞可以通过静态分析发现,但由于我们拥有修补和未修补的内核镜像(并且知道漏洞存在于哪个驱动中),我们可以使用差异比较来查找更改的代码。由于IOGPUFamily不是开源的,我们可以使用反汇编器(如IDA)配合差异比较程序(如Diaphora或BinDiff)。
由于macOS也受到影响(macOS和iOS共享内核和许多驱动),我们可以使用来自macOS KDK的符号化IOGPUFamily kext进行分析。
在最新易受攻击的kext和修补版本上运行Diaphora,在"Interesting matches"选项卡中产生了85个更改的函数。由于我们不知道哪个函数包含易受攻击的代码,我们必须查看每个匹配项并分析差异。
其中一个函数是IOGPUResource::newResourceGroup,查看差异的伪代码视图显示以下内容:
修补前:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
if ( userHashSize )
localHashSz = userHashSize; // 完全用户控制
else
localHashSz = 0x40;
a4.i32[0] = (int)localHashSz;
v5 = (uint8x8_t)vcnt_s8(a4);
v5.i16[0] = vaddlv_u8(v5);
if ( v5.i32[0] == 1 )
{
AGXResourceTexture = IOGPU->createResource(this, a2, 3);
if ( AGXResourceTexture )
{
AGXResourceTexture->lock = IOLockAlloc();
groupMemory = IOGPUGroupMemory::groupMemory(this, localHashSz);
AGXResourceTexture->groupMem = groupMemory;
if ( !groupMemory )
goto nomem;
mallocedBuf = IOMallocTypeImpl();
*(_OWORD *)mallocedBuf = 0u;
*(_OWORD *)(mallocedBuf + 16) = 0u;
*(uint64_t *)(mallocedBuf + 32) = 0LL;
*(uint64_t *)(mallocedBuf + 40) = 0LL;
*(uint64_t *)(mallocedBuf + 48) = 0LL;
*(uint64_t *)(mallocedBuf + 56) = 0LL;
AGXResourceTexture->m_mallocedBuf = mallocedBuf;
if ( IOGPUCountedMap<unsigned long long,IOGPUResource *,IOGPUResourceCountedMapBucket,IOGPUIOLibAllocatorPolicy>::init(
mallocedBuf,
(unsigned int)localHashSz,
0x100000u) )
{
LODWORD(AGXResource_obj->resType) |= 0x200u;
...
}
else
{
nomem:
((void (__fastcall *)(AGXResource *))AGXResource_obj->vtab->release_0)(AGXResource_obj);
return 0LL;
}
|
修补后:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
if ( (unsigned int)userHashSize > 0x3F )
{
if ( ((unsigned int)userHashSize ^ ((uint32_t)userHashSize - 1)) <= (int)userHashSize - 1 )
{
_os_log_internal(
&dword_0,
(os_log_t)&_os_log_default,
OS_LOG_TYPE_FAULT,
"%s: newResourceGroup bad initial capacity: %d\n",
"static OSPtr<IOGPUResource> IOGPUResource::newResourceGroup(IOGPU *, IOGPUDevice *, uint32_t)",
userHashSize);
return 0LL;
}
else
{
AGXResourceTexture = IOGPU->createResource(this, a2, 3);
if ( AGXResourceTexture )
{
AGXResourceTexture->lock = IOLockAlloc();
groupMemory = IOGPUGroupMemory::groupMemory(this, userHashSize);
AGXResourceTexture->groupMem = groupMemory;
if ( !groupMemory )
goto nomem;
mallocedBuf = IOMallocTypeImpl();
*(_OWORD *)mallocedBuf = 0u;
*(_OWORD *)(mallocedBuf + 16) = 0u;
*(uint32_t *)(mallocedBuf + 32) = 0;
*(uint64_t *)(mallocedBuf + 40) = 0LL;
*(uint32_t *)(mallocedBuf + 36) = 0;
*(uint64_t *)(mallocedBuf + 48) = 0LL;
*(uint64_t *)(mallocedBuf + 56) = 0LL;
AGXResourceTexture->m_mallocedBuf = mallocedBuf;
if ( IOGPUCountedMap<unsigned long long,IOGPUResource *,IOGPUResourceCountedMapBucket,IOGPUIOLibAllocatorPolicy>::init(
mallocedBuf,
(unsigned int)userHashSize,
0x100000u) )
{
LODWORD(AGXResource_obj->resType) |= 0x200u;
...
}
else
{
nomem:
((void (__fastcall *)(AGXResource *))AGXResource_obj->vtab->release_0)(AGXResource_obj);
return 0LL;
}
|
在函数的开头,您可以看到添加了额外的检查来验证用户提供的userHashSize值。请注意,这是在调用路径中第一次验证此值。
在原始版本中,代码检查该值是否为零,如果为零,则将localHashSz赋值为0x40。如果非零,它将使用用户提供的值。在任何一种情况下,它都会验证该值是否为2的幂(此检查在伪代码中显示为浮点操作)。
在修补版本中,代码仍然检查该值是否为2的幂(x ^ (x - 1) <= x - 1),但确保该值也大于0x40。
我们可以看到错误消息将此值称为"初始容量",这很好地表明这是有问题的代码路径。
虽然这确实是漏洞,但尚不清楚此值如何被代码使用以及可能导致什么问题。让我们首先退一步了解此代码路径在做什么。
注意:您可能会注意到有问题的size也传递给了IOGPUGroupMemory::groupMemory调用。此函数内部创建了一个IOGPUCountedSet对象,由于此有问题的size检查,该对象也有自己的一系列问题。为简洁起见,我们将在本文中重点讨论IOGPUCountedMap对象,而IOGPUCountedSet留给读者作为练习。
IOGPUGroupMemory
像Safari、backboardd或游戏这样的用户进程将创建许多纹理对象作为其GUI渲染过程的一部分。在用户模式下,这由苹果的Metal框架处理,该框架在底层与IOGPUFamily驱动通信以在GPU上渲染这些对象。为了优化此过程,苹果将对象(即纹理)分组在一起。这减少了CPU开销,提高了效率,并允许他们从可用硬件中提取更好的性能。此处的Metal文档更详细地介绍了此过程。虽然此文档已有几年历史并引用了用户模式API Metal,但苹果希望在内核驱动中也应用此优化是有意义的。
以前,用户进程将创建GPU资源(例如,带有IOSurface支持),检索底层共享内存,复制纹理位图,提交缓冲区(即将其连接到GPU页表),并发出绘制命令。
使用IOGPUGroupMemory,进程可以将多个相同类型的对象分组在一起。在驱动中,苹果通过在IOGPUResource对象中创建哈希映射来实现此目的。使用新的newResourceGroup调用,这些对象可以在哈希映射中批量处理。然后,当用户进程想要将支持页面连接到GPU的页表或拆除组时,它可以一次调用完成。
哈希映射
哈希映射是计算机科学中使用的一种数据结构,用于存储键/值对。通过对键应用哈希函数,它允许在映射内以近常数时间插入、查找和删除对象。由于哈希函数可能在唯一键之间产生冲突(例如,键’A’和’B’可能哈希到相同的索引),您需要一种解决这些冲突的方法。为此,每个数组槽(一个"桶")可以保存一个辅助容器,如链表或动态数组,该容器存储所有哈希值映射到相同索引的键/值条目。这允许多个项目在同一索引处共存,同时保持良好的性能。
分桶策略是特定于实现的,但两种常见类型是:
性能和内存效率取决于任务和实现本身,但平均而言,哈希映射提供常数O(1)查找时间;尽管这也取决于冲突次数和表的大小。
封闭寻址(链式)实现可能如下所示(如果我们假设键’A’和’B’在我们的哈希实现中冲突):
1
2
3
4
5
6
7
8
9
10
|
Bucket[1] -> ---------
| key: A |
| val: 1 |
| next ----> ---------
--------- | key: B |
| val: 2 |
| next: null |
---------
Bucket[2] -> null
|
与开放寻址实现相对:
1
2
3
4
5
6
7
8
9
10
11
|
Bucket[1] -> ---------
| key: A |
| val: 1 |
---------
Bucket[2] -> ---------
| key: B |
| val: 2 |
---------
Bucket[3] -> null
|
考虑到这一点,我们现在可以查看IOGPUGroupMemory代码以了解哈希表的实现方式以及苹果使用的寻址方案。
IOGPUCountedMap
花费一些时间研究漏洞的敏锐读者可能会注意到,之前显示的修补检查确保该值不能低于0x40,这表明可能存在某些下溢问题。
确实,查看从IOGPUResource::newGroupMemory调用的IOGPUCountedMap::init,揭示了以下内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
bool __fastcall IOGPUCountedMap<unsigned long long,IOGPUResource *,IOGPUResourceCountedMapBucket,IOGPUIOLibAllocatorPolicy>::init(
IOGPUCountedMap *hopmap,
uint32_t userHashSize,
uint32_t a3)
{
uint64_t *v5; // x0
bool v6; // zf
if ( a3 < 2 * userHashSize )
return 0LL;
hopmap->capacity = userHashSize;
hopmap->mask = userHashSize - 1;
hopmap->group_select_mask = (userHashSize >> 6) - 1; // <-- A
hopmap->log2Sz = __clz(__rbit32(userHashSize));
*(_DWORD *)&hopmap->buf_dummy[4] = a3;
hopmap->hashMap = (uint64_t *)IOMallocZeroData();
hopmap->IOMalloc = (uint64_t *)IOMallocTypeVarImpl(..., 8LL * hopmap->capacity);
hopmap->IOMalloced_via_user_sz = (void *)IOMallocTypeVarImpl(..., 16LL * hopmap->capacity);
v5 = (uint64_t *)IOMallocZeroData();
hopmap->hopMapBits = v5;
if ( !hopmap->hashMap )
return 0LL;
if ( hopmap->IOMalloced_via_user_sz )
v6 = v5 == 0LL;
else
v6 = 1;
return !v6;
}
|
我们带有错误size检查的用户控制值作为userHashSize传递到此函数中。我们可以在[A]处看到,该值右移6(相当于除以0x40),然后减去1。在大多数情况下,此操作是正常的,除非您提供小于0x40的值。例如,0x2右移6是零,减去1导致整数下溢,给我们留下值0xffffffff分配给group_select_mask。
其他字段如log2Sz也变得"意外"。当使用0x40或更大的值时,它至少为6。当使用0x2时,它变为1。
为了理解此漏洞给我们带来了什么,我们可以查看哈希映射的插入和删除代码。
跳房子
如前所述,哈希映射有许多实现,但苹果决定使用跳房子哈希。
跳房子哈希是一种开放寻址类型。跳房子的基本思想是它创建一定数量的桶,如果发生冲突,它将使用相邻的空闲桶在那里存储值。当然,为了使这比线性探测更有效,实现了一种记录保持形式。跳房子通过创建位图来实现此目的,基本上为键将哈希到的相邻桶保持一个"占用"位。这也保证了在需要重新哈希之前,键将距离主桶固定偏移。
在实践中,与桶关联的位图信息跟踪前面的n个位置。例如,假设设置了第3位;这告诉我们有一个最初哈希到此’主’桶的键,但由于它已被占用,它将被放置在三个槽之外,即位置’主 + 3’。这很有效,因为在查找期间,我们可以读取位图并检查它指示的偏移。
为了更好地理解这个概念,下图演示了这一点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
Bucket[0] -> --------- bitinfo: ----------
| key: A | | 00000101 | -> 主桶(Bucket[0])被此键占用
| val: 1 | | | -> 相邻桶(Bucket[2])被占用
| bitmap | ---------> ---------- 位{0,2}被设置,A = 主,B被置换到主+2
---------
Bucket[1] -> --------- bitinfo: ----------
|key:null| | 00000000 | -> 此桶完全为空
| val: 0 | | |
| bitmap | ---------> ----------
---------
Bucket[2] -> ---------
| key: B |
| val: 2 |
| bitmap |
---------
Bucket[3] -> ---------
|key:null|
| val: 0 |
| bitmap |
---------
|
现在,在kext中的实现方式与上图略有不同。例如,他们不是创建’随机’数量的桶,而是创建组,一个组中有64个桶。这意味着总容量(由userHashSize决定)必须是64的倍数,如果输入例如128,那么将创建2组64个桶。
此操作的实际实现相当广泛,此处不需要逐行进行。‘组’状态的高级概述如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
capacity: 128
group[0] --+ group[1] --+
| |
| |
| |
Buckets: | | +-------+
+--------+ | |
| | |
V | V
[0], [1], [2], ..., [63] | [64], [65], ..., [127]
| |
|
|
|
+----> Bucket[0] -> --------- bitinfo: ----------
| key: A | | 00000101 |
| val: 1 | | |
| bitmap | ---------> ----------
---------
|
这种分组很重要,因为关键字段之一是group_select_mask,它来自计算(userHashSize >> 6) - 1。当此计算下溢(即userHashsize小于64)时,它将使整个结构处于不一致状态。
一旦处于不一致状态,我们就有像add_group_resources和add_group_resources_fast这样的函数。这些从用户空间调用,本质上将获取这些AGX对象之一,并将其添加到哈希映射中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
__int64 __fastcall IOGPUResource::add_group_resources_fast(
IOLock **this,
IOGPUResource **malloced_obj, // 存储要添加对象的缓冲区
unsigned int user_input_scalar) // 缓冲区中的对象数
{
...
mallocedObj = malloced_obj;
while ( 2 )
{
AGXResource_fetched = (AGXResource *)mallocedObj[v6];
if ( !AGXResource_fetched || LOBYTE(AGXResource_fetched->resType) == 3 )
IOGPUResource::add_group_resources_fast();
globalObjectIDCounter = AGXResource_fetched->globalObjectIDCounter;
hash_idx = (0x9E3779B97F4A7C15LL * globalObjectIDCounter) >> -LOBYTE(AGXResource_gpuCountedMap->log2Sz); // <-- A
object_entry = *((_DWORD *)AGXResource_gpuCountedMap->hashMap + (unsigned int)hash_idx); // <-- B
do
{
if ( !object_entry ) // 哈希映射条目为空
{
while ( 2 )
{
v33 = 0;
mask = AGXResource_gpuCountedMap->capacity - 1;
group_select_mask = AGXResource_gpuCountedMap->group_select_mask;
indexFor = (0x9E3779B97F4A7C15LL * globalObjectIDCounter) >> -LOBYTE(AGXResource_gpuCountedMap->log2Sz);
v37 = indexFor & 0x3F;
hopMapBits = AGXResource_gpuCountedMap->hopMapBits;
occupancy = hopMapBits[(unsigned int)indexFor >> 6] | ~(-1LL << indexFor); // <-- C
v39 = (unsigned int)indexFor >> 6 << 6;
v40 = ((unsigned int)indexFor >> 6) + 1;
...
v32 = (unsigned int)__clz(__rbit64(~occupancy)) + v39;
...
hopMapBits[group_select_mask & ((unsigned int)v32 >> 6)] |= 1LL << v32; // <-- D
...
AGXResource_gpuCountedMap->hashMap[indexFor] |= 1 << (object_idx - indexFor); // <-- E
AGXResource_gpuCountedMap->hopMapBits[group_select_mask & (object_idx >> 6)] |= 1LL << object_idx; // <--- F
}
...
}
...
}
}
...
}
|
注意:此代码经过大量删减以突出主要问题。因此,并非所有变量定义都存在,逻辑的确切细节被排除。
在[A]处,代码使用我们想要添加的对象的"全局对象ID"作为哈希映射的键。这乘以’黄金哈希’比率,产生64位哈希。为了将此哈希转换为后备数组的索引,代码通过log2Sz的负值右移它。但是,由于我们可以设置容量为1的哈希映射,log2Sz变为零,导致右移操作无效。然后我们在hash_idx中留下一个巨大的值,然后在[B]处用作哈希映射的越界索引。稍后它再次计算(作为indexFor),并可能导致[C]处的越界读取,以及[D] - [F]处的三个写入。
代码中还有其他进一步的越界问题,例如当用户客户端关闭时,需要清理对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
void __fastcall IOGPUGroupMemory::removeMemoryFromResourceMap(
IOLock **this,
IOGPUCountedMap *gpu_countedMap,
int const_zero)
{
uint32_t idx; // w22
unsigned __int64 bits_set; // x24
unsigned __int64 bucket_idx; // x8
__int64 vars8; // [xsp+48h] [xbp+8h]
IOGPUMemory::lock(this);
group_idx = 0;
do
{
bits_set = gpu_countedMap->hopMapBits[group_idx]; // <-- A
while ( bits_set )
{
bucket_idx = __clz(__rbit64(bits_set));
bits_set &= ~(1LL << bucket_idx);
IOGPUGroupMemory::remove_memory_object(
(IORegistryEntry **)this,
*(IOGPUMemory **)(*((_QWORD *)gpu_countedMap->IOMalloced_via_user_sz + 2 * (bucket_idx | (group_idx << 6))) + 0x28LL), // <-- B
const_zero);
}
++group_idx;
}
while ( group_idx <= gpu_countedMap->group_select_mask );
if ( ((vars8 ^ (2 * vars8)) & 0x4000000000000000LL) != 0 )
__break(0xC471u);
IOGPUMemory::unlock(this);
}
|
这里,代码首先循环遍历从0到group_select_mask的所有组,索引hopMapBits以确定该组中设置的对象的索引。最终group_idx将递增超过hopMapBits的大小,导致[A]处的越界读取。
同样,由于越界的group_idx,当函数从IOMalloced_via_user_sz获取对象指针时,将在[B]处发生另一个越界读取。
不幸的是,在有问题的场景中,group_select_mask将始终为0xffffffff。即使您可以从remove_memory_object调用派生有用的行为,循环最终将执行高达约32GB的越界访问(至少对于读取[A]),这将始终不可避免地导致崩溃。由于无法提前跳出此循环,这是不可避免的,因此不是考虑利用的可行路径。
利用
近年来,虽然iOS/macOS内核中的漏洞仍然存在,但漏洞利用的数量已急剧下降。这是由于苹果不断推动在内核中实施更强有力的缓解措施。虽然有很多,但这里适用的主要措施是苹果在内核中使用"仅数据"堆。
在启动时,内核的虚拟内存子系统分配一个区域映射。从此,每当核心或kext想要分配一些内存时,它可以通过kalloc_* API进行。
在iOS 14中,苹果引入了区域隔离,允许他们将区域划分为多个子区域。此外,在iOS 15中,他们引入了kalloc_type。这允许他们不仅按类型分组特定区域,还分组对象。以前,对象仅基于其大小分组,例如:
1
2
3
4
|
kalloc.16 16
kalloc.32 32
kalloc.48 48
kalloc.64 64
|
在此设置中,任何两个相同大小的对象将在同一区域中分配,并可能在内存中相邻。
这使得UaF漏洞的利用变得简单,因为攻击者可以喷洒任何相同大小的对象来替换悬空对象。同样,对于越界访问,攻击者可以喷洒相同大小的对象,使得"攻击者"和"目标"对象在堆上相邻。
在kalloc type下,kalloc大小桶进一步划分为类型范围:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
kalloc.type0.16 16
kalloc.type1.16 16
kalloc.type2.16 16
kalloc.type3.16 16
kalloc.type4.16 16
kalloc.type5.16 16
kalloc.type6.16 16
kalloc.type.var1.16 16
kalloc.type.var2.16 16
kalloc.type.var3.16 16
kalloc.type.var4.16 16
kalloc.type.var5.16 16
kalloc.type.var6.16 16
data.kalloc.16 16
|
使用kalloc_type,在内核中编译时创建一个结构,该结构保存对象的"签名"以及其他字段。签名基于对象内的字段类型,基于8字节粒度。
例如,具有指针和数据成员的结构将获得以下签名:
1
2
3
4
5
6
|
struct some_struct {
void *ptr; // 指针类型 - 1
uint64_t size; // 数据类型 - 2
};
// 结果签名:"12"
|
此外,静态和可变大小的类型进一步拆分为kalloc.typeN和kalloc.type.varN区域。
任何包含纯数据(即没有指针)的类型都在data.kalloc区域中分配,这些区域再次与kalloc.type{.var}分配分开。因此,即使数据区域中存在内存损坏,您也无法获得任何内核控制,因为根本没有指针可以攻击,例如构建任意读取或写入原语。
如上所述,有2个堆分配我们可能越界访问:hashMap和hopMapBits。
如果我们参考初始化代码,我们可以看到两个对象都在数据堆上分配(IOMallocZeroData专门请求在data.kalloc堆中分配,由分配器清零):
1
2
|
hopmap->hashMap = IOMallocZeroData(...);
hopmap->hopMapBits = IOMallocZeroData(...);
|
因此,即使您能够强制代码写入受控值(由于哈希映射逻辑的复杂性,这本身是一项艰巨的任务),类型分离意味着您无法损坏内核内存中的任何有用对象。
结论
在这篇博客文章中,我们讨论了使用差异比较来发现XNU内核(或更具体地说,其驱动)中修补的漏洞,并演示了单个缺失检查如何在复杂对象内导致多种状态问题。我们还讨论了由于过去几年添加的堆缓解措施,此漏洞在现代苹果内核上不可利用的原因。
致谢
我们要感谢Dataflow Forensics的Tomi Tokics(@tomitokics)撰写此博客文章,以及Dataflow Security的Ben Sparkes(@iBSparkes)的协助。