@jedisct1: epoll UAF

X AI KOLs Timeline 新闻

摘要

对 Linux 内核 epoll 子系统中的一个释放后使用(UAF)漏洞的详细分析,该漏洞通过切换到 RCU 修复,以及作者在现代设备上尝试利用该漏洞失败的经过。

epoll UAF https://t.co/grDutada6V
查看原文
查看缓存全文

缓存时间: 2026/05/27 01:15

epoll 释放后使用 (UAF) 漏洞

来源:https://guysrd.github.io/epoll-uaf

几周前,Nicholas Carlini 在 fs/eventpoll.c 中利用了一个 epoll 的释放后使用 (UAF) 竞态条件。提交 07712db80857 (https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=07712db80857d5d09ae08f3df85a708ecfc3b61f) 将 kfree() 改为了 kfree_rcu()。提交信息写道:“eventpoll: defer struct eventpoll free to RCU grace period”。

这一处修改修复了一个 UAF 漏洞,该漏洞自从受影响的优化被引入以来,在运行 6.6 及以上内核的任何 Linux / Android 系统上,任何无特权的进程都可以触发。这篇文章将讲述这个漏洞本身、它提供了什么攻击能力,以及我在真实现代设备上尝试利用该漏洞(但最终失败)的经历。我在 Pixel 10 上花了一些时间研究这个漏洞,过程中学到了比预料中更多的关于 CFS vruntime 技巧、SLUB 内部机制和 ARM64 内存模型的知识。


两秒钟了解 epoll

如果你运行过 Linux 服务器,那么你间接使用过 epoll。它是内核的可扩展 I/O 通知机制,能让 nginx 在不阻塞每个连接线程的情况下监视成千上万个套接字。三个系统调用:epoll_create() 创建一个实例,epoll_ctl() 添加或移除被监视的文件描述符,epoll_wait() 阻塞直到有事件发生。

Linux 将所有内容都当作文件来管理,因此 epoll fd 本身也是一个文件描述符。你可以将一个 epoll 添加到另一个 epoll 中。这创建了一个有向图,其中实例监视实例,内核在 epoll_ctl(ADD) 内部具有验证代码,用于遍历此图以检查循环和深度违规,而该验证代码正是漏洞所在之处。

