单漏洞攻破Pixel 6 Pro:Mali GPU内核漏洞分析与利用

本文深入分析了CVE-2023-48409漏洞在Pixel 6 Pro设备上的利用过程,通过物理内存喷射和页表操纵技术实现权限提升,展示了单一漏洞的完整攻击链。

Solo: A Pixel 6 Pro Story (When one bug is all you need)

目录

  • 根本原因分析
    • CVE-2023-48409
    • 什么是CONFIG_HARDENED_USERCOPY?
    • 缺失的部分:CVE-2023-26083
  • 单漏洞提权
    • 物理内存喷射
    • 页表喷射
    • 从物理到虚拟:转换原语
  • Android的独特挑战
    • 绕过Android应用沙箱
    • 禁用SELinux强制
  • 意外发现
    • 理解时间线流泄漏
    • 命令流前端
    • 黄金对象:kbase_context
    • 清理:使漏洞利用实用化
  • 结论
  • 参考文献

根本原因分析

CVE-2023-48409

这里我们将首先深入分析另一个漏洞:CVE-2023-48409,这似乎更容易被利用。该CVE包含在2023年12月的Pixel安全公告中;通过参考内部Bug ID,我们可以找到设备的精确补丁,确认该漏洞在2023年12月SPL及之后版本中已修复。因此,我们将设备版本回滚到较早的补丁,在我们的案例中是UP1A.231005.007。

从描述中:

在gpu_pixel_handle_buffer_liveness_update_ioctl函数中(位于private/google-modules/gpu/mali_kbase/mali_kbase_core_linux.c),由于整数溢出可能导致越界写入。这可能导致本地权限提升,无需额外执行权限。利用无需用户交互。

该漏洞由整数溢出引起,当计算在内核对象分配大小时(用于GPU驱动中的活跃度更新操作):

 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
int gpu_pixel_handle_buffer_liveness_update_ioctl(struct kbase_context* kctx,
                                                  struct kbase_ioctl_buffer_liveness_update* update)
{
    int err = 0;
    struct gpu_slc_liveness_update_info info;
    u64* buff;

    u64 const buffer_info_size = sizeof(u64) * update->buffer_count;
    u64 const live_ranges_size =
        sizeof(struct kbase_pixel_gpu_slc_liveness_mark) * update->live_ranges_count;
    if (!buffer_info_size || !live_ranges_size)
        goto done;

    if (!update->live_ranges_address || !update->buffer_va_address || !update->buffer_sizes_address)
        goto done;
    buff = kmalloc(buffer_info_size * 2 + live_ranges_size, GFP_KERNEL);

    info = (struct gpu_slc_liveness_update_info){
        .buffer_va = buff,
        .buffer_sizes = buff + update->buffer_count,
        .live_ranges = (struct kbase_pixel_gpu_slc_liveness_mark*)(buff + update->buffer_count * 2),
        .live_ranges_count = update->live_ranges_count,
    };
    err =
        copy_from_user(info.live_ranges, u64_to_user_ptr(update->live_ranges_address), live_ranges_size);
    if (err) {
        dev_err(kctx->kbdev->dev, "pixel: failed to copy live ranges");
        err = -EFAULT;
        goto done;
    }
    
...

done:
    kfree(buff);
    return err;
}

具体来说,我们能够指定两个u64值:buffer_count和live_ranges_count,使得驱动程序通过kmalloc(GFP_KERNEL)分配一个对象,该对象应容纳3个连续缓冲区,所有这些缓冲区都直接用户可控:

  • buffer_sizes,大小为sizeof(u64) * buffer_count
  • buffer_va,大小为sizeof(u64) * buffer_count
  • live_ranges,大小为sizeof(struct kbase_pixel_gpu_slc_liveness_mark) * update->live_ranges_count(结构体为4字节)

关注live_ranges缓冲区:

  • 分配对象大小:8buffer_count2 + 4*live_ranges_count
  • 从分配对象的偏移:+ 8buffer_count2

如果值被精心构造,使得8buffer_count2 + 4live_ranges_count = (1«64) + <object_size>,我们的live_ranges缓冲区将位于分配对象之前4live_ranges_count - <object_size>字节处。幸运的是,由于live_ranges首先被写入,我们可以在写入过程中导致无效内存访问,从而中止整个操作,而不会写入其他两个缓冲区(它们具有非常明显的无效边界)。因此,我们可以有效地将该漏洞视为一个相当通用的原语:任意大小的kmalloc(GFP_KERNEL)配对从分配对象开始的任意长度(0x10字节对齐)缓冲区下溢。

