iOS内核漏洞深度解析:IOGPUFamily越界写入漏洞分析

本文详细分析了iOS 18.4中修复的IOGPUFamily内核驱动漏洞CVE-2025-24257,通过代码对比技术揭示了一个因输入验证不足导致的越界写入漏洞,并深入探讨了漏洞的发现过程、技术原理及在现代iOS系统中的不可利用性。

描绘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_resourcesadd_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.typeNkalloc.type.varN区域。

任何包含纯数据(即没有指针)的类型都在data.kalloc区域中分配,这些区域再次与kalloc.type{.var}分配分开。因此,即使数据区域中存在内存损坏,您也无法获得任何内核控制,因为根本没有指针可以攻击,例如构建任意读取或写入原语。

如上所述,有2个堆分配我们可能越界访问:hashMaphopMapBits

如果我们参考初始化代码,我们可以看到两个对象都在数据堆上分配(IOMallocZeroData专门请求在data.kalloc堆中分配,由分配器清零):

1
2
hopmap->hashMap = IOMallocZeroData(...);
hopmap->hopMapBits = IOMallocZeroData(...);

因此,即使您能够强制代码写入受控值(由于哈希映射逻辑的复杂性,这本身是一项艰巨的任务),类型分离意味着您无法损坏内核内存中的任何有用对象。

结论

在这篇博客文章中,我们讨论了使用差异比较来发现XNU内核(或更具体地说,其驱动)中修补的漏洞,并演示了单个缺失检查如何在复杂对象内导致多种状态问题。我们还讨论了由于过去几年添加的堆缓解措施,此漏洞在现代苹果内核上不可利用的原因。

致谢

我们要感谢Dataflow Forensics的Tomi Tokics(@tomitokics)撰写此博客文章,以及Dataflow Security的Ben Sparkes(@iBSparkes)的协助。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计