缓存时间:
2026/06/29 20:31
# 通过 DRM GEM change_handle 中的释放后使用实现无特权提权至 root (CVE-2026-46215) – cyberstan
来源:https://cyberstan.co.uk/drm-lpe-linux/
于 2026 年 4 月 12 日报告至 security@kernel\.org · 主线已修复 \(5e28b7b (https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=5e28b7b94408897e41c63477aabc9e1db439bc8c)\),2026 年 5 月,抄送: stable · 修复: 53096728 · 受影响版本: v6\.18\-rc1 至修复版本 · CVE\-2026\-46215 \(独立报告;归功于另一位研究人员,详见披露章节\)
## 摘要
DRM GEM 核心 ioctl `DRM\_IOCTL\_GEM\_CHANGE\_HANDLE` 中存在一个释放后使用漏洞,允许任何能够访问渲染节点的本地用户提升至 root 权限。`drm\_gem\_change\_handle\_ioctl()` 将一个 GEM 对象从一个句柄移动到另一个句柄,但从未调整对象的 `handle\_count`。在一个短暂的时间窗口内,该对象拥有两个 IDR 条目,而其 `handle\_count` 仍然为 1。此时,对旧句柄并发执行 `DRM\_IOCTL\_GEM\_CLOSE` 会将该计数减至 0,并释放该对象,而新句柄仍然指向它。这个悬垂句柄就是释放后使用的根源。
两个 ioctl 都标记有 `DRM\_RENDER\_ALLOW`,因此任何能够打开 `/dev/dri/renderD\*` 的人都可以触发该漏洞。在每个主流桌面发行版上,systemd\-logind 默认授予活动会话对该节点的读/写权限,因此普通登录用户无需特殊权限即可利用。我编写的利用链会:获取被释放的对象,用喷洒的 `pipe\_buffer` 数组重新占据其 slab 槽位,泄露一个内核指针以绕过 KASLR,设置 `PIPE\_BUF\_FLAG\_CAN\_MERGE` 以绕过 DirtyPipe 修复,并通过页面缓存覆盖只读的 `/etc/passwd`。结果是从无特权进程实现无密码 root,成功率约 99%。
Puttimet Thammasaeng 报告了相同的漏洞并且抢先了一步,因此上游的 `Reported\-by` 署名和 CVE\-2026\-46215 归其所有。我是独立发现并报告的,以下是我自己的分析和利用。更多细节见披露章节。
## 无特权本地攻击面
大多数有趣的内核漏洞需要一些权限才能触及。但这个不需要。DRM 渲染节点的存在正是为了让无特权客户端(如你的合成器、浏览器的 GPU 进程、任何执行 GPU 工作的程序)能够在不经过特权主节点的情况下提交命令。systemd\-logind 在登录时会为活跃的控制台用户授予 `/dev/dri/renderD128` 的 ACL,而此竞态条件中涉及的两个 ioctl 都带有 `DRM\_RENDER\_ALLOW` 标志,意味着它们在渲染节点上明确被允许。
因此,威胁模型是本地漏洞中最强的一种:标准桌面上的正常用户会话,无能力集,无 setuid 辅助程序,无需容器逃逸。只要你已登录,就可以打开该节点;只要你能打开节点,就可以运行此竞态条件。
## change\_handle ioctl
GEM(图形执行管理器)对象是 DRM 子系统分发给用户空间的缓冲对象。进程通过存储在每个文件 IDR(`file\_priv\-\>object\_idr`)中的整型句柄来引用它们。每个句柄持有对对象的一个引用,通过一个名为 `handle\_count` 的字段与对象的内核引用计数分开跟踪。当最后一个句柄消失时,`handle\_count` 降至 0,释放句柄计数对对象的引用,最终导致对象被释放。
句柄的生命周期通常由一组小巧的助手函数驱动,这些函数使 `handle\_count` 和 IDR 保持同步:`drm\_gem\_handle\_create\_tail()` 用于发布一个句柄(它调用 `drm\_gem\_object\_handle\_get()` 来增加计数),以及 `drm\_gem\_handle\_delete()` 用于移除一个句柄(它调用 `drm\_gem\_object\_release\_handle()` 和 `drm\_gem\_object\_handle\_put\_unlocked()` 来减少计数)。这些助手函数的存在是因为顺序错误很容易发生,其后果正是你所期望的那种引用计数漏洞。
`DRM\_IOCTL\_GEM\_CHANGE\_HANDLE`(ioctl 编号 0xD2)比较新。它由提交 `53096728b891` 在 v6\.18\-rc1 中为 AMD 的 CRIU 工作添加,以便检查点/恢复能够将 GEM 对象重新分配到一个特定的句柄编号。它做的事情是现有任何助手函数都没有做的:它就地移动一个对象从一个句柄到另一个句柄。在此过程中,它手动操作句柄,而不是使用生命周期助手函数。
## 漏洞:永不移动的引用计数
简化后的 ioctl 如下:查找对象,插入一个新的 IDR 条目,移除旧条目,并释放其查找引用:
``
/* drm_gem_change_handle_ioctl(), 简化版本, 修复前 */
obj = drm_gem_object_lookup(file_priv, args->handle); /* +1 查找引用 */
spin_lock(&file_priv->table_lock);
idr_alloc(&file_priv->object_idr, obj, new_handle, ...); /* 新条目 */
spin_unlock(&file_priv->table_lock);
/* ... prime 簿记工作在 prime.lock 下 ... */
spin_lock(&file_priv->table_lock);
idr_remove(&file_priv->object_idr, args->handle); /* 旧条目 */
spin_unlock(&file_priv->table_lock);
drm_gem_object_put(obj); /* -1 查找引用 */
``
它从未为新的句柄调用 `drm\_gem\_object\_handle\_get()`,也从未为旧的句柄调用 `drm\_gem\_object\_handle\_put\_unlocked()`。在整个操作过程中,`handle\_count` 保持为 1。单独来看这没问题,因为一个句柄消失的同时另一个句柄出现,所以最终计数仍然是 1。问题出在中间。
在 `idr\_alloc` 和 `idr\_remove` 之间,该对象有两个活跃的 IDR 条目(旧句柄和新句柄),而 `handle\_count` 读数为 1。`table\_lock` 自旋锁在单个 IDR 操作之间被释放,并且没有任何机制将这个复合序列与 `drm\_gem\_handle\_delete()` 中的复合序列进行序列化。因此,第二个线程可以在该窗口内对旧句柄调用 `GEM\_CLOSE`。关闭路径移除其 IDR 条目并运行 `drm\_gem\_object\_release\_handle()`,这将 `handle\_count` 从 1 减至 0,释放句柄计数引用,并释放对象。新的 IDR 条目现在指向已释放的内存。
值得具体说明锁的情况,因为锁之间的间隙正是整个漏洞所在。`change\_handle` 在操作期间持有 `prime.lock`,但仅在对每个 IDR 调用时持有 `table\_lock`,并在两次调用之间释放它。`drm\_gem\_handle\_delete()` 为了其 `idr\_replace` 而持有 `table\_lock`,然后为了 `handle\_count` 递减而持有 `object\_name\_lock`。没有一个锁在整个复合序列期间被持有,因此它们可以自由交错,并且 `close` 可以在 `change\_handle` 的两个 IDR 操作之间插入其释放。
一个对象,handle\_count = 1,两个 IDR 条目的复合移动没有针对复合关闭进行序列化,并且在步骤之间释放了锁。1. 在 change\_handle 窗口内 2. 一个竞争的 GEM\_CLOSE 在旧句柄上
旧句柄 -> obj
新句柄 -> obj
drm\_gem\_object
handle\_count = 1
旧句柄已移除
新句柄 -> 已释放
已释放对象
handle\_count = 0
change\_handle 执行 idr\_alloc(新),然后 idr\_remove(旧),但从未触碰 handle\_count。
在间隙中,针对旧句柄的 GEM\_CLOSE 运行 release\_handle,将 handle\_count 从 1 减至 0
并释放对象。新的 IDR 条目现在引用已释放的内存:一个悬垂句柄,稍后
在 drm\_gem\_object\_release\_handle() 中被解引用。这就是释放后使用。
活跃 / 即将被关闭 / 已释放 / 悬垂
移动操作留下两个 IDR 条目,而 handle\_count 为 1;一个竞争的关闭操作从新句柄下释放了对象。
被释放的对象是一个 `kmalloc\-512` 分配(支持 virtio\-gpu 和 nouveau 缓冲区的 GEM 对象落在这个缓存中)。在 2 核虚拟机上,竞态条件在大约 100 次迭代内一致成功,PoC 中的校准逻辑测量两个 ioctl 的单独延迟并交错线程,使它们碰撞在 `table\_lock` 上,这进一步提高了成功率。
## 重新占据对象
一旦对象被释放但通过悬垂句柄仍然可达,下一步就是将一些有用的东西放入其位置。目标是 `struct pipe\_buffer`,它也在管道增长时落入 `kmalloc\-512`:一个具有八个槽位的管道持有一个八元素 `pipe\_buffer` 数组,每个元素 40 字节,该数组位于 512 字节缓存中。
为了使重新占据可靠,我首先用 `msg\_msg` 喷洒来调节缓存(分配几百个 512 字节的 System V 消息,然后释放几个,LIFO,使被释放对象的槽位接近空闲列表顶部),然后喷洒管道,其缓冲数组落入这个空洞。之后,悬垂的 GEM 句柄和一个 `pipe\_buffer` 数组别名同一 512 字节。
## 泄露指针:结构体重叠
当两个结构体彼此重叠时,字段对齐的方式使得利用的两个半部分都唾手可得。数字来自 `pahole`,并且在 6\.18 到 7\.0 版本中稳定:
``
#define GEM_SIZE_OFF 216 /* drm_gem_object.size */
#define GEM_NAME_OFF 224 /* drm_gem_object.name */
#define PIPEBUF_SIZE_ACTUAL 40 /* sizeof(struct pipe_buffer) */
#define OVERLAP_IDX 5
#define PIPEBUF_OPS_OFF 16 /* pipe_buffer.ops -> 5*40+16 = 216 */
#define PIPEBUF_FLAGS_OFF 24 /* pipe_buffer.flags -> 5*40+24 = 224 */
``
对象在偏移 216 处的 `size` 字段正好位于 `pipe\_buf[5].ops` 之上,而对象在偏移 224 处的 `name` 字段正好位于 `pipe\_buf[5].flags` 之上。`ops` 字段是一个指向内核 `.text` 中 `anon\_pipe\_buf\_ops` 的指针,因此读取它回来就得到了一个已知的内核符号,从而得到 KASLR 基址。而且驱动程序免费地将其提供给我:virtio\-gpu 的 `RESOURCE\_INFO` ioctl(以及 nouveau 的 `GEM\_INFO`)返回 GEM 对象的 `size` 字段,现在它是 `pipe\_buf[5].ops`。
同一 512 字节,两种视角
悬垂的 GEM 句柄和一个喷洒的 pipe\_buffer 数组别名已释放的槽位。字段对齐。
视为 drm\_gem\_object
size@216
name@224
视为 pipe\_buffer[8] (每个 40 字节)
buf[0] buf[1] buf[2] buf[3] buf[4] buf[5].ops@216 .flags@224 buf[6] buf[7]
size (216) = buf[5].ops: 一个内核 .text 指针。通过 RESOURCE_INFO 读回 -> KASLR 基址。
name (224) = buf[5].flags: FLINK 分配 name 16 = 0x10 = PIPE_BUF_FLAG_CAN_MERGE。
一个泄露原语和一个写入原语,都来自相同的字段重叠。
size@216 落在 pipe_buf[5].ops 上(泄露);name@224 落在 pipe_buf[5].flags 上(CAN_MERGE 写入)。
## 绕过 DirtyPipe 修复
DirtyPipe (CVE-2022-0847) 滥用了陈旧的 `PIPE\_BUF\_FLAG\_CAN\_MERGE` 标志来写入只读文件的页面缓存。修复确保了该标志始终被初始化,因此你不能再偶然发现它被设置。但在这里,我不是依赖一个陈旧的标志,而是通过重叠故意设置它。
`PIPE\_BUF\_FLAG\_CAN\_MERGE` 是 `0x10`,即 16。GEM 名称是作为来自 IDR 的小顺序整数分配的。因此,如果我预先用一次性 FLINK 分配名称 1 到 15,然后对悬垂句柄执行 FLINK,它会得到名称 16。该值被写入对象的 `name` 字段,即 `pipe\_buf[5].flags`,因此 `flags` 变为 `0x10`,并且 `CAN\_MERGE` 被设置在一个活跃的管道缓冲区上:
``
/* 预置名称 1..15,使得悬垂句柄的 FLINK 获得名称 16 */
for (i = 0; i < 15; i++) {
h = create_gem_bo(fd);
ioctl(fd, DRM_IOCTL_GEM_FLINK, &(struct drm_gem_flink){ .handle = h });
}
struct drm_gem_flink fl = { .handle = dangling };
ioctl(fd, DRM_IOCTL_GEM_FLINK, &fl); /* fl.name == 16 == 0x10 */
/* 写入 16 到 gem.name @224 == pipe_buf[5].flags -> PIPE_BUF_FLAG_CAN_MERGE */
``
从这里开始,就是 DirtyPipe 的结局。我将只读目标文件的一个字节拼接到喷洒的管道中,使得每个管道都锚定到一个页面缓存页,然后通过它们写入。设置了 `CAN\_MERGE` 的缓冲区将我的数据合并到文件的缓存页中,覆盖它而不需要将页面标记为脏或通过文件系统权限检查写回。
## 利用链
1. 使 `GEM\_CHANGE\_HANDLE` 与 `GEM\_CLOSE` 竞争,以在新句柄仍然引用对象时释放它。
2. 用喷洒的 `pipe\_buffer` 数组重新占据 `kmalloc\-512` 槽位(msg\_msg 风水,然后通过拼接填充管道)。
3. 通过驱动信息 ioctl 读回 `size`。它是 `pipe\_buf[5].ops`,一个内核 `.text` 指针,从而得到 KASLR 基址。
4. 对悬垂句柄执行 FLINK,使其名称 (16 = `0x10`) 落在 `pipe\_buf[5].flags` 上并设置 `PIPE\_BUF\_FLAG\_CAN\_MERGE`。
5. 通过拼接的管道写入,将攻击者数据合并到只读 `/etc/passwd` 的页面缓存中。root 行丢失其密码字段。
泄露使链变得确定性而非猜测:地址在运行时返回,因此开启 KASLR 也能工作,只是每次启动值会变化。演示使用 `nokaslr` 只是为了输出稳定可读。
## 证据
与驱动程序无关的 KASAN 触发器竞争两个 ioctl,然后关闭文件描述符以解引用悬垂句柄。在 QEMU 下使用 virtio-gpu 的 Linux 7.0-rc7 上启用 `CONFIG\_KASAN=y` 报告:
``
BUG: KASAN: slab-use-after-free in drm_gem_object_release_handle+0x24/0x100
Read of size 8 at addr ffff888104769d60 by task kasan_trigger/75
Allocated by task 75:
virtio_gpu_create_object -> __drm_gem_shmem_create ->
virtio_gpu_mode_dumb_create -> drm_mode_create_dumb_ioctl
Freed by task 39:
kfree -> virtio_gpu_dequeue_ctrl_func -> process_one_work
The buggy address belongs to the cache kmalloc-512 of size 512
``
完整的利用链在无 KASAN 下运行(隔离区阻塞了管道喷洒的重占据),并且可靠地获得 root。在 100 次全新启动中,成功了 99 次。PoC 内部重试:在泄露失败时,它重新竞争以获得一个新的悬垂对象,而不是重新喷洒一个死槽位,最多 200 轮,每轮几十毫秒。大多数启动在第一次竞争就成功。大约 1% 的失败是内核竞态 UAF 固有的缺点:一种失败的交互导致虚拟机挂起,可通过断电重启恢复。
``
[!] 竞争成功 (迭代 977): 句柄=132049
[!] KASLR: pipe_buf_ops = 0xffffffff82428400
[!] FLINK: 16 = 0x10
[*] /etc/passwd:
root::0:0:pwned:/root:/bin/sh
[!] LPE CONFIRMED
``
## 修复
已发布的修复基本保留了 `change\_handle` 的原样,但阻止了新句柄在竞态窗口期间指向一个活跃的对象。它分配新槽位,立即用 NULL 替换它,并且仅在 prime 簿记完成后才提交真正的对象。如果并发的关闭先到达,它会注意到并退回:
``
ret = idr_alloc(&file_priv->object_idr, obj, handle, handle + 1, GFP_NOWAIT);
if (ret < 0) { ... }
idrobj = idr_replace(&file_priv->object_idr, NULL, handle);
if (idrobj != obj) {
/* 并发关闭已经占用了此槽位 */
idr_replace(&file_priv->object_idr, idrobj, handle);
idr_remove(&file_priv->object_idr, args->new_handle);
ret = -ENOENT;
goto out_unlock;
}
``
## 我建议的修复与最终提交的修复之间的差异
对我来说,更有趣的部分是我发送的修复与实际落地的修复之间的差距,因为它们以相当不同的方式关闭了同一个漏洞。
我的本能是让 `change\_handle` 不再是一个特例。漏洞存在的全部原因就是它手动操作句柄移动而不是使用生命周期助手函数,因此我的补丁围绕它们重建了它:用 `drm\_gem\_object\_handle\_get()` 获取句柄计数引用,用 `create\_tail` 的方式发布新句柄(先 `idr\_alloc(NULL)`,然后在完全设置好后用 `idr\_replace(obj)`),连接 `create\_tail` 所做的每个句柄的位(`drm\_vma\_node\_allow()`,`obj\->funcs\->open()`),并通过 `drm\_gem\_handle\_delete()` 的路径拆除旧句柄。基本上,使其看起来像文件中所有其他的句柄操作。它有效,但对于一个小漏洞来说变化量很大。
David Francis 和 Dave Airlie 提交的修复更小,而且老实说,更优雅。他们没有重建漏洞,而是...