虽然这非常强大,似乎有很多方法可以从此分支出去,但该漏洞有两个主要限制:

  1. 分配的对象在下溢后立即被释放。理论上,如果我们只需要下溢一次,这不应该是一个问题,如果需要,我们也可以尝试找到另一个对象来暂时“持有”该位置。
  2. 写入使用copy_from_user,并且下溢的设置方式实际上保证会溢出受害者对象(进入我们实际分配的对象)。在启用CONFIG_HARDENED_USERCOPY的内核中,这意味着我们直接被禁止破坏SLUB分配器中的任何对象。

但首先,作为入门:

什么是CONFIG_HARDENED_USERCOPY?

CONFIG_HARDENED_USERCOPY,在此引入,是一个内核配置,首次出现在Linux v4.8中。从Kconfig本身,我们有:

此选项在将内存复制到/从内核(通过copy_to_user()和copy_from_user()函数)时检查明显错误的内存区域,通过拒绝大于指定堆对象、跨越多个单独分配页面、不在进程堆栈上或是内核文本一部分的内存范围。这消除了整个类别的堆溢出漏洞和类似的内核内存暴露。

本质上,在copy_*_user期间,内核调用check_object_size,如果目标指针落在某些突出显示区域内,则执行特定边界检查,并在违反边界时中止(BUG())。特别是,如果指针在SLUB对象内,它确保至少读/写区域必须完全落在该特定对象的可用区域内(基于缓存的object_size),使大多数对象溢出攻击无效。自v4.16以来,白名单区域进一步限制为仅对象的usercopy区域(基于缓存的useroffset和usersize),尽管对于通用缓存来说,它等同于整个对象区域。

为了理解影响规模,我们必须首先理解大多数越界攻击,至少在该配置引入时,专注于覆盖或泄漏放置在易受攻击对象旁边的单独受害者对象中的关键字段。这种情况下最常用的“技术”是slab分配的对象,特别是通用对象,由于它们的使用灵活性以满足此类表面常见漏洞的需求。

放在上下文中,没有CONFIG_HARDENED_USERCOPY,我们讨论的漏洞可能非常通用,因为它有潜力由于其灵活的大小覆盖几乎任何我们想要的对象。例如,我们可以通过利用anon_vma_name对象轻松将我们的盲写转换为泄漏:

  1. 喷射anon_vma_name对象,理想情况下填充整个slab以提高可靠性。
  2. 释放其中一个以创建空洞,并在其位置分配易受攻击的对象,覆盖前面的anon_vma_name对象,使其char缓冲区扩展到后面的anon_vma_name对象。
  3. 如果我们事先标记了对象,我们现在已经找到了易受攻击对象前面和后面的anon_vma_name对象的id,有效克服CONFIG_SLAB_FREELIST_RANDOM:前面的对象将有其名称扩展,而后面的对象的id将出现在扩展名称中。
  4. 释放后面的anon_vma_name对象,并在其位置分配任何合适的对象(匹配大小和类型),我们想要泄漏其第一个字段。

实际上,由于CONFIG_HARDENED_USERCOPY默认启用,尝试执行上述步骤2会立即使我们的内核崩溃。此功能完全扼杀了许多越界漏洞,同时严重限制了其余漏洞的潜在选项。但在我们的案例中,我们仍然有办法。

缺失的部分:CVE-2023-26083

使用严重削弱写入漏洞,最佳选择是首先生成一个我们可以随后操作的地址泄漏。在原始漏洞利用中,这是通过第二个漏洞CVE-2023-26083实现的。

简要来说,驱动程序中的一个流功能(tlstream)自由暴露给用户空间进程,包括无特权进程,可能包含某些对象的内核地址作为纯字节。特别是,漏洞利用识别了kbase_kcpu_command_queue,它可以:

  • 自由喷射;
  • 允许定向kfree;并且
  • readily将其地址泄漏到流中。

从原始文章中,冒犯函数如下:

 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
