@jedisct1: epoll UAF
摘要
对 Linux 内核 epoll 子系统中的一个释放后使用(UAF)漏洞的详细分析,该漏洞通过切换到 RCU 修复,以及作者在现代设备上尝试利用该漏洞失败的经过。
查看缓存全文
缓存时间: 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_proc 和 reverse_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_proc 在 rcu_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=y 和 CONFIG_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,如果你足够小心且勇敢,可以进入这些路径。它们需要巨大的内存压力,有些可能是有成果的。琐事:我们还控制 gen 和 loop_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_head。kfree_rcu() 将释放延迟到 RCU 宽限期结束。由于遍历器持有 rcu_read_lock(),因此在遍历器完成之前宽限期无法结束。
结语
这个漏洞给我留下深刻印象的不是竞态条件或分配器内部机制,而是需要多少工作才能理解 epoll 中的哪些代码路径受到哪些保护。等待队列锁序列化回调,文件引用计数控制 ep_free,__fput 排序清理,call_rcu 延迟 epitem 释放。每个机制覆盖了某些部分。你必须在脑中同时持有所有这些,然后才能指向 epi->ep 并确信没有任何东西让目标保持活跃。我仅在这一部分就花了好几天。
我鼓励任何人在现代 Android 系统上尝试利用此漏洞,这听起来很有趣,并且我很想知道你是如何基于此漏洞获得稳定的任意读写原语的。
相似文章
Bad Epoll (CVE-2026-46242)
Bad Epoll (CVE-2026-46242) 是 Linux 内核 epoll 子系统中的一个竞态条件释放后使用漏洞,允许无权限用户在 Linux 和 Android 设备上获取 root 权限。该漏洞由 Jaeyoung Chung 报告,但被 Anthropic 的 Mythos AI 遗漏。
一次Linux内核零日漏洞之旅——从受限的UAF到物理内存读写
本文详细介绍了在网络调度子系统(red调度器)中发现并利用的一个Linux内核零日漏洞,将一个受限的slab释放后使用(UAF)转化为完全的物理内存读写,最终实现root权限提升。该漏洞存在了2.5年,于2026年6月被修复。
Unix GC 重制版
详解 Linux 内核 AF_UNIX 垃圾收集器的重写,包括背景、新的基于图的模型以及一个释放后使用漏洞。
Linux内核中因单个错误字符导致的高危漏洞
Linux内核中一个错误的字符引入了一个use-after-free漏洞(CVE-2026-53111),允许非特权用户在Debian和Ubuntu系统上将权限提升至root;该漏洞已修复并移植回旧版本。
你给我一个u32。我让你成为root。 (io_uring ZCRX freelist LPE)
Linux内核io_uring子系统中通过零拷贝接收freelist漏洞实现的本地权限提升利用。