epoll 有历史记录 (https://lore.kernel.org/all/[email protected]/) CVE (https://lore.kernel.org/all/[email protected]/) 漏洞,但它们的利用没有文档记录,并且非常稀少。


数据结构

epoll 的数据结构和 UAF

struct eventpoll:每个 epoll_create() 对应一个。包含等待队列、被监视项的 RB 树,以及在偏移量 176 处的 refs:一个 hlist 头,它将每个从其他地方指向此实例的 epitem 链接起来。这是图上的入边列表。

struct epitem:每个 (epoll 实例, 被监视 fd) 对对应一个。包含 epi->ep,一个指向其所属 eventpoll 的指针。如果被监视的 fd 本身也是一个 epoll,那么这个 epitem 也会通过 fllink 链接到那个 epoll 的 refs hlist 中。

图遍历器遍历 ep->refs,对于每个条目通过 epi->ep 到达父 eventpoll,然后递归。那个 epi->ep 的解引用就是 UAF。


2023 年的优化

在 2023 年 3 月之前,每次带有嵌套目标的 epoll_ctl(ADD) 都会获取一个名为 epmutex 的全局互斥锁。在 HTTP 基准测试下,58% 的 CPU 时间都浪费在了对此锁的争用上。

一个补丁将 epmutex 替换为每个实例的 refcount_t,向 struct epitem 添加了一个 dying 标志,并将剩余的锁定范围缩小到仅在实际图遍历期间保持。吞吐量提升了 60%。

竞态发生在图遍历器 ep_get_upwards_depth_procreverse_path_check_proc 中。这两个函数在 rcu_read_lock() 下迭代 ep->refs,而其他线程正在拆除它们所指向的结构。旧的 epmutex 曾经巧合地序列化了这个过程,但新的优化过于开放,没有人注意到遍历器的竞态。原因是它们并没有触及互斥锁名义上保护的任何数据,它们仅仅是在读取数据。


漏洞

static int ep_loop_check(struct eventpoll *ep, struct eventpoll *to)
{
	int depth, upwards_depth;

	inserting_into = ep;
	/*
	 * 检查从 @to 出发能向下深入多少层,以及是否可能存在
	 * 回到 @ep 的循环。
	 */
	depth = ep_loop_check_proc(to, 0);
	if (depth > EP_MAX_NESTS)
		return -1;
	/* 检查从 @ep 出发能向上走多远。 */
	rcu_read_lock();
	upwards_depth = ep_get_upwards_depth_proc(ep, 0);
	rcu_read_unlock();

	return (depth+1+upwards_depth > EP_MAX_NESTS) ? -1 : 0;
}

..
snip
..

static int ep_get_upwards_depth_proc(struct eventpoll *ep, int depth)
{
    int result = 0;
    struct epitem *epi;

    if (ep->gen == loop_check_gen)
        return ep->loop_check_depth;

    hlist_for_each_entry_rcu(epi, &ep->refs, fllink)
        result = max(result, ep_get_upwards_depth_proc(epi->ep, depth + 1) + 1);
    ep->gen = loop_check_gen;
    ep->loop_check_depth = result;
    return result;
}

ep_get_upwards_depth_procrcu_read_lock() 下运行。每个 epitem 在解除链接时是安全的,它通过 call_rcu() 释放,因此 RCU 在读侧临界区内使其保持活跃。甚至在源代码中有一条注释承认了 RCU 读取者:

/* rcu 读侧,reverse_path_check_proc(),不会使用 rbn 字段。 */
call_rcu(&epi->rcu, epi_rcu_free);

该注释关于 epitem 是正确的。但它没有提到 epi->ep 指向什么。

现在来看看拆除路径:

static void ep_free(struct eventpoll *ep)
{
    mutex_destroy(&ep->mtx);
    free_uid(ep->user);
    wakeup_source_unregister(ep->ws);
    kfree(ep);
}

kfree(),立即释放。没有 RCU 宽限期。

遍历器加载 epi->ep(一个指针读取),然后解引用目标,但该 eventpoll 可能已经被释放并被一个完全不同的 kmalloc-256 分配重用。


触发漏洞

竞态时间线

我最初尝试在两个不同的 CPU 上使用两个线程,一个遍历图,另一个关闭 epoll fd,但没有成功。从 hlist 加载 epi 到跟随 epi->ep 之间的窗口只有几条 ARM64 指令。真正有效的是同一 CPU 的抢占。我测试的 Frankel 设备运行 CONFIG_PREEMPT=yCONFIG_PREEMPT_RCU=y,这意味着 rcu_read_lock() 只是增加一个每任务计数器,而不会禁用抢占。遍历过程中的一个定时器滴答可以让 CPU 让渡给关闭线程,即使遍历器正处于 RCU 临界区中。

仅提供一些数字供参考(CONFIG_HZ=250,每 4 毫秒一个滴答):

  • 4096 个父节点:遍历大约需要 400 微秒。很少与滴答重叠。
  • 8000 个父节点:大约 2 毫秒。可靠地重叠。每次尝试的命中率约为 4%。

如果关闭线程在等待触发信号时忙等待,调度器会将其视为与遍历器相同的优先级,永远不会切换;但如果你在等待循环中让关闭线程 usleep(1000) 则不同。睡眠线程在唤醒时会获得调度优先级,调度器会立即抢占遍历器。

Pixel 的默认调节器会在空闲时将频率限制到 729 MHz,在此频率下遍历时间会发生足够大的偏移,以至于竞态完全停止触发 :’)


写入什么

struct eventpoll {
    struct mutex               mtx;                  /*     0    48 */
    wait_queue_head_t          wq;                   /*    48    24 */
    wait_queue_head_t          poll_wait;            /*    72    24 */
    struct list_head           rdllist;              /*    96    16 */
    rwlock_t                   lock;                 /*   112     8 */
    struct rb_root_cached      rbr;                  /*   120    16 */
    struct epitem *            ovflist;              /*   136     8 */
    struct wakeup_source *     ws;                   /*   144     8 */
    struct user_struct *       user;                 /*   152     8 */
    struct file *              file;                 /*   160     8 */
    u64                        gen;                  /*   168     8 */ /* 读取,然后写入 loop_check_gen */
    struct hlist_head          refs;                 /*   176     8 */ /* 作为 hlist 指针读取         */
    u8                         loop_check_depth;     /*   184     1 */ /* 写入 0 或一个内核指针      */
    refcount_t                 refcount;             /*   188     4 */
    unsigned int               napi_id;             /*   192     4 */

    /* 大小: 200, 缓存行: 4, 成员: 15 */
};

struct eventpoll 位于 kmalloc-256 中(order-1 的 slab,每 slab 32 个对象,此设备上 cpu_partial=52)。Frankel 设备默认设置了 init_on_free=1,并且 Android 在每个对象的末尾添加了自定义填充,因此该结构与主线 Linux 略有不同。

由于 refs.first 的遍历在偏移量 176 处,这是我们的目标偏移量,这对于我尝试在没有信息泄露的情况下一次性利用此漏洞至关重要:

  • 如果它是init_on_free 的情况),则 hlist 看起来为空。遍历器跳过循环,在 168 处写入 loop_check_gen,在 184 处写入一个零字节,然后返回。无论哪个对象被重用,都会被静默破坏 9 个字节。
  • 如果它是非零,则遍历器将其作为指向 epitem 的指针跟随,计算 container_of(),解引用 epi->ep,并递归到它指向的任何位置。这是一个任意写原语。