void __kbase_tlstream_tl_kbase_kcpuqueue_enqueue_fence_wait(
    struct kbase_tlstream *stream,
    const void *kcpu_queue,
    const void *fence
)
{
    const u32 msg_id = KBASE_TL_KBASE_KCPUQUEUE_ENQUEUE_FENCE_WAIT;
    const size_t msg_size = sizeof(msg_id) + sizeof(u64)
        + sizeof(kcpu_queue)
        + sizeof(fence)
        ;
    char *buffer;
    unsigned long acq_flags;
    size_t pos = 0;

    buffer = kbase_tlstream_msgbuf_acquire(stream, msg_size, &acq_flags);

    pos = kbasep_serialize_bytes(buffer, pos, &msg_id, sizeof(msg_id));
    pos = kbasep_serialize_timestamp(buffer, pos);
    pos = kbasep_serialize_bytes(buffer,
        pos, &kcpu_queue, sizeof(kcpu_queue));
    pos = kbasep_serialize_bytes(buffer,
        pos, &fence, sizeof(fence));

    kbase_tlstream_msgbuf_release(stream, acq_flags);
}

注意kbasep_serialize_bytes(在[1]处)只不过是一个memcpy到获取的tlstream缓冲区:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
static inline size_t kbasep_serialize_bytes(
        char       *buffer,
        size_t     pos,
        const void *bytes,
        size_t     len)
{
    KBASE_DEBUG_ASSERT(buffer);
    KBASE_DEBUG_ASSERT(bytes);

    memcpy(&buffer[pos], bytes, len);

    return pos + len;
}

由于内核地址的引用被用作memcpy的源,地址本身作为字节写入缓冲区,有效地呈现为自由泄漏。

现在,如开头所述,这对我们的设备不直接工作。尝试从原始漏洞利用访问tlstream给我们抛出一个-EINVAL,而查看源代码让我意识到围绕kbase_kcpu_command_queue的代码甚至没有编译(剧透!)到我们版本的驱动程序中。因此,我们将暂时搁置这个漏洞(暂时)。

单漏洞提权

回到我们的下溢漏洞:起点是,我们必须直接针对页面分配器,而不是slab分配器。虽然大多数内核对象通过slab分配器分配,但通用缓存仅覆盖到一定大小——在大多数情况下最多kmalloc-8k,对应于order-1块分配。因此,对kmalloc(sz, GFP_KERNEL)或类似的调用,其中sz > 0x2000,导致内核将分配委托给页面分配器,剥离CONFIG_HARDENED_USERCOPY的保护,但至少分配一个order-2块(0x4000字节)。

然后,我们可以直接为我们的易受攻击对象分配一个order-2块。与slab分配器不同,后者有另一层保护即CONFIG_FREELIST_RANDOM(尽管仍然相对容易绕过),页面分配器直接与底层物理内存工作,我们可以更有效地修饰分配器以使其产生两个连续地址。(不幸的是,这不是100%保证,所以有可能其中一个页面分配给其他东西,可能触发CONFIG_HARDENED_USERCOPY并使系统崩溃,带来小麻烦。)

这也是原始漏洞利用所使用的,它分配了一个pipe_buffer数组对象,总大小大于slab分配阈值,并劫持其页面指针以获取对单个任意地址的无限读/写(理论上可以重复多次,但那样可靠性较低)。当然,这不幸需要事先泄漏,之前通过第二个漏洞获得,我们在我们的场景中无法访问。类似的漏洞利用如Page UAF也不会工作,因为那需要修改页面指针的第一个字节,这对于下溢是不可能的。

我们首先需要实现的是将这个受限下溢转换为某种形式的地址泄漏。虽然肯定可以搜索内核源代码以寻找合适的泄漏地址的技术(kcalloc似乎是一个很好的起点),但实际上有一个更直接的替代方案:为什么我们不猜测地址呢?

物理内存喷射

ret2dir是一种滥用physmap的流行技术,简要来说,它是物理内存到内核虚拟地址空间的直接1:1映射,其中任何物理地址是某个虚拟地址的直接线性转换(偏移是该区域的起始地址)。

这里,我们更关心“喷射”部分:我们知道物理内存是有限的,并且页面分配器在它将分配的内存区域中相对可预测。我们或许可以用一些可喷射的受害者对象耗尽页面分配器,以最大化对象在我们选择地址(我们的“有根据的猜测”)分配的机会,如原始论文中概述。

然而,我们必须自己找出一个理想的地址。为此,我们可以黑客一个虚拟驱动程序强制内核分配尽可能多的页面,并找出分配位置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void *spray_head = NULL; // 链接列表用于退出时批量释放
static long dummy_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
    struct dummy_ioctl_struct data = { .addr = 0 };
    switch (cmd) {
    case DUMMY_IOCTL_SPRAY_ONE:
        void *tmp = (void *)__get_free_pages(GFP_KERNEL, 0);
        if (tmp) {
            *(void **)tmp = spray_head;
            spray_head = tmp;
            data.addr = tmp;
        }
        if (copy_to_user((void __user *)arg, &data, sizeof(data))) {
            return -EFAULT;
        }
        break;
    default:
        return -EINVAL;
    }
    return 0;
}

例如,这里是多次运行中1,500,000个单页分配的图表:

[图表描述:显示分配地址的分布]

应该注意的是,与我們喷射的地址范围相比,设备重启的起始地址的熵是微不足道的,因此也不是问题:

[图表描述:显示重启间地址的一致性]

通过选择一个在运行中一致出现的地址,我们有效地模拟了地址泄漏,例如,我们可以随后覆盖pipe_buffer页面指针指向该地址以对该页面进行读/写。

现在是我们喷射对象的选择。不幸的是,由于系统对用户可以为管道分配的最大页面数的限制:/proc/sys/fs/pipe-user-pages-soft,在我们的案例中设置为16384(0x4000),即0xa0000字节的pipe_buffers,我们不能直接喷射pipe_buffer以获得完全控制。

页表喷射

在这一点上,我的导师向我介绍了一个有用的技术:Dirty Pagetable by @ptrYudai。本质上,这种技术依赖于页表本身(帮助将虚拟地址映射到物理地址的结构的最后一级)由页面分配器作为普通order-0块分配的事实,并且通过写入原语,我们可以修改其条目之一,使mmap-ed的用户态虚拟页面反映几乎任何任意物理页面。

这与pipe_buffer有类似效果,但它直接关注物理地址。然而,一个关键区别是它更可喷射:虽然仍然存在限制(vm.max_map_count = 65530),但这是每个进程的限制,因此我们可以轻松分叉一堆进程以喷射更多页表。我们还可以通过在每个页表中仅分配1页来优化每个进程中喷射的页表对象数量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/*
 * (in PGD)   (in PUD)   (in PMD)   (in PTE)      (in page)
 *   PUD#       PMD#       PTE#      PAGE#         OFFSET
 * 4444 4333 3333 3322 2222 2221 1111 1111 0000 0000 0000
 * 0000 0000 0000 0100 0000 0000 0000 0000 0000 0000 0000 base addr
 * 0000 0000 0000 0100 0000 1001 1111 1111 0000 0000 0000 sample spray (idx 4)
 */

#define BASE_ADDR (1UL<<(12+9+9))
#define PAGE_ADDR(x) ((BASE_ADDR)+((uint64_t)(x)<<(12+9)) | 511UL<<12)

static inline int mmap_page(uint64_t page_idx) {
    if (mmap((void *)PAGE_ADDR(page_idx), 0x1000,
        PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED,
        -1, 0) == (void *)-1) return -1;
    return 0;
}

这种方法有一个注意事项:为了实际分配页表对象,我们必须通过访问/写入其页面导致故障,在此过程中分配实际的mmap页面(当然也是order-0)。这个mmap页面本身不希望地与页表对象竞争在我们目标地址分配。然而,一线希望是它们在页面分配器中具有不同的迁移类型:

1
2
3
4
5
6
7
8
9
static inline struct page *
alloc_zeroed_user_highpage_movable(struct vm_area_struct *vma,
                   unsigned long vaddr)
{
    struct page *page = alloc_page_vma(GFP_HIGHUSER_MOVABLE | __GFP_CMA, vma, vaddr);
    if (page)
        clear_user_highpage(page, vaddr);
    return page;
}

每种迁移类型(在这种情况下为movable与unmovable)有它们相应的pageblocks,顺序为pageblock_order(根据/proc/pagetypeinfo,在我们的设备上值为10),页面将从这些pageblocks分配。由于两种类型的页面(mmap页面和页表页面)线性分配在一起,我们的页表将最终位于包含我们选择地址的pageblock或直接在其后的pageblock(在我们的案例中相隔2**10 = 0x400000字节)中。也就是说,为了可靠地找到页表,我们将不得不检查两个地址。

除此之外,通过喷射页表,我们也或多或少耗尽了order-2空闲列表并修饰页面分配器以相当可靠地随后提供连续的order-2块,使我们能够轻松触发漏洞。我们也可以选择覆盖一些页表对象本身,而不是破坏pipe_buffer,因为这使我们能够对整个页面有更大的控制。但由于它们是不同的order(order-0),需要更多设置:

  1. 分配2个order-2的pipe_buffer数组,它们应该在内存中连续。
  2. 释放第一个pipe_buffer数组(通过close),并
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计