如果你能控制偏移量 176 处的对象,你就可以控制递归。每一层都会在指针目标固定偏移处写入 loop_check_gen(一个全局 u64 计数器,每次 epoll_ctl(ADD) 递增)和一个零字节。这是一个受限的写入原语。你用它做什么取决于你用什么 kmalloc-256 对象进行回收,以及你的创造力。

注意:还有我未在本文中包含的其他路径。其中一条路径通向 mutex_unlock,如果你足够小心且勇敢,可以进入这些路径。它们需要巨大的内存压力,有些可能是有成果的。琐事:我们还控制 genloop_check_depth,这允许将受控值(尽管非常缓慢但相当确定地)清零或写入已释放的块中。


能跨缓存利用吗?

我想将这个漏洞作为一次性原语来利用,并想通过 PTE 损坏来实现,我的尝试失败了,但这是我的策略。如果我要进行信息泄露,我会使用不同的原语,然后利用 refs.first 作为指针轻松解决所有问题。注意:这部分技术性较强。如果你不熟悉 PCP、页表项或 SLUB / Buddy 内部机制,我建议你在阅读此部分之前先了解一下。

释放的对象进入 kmalloc-256 并使用 order-1 的 slab。ARM64 的 PTE 页是 order-0(4 KB)。它们位于不同的 PCP 空闲列表上。从 slab 缓存释放的 order-1 页不会满足 order-0 的 PTE 请求,除非 PCP 溢出并且 buddy 将其拆分。在狭窄的竞态窗口内安排这种溢出被证明是不平凡的。可以在不触发竞态的情况下执行拆分,但将两个部分整合在一起从未成功过。

这些部分可以单独工作。将 250 个 slab 页中的 244 个转移到 buddy,通过 16 个子进程 fork 并各自触发 8 GB 页错误,所有可用的 UNMOVABLE order-1 页被拆分为 PTE 分配。slab2buddy 转换有效,buddy2PTE 转换有效,问题在于将它们与竞态结合起来。遍历器大约在 2 毫秒内完成。完整的跨缓存流水线(SLUB 丢弃,PCP 排水,buddy 插入,带 __GFP_ZERO 的 PTE 分配)需要大约 100 毫秒。gen 写入需要落在一个已经完成从 slab 到 PTE 转换的物理页上,而这些时间线并不重叠。我找不到一种方法在不诉诸 SCHED_FIFO 或类似特权技巧的情况下延长遍历时间,这违背了目的。

同一缓存回收完全忽略了这一点。SLUB 的 per-CPU 空闲列表是 LIFO:最后释放,最先分配。在同一 CPU 上立即执行 kmalloc(256) 可以得到完全相同的槽位。困难的部分是找到一个在偏移量 168 和 176 处具有有用布局的 kmalloc-256 对象,我没有在这方面投入太多时间。


修复

提交 07712db80857 (https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=07712db80857d5d09ae08f3df85a708ecfc3b61f):

static void ep_free(struct eventpoll *ep)
 {
     mutex_destroy(&ep->mtx);
     free_uid(ep->user);
     wakeup_source_unregister(ep->ws);
-    kfree(ep);
+    kfree_rcu(ep, rcu);
 }

该修复向 eventpoll 添加了一个 struct rcu_headkfree_rcu() 将释放延迟到 RCU 宽限期结束。由于遍历器持有 rcu_read_lock(),因此在遍历器完成之前宽限期无法结束。


结语

这个漏洞给我留下深刻印象的不是竞态条件或分配器内部机制,而是需要多少工作才能理解 epoll 中的哪些代码路径受到哪些保护。等待队列锁序列化回调,文件引用计数控制 ep_free__fput 排序清理,call_rcu 延迟 epitem 释放。每个机制覆盖了某些部分。你必须在脑中同时持有所有这些,然后才能指向 epi->ep 并确信没有任何东西让目标保持活跃。我仅在这一部分就花了好几天。

我鼓励任何人在现代 Android 系统上尝试利用此漏洞,这听起来很有趣,并且我很想知道你是如何基于此漏洞获得稳定的任意读写原语的。

相似文章

Bad Epoll (CVE-2026-46242)

Lobsters Hottest

Bad Epoll (CVE-2026-46242) 是 Linux 内核 epoll 子系统中的一个竞态条件释放后使用漏洞,允许无权限用户在 Linux 和 Android 设备上获取 root 权限。该漏洞由 Jaeyoung Chung 报告,但被 Anthropic 的 Mythos AI 遗漏。

Unix GC 重制版

Hacker News Top

详解 Linux 内核 AF_UNIX 垃圾收集器的重写,包括背景、新的基于图的模型以及一个释放后使用漏洞。

Linux内核中因单个错误字符导致的高危漏洞

Ars Technica

Linux内核中一个错误的字符引入了一个use-after-free漏洞(CVE-2026-53111),允许非特权用户在Debian和Ubuntu系统上将权限提升至root;该漏洞已修复并移植回旧版本。