GhostLock (CVE-2026-43499) 是 VEGA 发现的一个 Linux 内核漏洞,自 2011 年以来存在于每个主流发行版中。触发此漏洞无需任何特殊内核配置或特权。通过将其转化为一个成功率 97% 的本地提权与容器逃逸漏洞,我们在 Google 的 kernelCTF 项目中获得了 92,337 美元的赏金。本文详细介绍了漏洞利用的技术细节。
漏洞概述
GhostLock (CVE-2026-43499) 允许一个无特权的本地攻击者:
- 仅通过常规线程系统调用,获取一个指向内核栈内存的悬垂内核指针。
- 劫持一个函数表以实现控制流劫持,最终获得 root 权限。
GhostLock 在 Linux 2.6.39 中引入,在 Linux 7.1 中修复。它在 Linux 内核中已存在超过 15 年。所有未经修补的 Linux 发行版均受影响,应考虑升级至最新的 LTS 版本。
漏洞分析
概述
GhostLock 随 8161239a8bcc ( "rtmutex: Simplify PI algorithm and make highest prio task get lock" ) 中对 rtmutex 的重写而引入,在沉寂了大约十五年后,于 2026 年 4 月被 3bfdc63936dd ( "rtmutex: Use waiter::task instead of current in remove_waiter()" ) 修复。受影响内核版本为 v2.6.39-rc1 至 v7.1-rc1,唯一要求是配置 CONFIG_FUTEX_PI=y,且无需任何能力 (capabilities) 或用户命名空间。
kernel/locking/rtmutex.c 中的 remove_waiter() 函数会清除 current->pi_blocked_on。这在正常的慢速路径 (slow path) 上是正确的,因为 current 是拥有该 waiter (等待者) 的线程。但在代理路径 (proxy path) 上,这就错了。rt_mutex_start_proxy_lock() 代表另一个线程入队一个 rt_mutex_waiter,并在出错时回滚 (roll back)。因此,此时代理入队的 current 是 requeuer (重排线程) 而非 waiter (实际等待者)。
译者注:为了区分,下文将 waiter (等待者) 对象译为 “waiter 对象”,将持有 waiter 结构的实际线程(即等待者线程)译为 “waiter 线程”。requeuer 指执行 FUTEX_CMP_REQUEUE_PI 操作进行代理入队的线程。
waiter 对象位于一个通过 FUTEX_WAIT_REQUEUE_PI 正在休眠任务的栈上。随后,一个 FUTEX_CMP_REQUEUE_PI 操作将该 waiter 对象代理入队到目标 PI futex 上。当 rtmutex 的链式遍历报告死锁时,回滚操作会将 waiter 对象从锁的等待队列中移除,但错误地清除了 requeuer 线程的 pi_blocked_on。waiter 线程的 pi_blocked_on 仍然指向其自身的栈帧,该栈帧在 waiter 线程返回用户空间的那一刻即被弹出。之后任何通过此任务进行的 PI 链式遍历,都会跟随这个悬垂指针 (dangling pointer)。
根本原因
这与许多其他生命周期漏洞的形态相同:一个函数被并非为其编写的调用者重用。
辅助函数 remove_waiter() 最初只为一种场景编写:一个线程自己阻塞,然后自行清理。因此,它一直假设 current (当前正在运行的线程) 就是它需要清理的那个 waiter,并相应地清除 current->pi_blocked_on。
然而,Requeue-PI 打破了这一假设。通过 rt_mutex_start_proxy_lock(),这个辅助函数现在被用来代表另一个正在休眠的线程进行清理。在这个路径下,current 是发出 FUTEX_CMP_REQUEUE_PI 的线程,而不是实际的 waiter。
当 __rt_mutex_start_proxy_lock() 返回 -EDEADLK 时,它会通过 remove_waiter() 进行回滚,该函数在此处被误用。
int __sched rt_mutex_start_proxy_lock(struct rt_mutex_base *lock, struct rt_mutex_waiter *waiter, struct task_struct *task){int ret; raw_spin_lock_irq(&lock->wait_lock); ret = __rt_mutex_start_proxy_lock(lock, waiter, task);if (unlikely(ret)) remove_waiter(lock, waiter); // ret == -EDEADLK raw_spin_unlock_irq(&lock->wait_lock);return ret;}
然后,remove_waiter() 错误地清理了其他任务。
staticvoid __sched remove_waiter(struct rt_mutex_base *lock, struct rt_mutex_waiter *waiter){ ... raw_spin_lock(¤t->pi_lock); rt_mutex_dequeue(lock, waiter); current->pi_blocked_on = NULL; // 应该是 waiter->task raw_spin_unlock(¤t->pi_lock); ...}
waiter 是位于休眠线程自身栈上的对象,而此处的 current 是请求重排 (requeue) 的线程。修复方法是锁定 waiter->task->pi_lock 并清除 waiter->task->pi_blocked_on。这个问题绕过了 lockdep,因为 lockdep 只检查是否持有 pi_lock,而不关心持有的是谁的 pi_lock。
触发 -EDEADLK 路径。要到达 -EDEADLK 回滚,需要由三个 futex 字和三个线程构建一个 PI 依赖循环。
f_pi_chain,一个 PI futex,首先由 waiter 线程锁定。f_pi_target,一个 PI futex,首先由 owner 线程锁定。这是重排的目标锁。f_wait,waiter 线程通过 FUTEX_WAIT_REQUEUE_PI 阻塞其上的普通 futex。
操作序列如下:
- waiter 线程获取
f_pi_chain,然后通过 FUTEX_WAIT_REQUEUE_PI(f_wait -> f_pi_target) 阻塞。其 rt_mutex_waiter 结构体现在位于其栈上。 - owner 线程获取
f_pi_target,然后试图获取 waiter 线程持有的 f_pi_chain 并阻塞。 - 主线程调用
FUTEX_CMP_REQUEUE_PI(f_wait -> f_pi_target)。
重排操作试图将 waiter 代理入队到 f_pi_target 上。由于 f_pi_target 的持有者 (owner) 已经通过 f_pi_chain 阻塞在 waiter 后面,链式遍历会闭合环路 waiter -> f_pi_target -> owner -> f_pi_chain -> waiter,并返回 -EDEADLK,触发了有缺陷的回滚操作。waiter 线程被唤醒,其 pi_blocked_on 指向了一个悬垂地址。
这里唯一关键的时序点是:requeuer 在 waiter 线程依然持有这个即将被释放的对象时对其进行回滚,一旦循环条件达成,这就会自然发生。循环解除后,就没有任何时间压力了。waiter 线程带着悬垂的 pi_blocked_on 停留在用户空间,后续遍历链的 sched_setattr() 操作可以随时触发。UAF (释放后重用) 的窗口完全敞开。
这个漏洞的特殊之处在于,被释放的对象位于内核栈上 (如果我们把从 futex 系统调用返回 ret 视为一次“释放”,这就是一个栈-UAF)。为了重新利用这块内存,我们需要找到一个系统调用,它能将受控的字节写回同一栈的相同深度 (偏移量)。
触发栈-UAF
构建好三 futex 循环后,waiter 任务会留在用户空间,并且其 pi_blocked_on 指向其旧的 FUTEX_WAIT_REQUEUE_PI 帧。之后的所有操作都依赖于这一个指针。
注意,使用三个线程是为了更好地理解原理。为了赢得竞争并触发 UAF,你只需要一个 CPU 核心。
从 GhostLock 获得初始原语 (Primitive)
至此,我们持有了一个指向已释放内核栈的指针,并且可以随时触发一次内核访问,将这块内存解引用为一个 rt_mutex_waiter。我们可以将受控字节喷洒 (spray) 到那个栈上,并直接伪造一个 rt_mutex_waiter 结构体。根据我们伪造的结构形态,这一次访问能产生多种原语,其中主要有两个:
- 将一个 8 字节的零值写入一个任意 (但有限制) 的地址。
在原始操作触发之前,会进行多次指针解引用和完整性检查,操作完成后内核会正常返回,不会崩溃。
因此,我们的主要问题如下,每个问题将在后续章节中解答:
- 如何重新获取已释放的栈内存 (喷洒)?-> 重用栈
- 如何让伪造的
rt_mutex_waiter 通过其内置的结构检查,并伪造出读起来有效的指针?-> 从伪造的 waiter 到一次写入 - 选择哪种写入原语?我们要写什么,写到哪里?原语对所谓的“任意”地址有何限制?-> 借助 inet6_protos
利用细节
利用概述
- 预取 (prefetch) -> 泄漏内核镜像偏移 (slide) 和物理映射区基址 (physmap base)。
- GhostLock -> 在 waiter 任务的
pi_blocked_on 中留下一个悬垂的 rt_mutex_waiter 指针。 - (栈-)UAF 内存回收 -> 使用
PR_SET_MM_MAP 回收 waiter 线程自身的内核栈,并在已释放的栈帧上伪造一个假的 rt_mutex_waiter。 - 任意地址写入器 (Arb address writer) -> Rtmutex 红黑树删除:一次受限制的指针写入 (我们可以控制其内容),覆盖一个包含函数表的结构体:
inet6_protos[IPPROTO_UDP] = <CEA 指针>。 - CPU 入口区 (CPU entry area, CEA) -> 将{假的
inet6_protocol、栈转换 (pivot) 槽、ROP 栈}一起放置在已知的直接映射地址 (direct-map address)。 - 触发控制流劫持 (CFH) -> 触发生成一个回环 IPv6 UDP 数据包,通过被覆盖的处理函数进行调用,并转移执行流。
- DirtyMode -> 一次写入翻转
core_pattern 的模式位,之后的提权操作完全在用户空间完成。
本部分我们将重点讨论通用 x86 Linux 系统的基本利用步骤。我们的下一篇博客将讨论如何在 Android 上利用 GhostLock,回收栈帧,并同时绕过 ASLR 和 CFI。
所用技巧的背景知识
预取 (prefetch) 泄漏 ASLR
对给定地址执行 prefetch 指令的运行周期数,取决于该地址是否在当前页表中被映射。因此,一个无特权进程可以通过对内核地址范围进行 prefetch 并计时,来探测哪些地址被映射 (详情见 prefetch 论文)。
这种技术在 Linux 上有效,因为 Linux 对其默认内核镜像基址的随机化程度很低 (文本段基址约 9 位熵),所以通过少量平均计算就能以接近 100% 的可靠性恢复 KASLR 基址。
理论上,任何支持 prefetch 且未实施完整内核页表隔离的 CPU 都受影响。但实际上,这更像是一种 x86 技术 (除非 ARM 目标系统关闭了 KPTI)。kernelCTF 镜像默认禁用 KPTI。
kernelCTF 镜像默认禁用 KPTI,但即使开启 KPTI,prefetch 结合 EntryBleed 仍可通过蹦床 (trampoline) 恢复内核镜像基址。
CEA 喷洒与随机化绕过
CEA (CPU 入口区) 是一个每 CPU 的 x86 结构体,用于保存进入异常处理时使用的栈和寄存器上下文:异常、中断或系统调用时,CPU 会切换到位于 CEA 中的栈,进入代码会将寄存器帧 (pt_regs) 保存在那里。一个无特权的用户空间程序可以触发一个软件异常,并将其自身的寄存器上下文写入保存在 CEA 异常栈的 pt_regs 中。在 6.2 版内核之前,CEA 位于完全固定的地址,因此我们可以在已知的内核地址放置约 120 字节的连续受控内存,这对于伪造结构体、吸收过程中指针解引用的副作用以及设置 ROP 栈非常方便。
在 Project Zero 的 Bringing back the stack attack 文章发表后,内核开始对 CEA 的虚拟地址进行强随机化 (自 6.2 起)。但 CEA 的虚拟地址实际上并不需要,因为 CEA 的物理偏移是固定的,所以其直接映射别名 (direct-map alias) 可以从物理映射区基址 (physmap base) 推导出来 (与 @kqx 使用的观察相同)。
该直接映射地址可以通过 prefetch 泄漏,再加上候选地址边缘标准化和针对预测的 CEA 页的检查,以排除相邻的映射别名。 (直接映射泄漏比文本段泄漏的噪声更大,可能需要更多调优,但最终能达到非常高的准确度。) 因此,我们总能计算出 CEA 的另一个虚拟地址映射:
cea_direct = physmap_base + CPU1_CEA_BASE
请注意,每个 CPU 的 CEA 虚拟地址被随机化到不同的位置。不过,它们的物理地址都是固定的,并且这个偏移主要取决于目标内核版本和启动内存大小。在 kernelCTF 的 LTS 6.12.80、3.5G 启动内存的环境中,它是 0x11c517000(+0x1f58)。
重用栈:使用 PR_SET_MM_MAP 伪造 waiter
悬垂的对象是 waiter 线程自身的栈上的 rt_mutex_waiter。
structrt_mutex_waiter {structrt_waiter_nodetree;// 红黑树节点,位于 lock->waiters 中structrt_waiter_nodepi_tree;structtask_struct *task;structrt_mutex_base *lock;unsignedint wake_state;structww_acquire_ctx *ww_ctx;};
我们需要让受控字节回到那个确切的栈帧上,位于 waiter 线程自己的栈上,并且要有足够长的停留时间以供读取。waiter 线程从 futex 系统调用返回后,立即调用 prctl(PR_SET_MM, PR_SET_MM_MAP, ...)。在内部,prctl_set_mm_map() 将一个用户提供的 auxv 数据复制到固定大小的 unsigned long user_auxv[AT_VECTOR_SIZE] 栈缓冲区中。该缓冲区位于大致与已释放的 waiter 相同的栈深度,因此它是一个大的、自然对齐的、无命名空间的受控四字 (qword) 块,正好覆盖在旧对象之上。
auxv 的布局使得重叠的四字变为:
tree,一个红黑树节点,其形状使得擦除它时,会将一个选定的子指针 (W0_BASE,见下文) 提升为树根。task,设置为 &init_task,这是一个有效的 task_struct,以确保链式遍历中的任务解引用安全。lock,设置为 &inet6_protos[IPPROTO_UDP] - 8,即写入目标地址。
auxv 由一个 memfd 文件支持,并且放置位置使得复制操作跨越一个页边界。一个兄弟线程在 prctl 执行期间,通过在尾页 (trailing page) 上进行 fallocate(PUNCH_HOLE) 操作来竞争,这会延长 copy_from_user 的窗口期。伪造的 waiter 会保留在栈上,与此同时,在另一个 CPU 上,一个消费线程 (consumer thread) 在 waiter 上执行 sched_setattr() 以遍历 PI 链。竞争窗口很宽,我们相信 GhostLock 在单核 CPU 上也是可利用的。
clone / setsockopt / pselect / keyctl 以及其他带有大型受控栈局部变量的系统调用作用相同。prctl 在此处只是比较方便。缓冲区大、对齐且无需命名空间。在我们的 开源 PoC 代码 中,可以找到更多可用于回收栈帧的有用系统调用。
从伪造的 waiter 到一次受控的(有限制的)写入
控制 waiter 并不能直接获得任意写入能力。链式遍历只执行以下操作:
task->pi_blocked_on -> 伪造的 waiter伪造的 waiter->lock -> 伪造的 rt_mutex_basert_mutex_dequeue(lock, waiter) // 在 lock->waiters 上进行红黑树的 rb_erase 操作
rt_mutex_dequeue() 是一次红黑树擦除操作,擦除一个单子节点 (single-child) 的根节点时,会将该子节点写入根节点槽位 (slot)。将 lock 指向 target - 8,可以把 rt_mutex_base 的字段与目标指针周围的数据对齐。
target - 8 -> raw_spinlock_t wait_lock (必须读为“未锁定”状态)target -> waiters.rb_root.rb_node (该槽位将被写入)target + 8 -> waiters.rb_leftmosttarget + 16 -> owner
伪造的 waiter 的红黑树节点形状经过设计,使得擦除操作恰好将一个子指针写入 rb_root.rb_node。写入原语本身是一次受约束的存储操作:*(uint64_t *)target = W0_BASE。
约束也非常严格:目标地址之前的四字必须读作一个未锁定的自旋锁,这意味着低 4 字节为零,否则尝试锁失败,遍历会退出而不进行写入。其后的四字 (rb_leftmost, owner) 不能将遍历引导到不受控的顶部 waiter 或 owner。如果这些位置的值未映射,会导致系统故障 (panic)。大致等效的目标地址约束如下 (*target 将被写入一个指针):
*(u32 *)(target - 0x08) == 0*(u64 *)(target + 0x08) == 0// 简化表示((*(u64 *)(target + 0x10)) & ~1ULL) == 0// 然后我们可以执行:*(u64 *)target = &W0->tree.entry // W0_BASE
这里的 W0_BASE 必须指向某个在后续比较以及同一 rt_mutex_adjust_prio_chain() 函数中后续的无主唤醒 (no-owner wakeup) 过程中保持有效的对象。我们将其指向 CPU 入口区的直接映射别名,这能带来双重好处:
- 写入前:CEA 是位于已知地址的可控内存,因此可以在
W0 处伪造一个自洽的假 waiter 和锁,从而在遍历中幸存。 - 写入后:目标现在指向 CEA 内部。一旦遍历结束,
W0 就不再需要看起来像一个 waiter 了,因此我们可以用内核期望目标指向的任何内容重新喷洒 CEA (如果我们用一个指向 W0 的指针覆盖了一个函数表指针,那么现在我们就可以在 CEA 中伪造函数指针以实现控制流劫持)。
有几种方法可以在固定的 (已知的) 内核地址上喷洒可控内存。CEA 是较为高效的方法之一,其主要限制是大小较小,约 120 字节。NPerm、kernelsnitch 等技巧可以完成相同的工作,并提供更多空间。
在触发之前,W0 被喷洒为那个假的 waiter 和锁对:task = &init_task,一个合法的 prio 值,以及一个其 wait_lock 读作未锁定且其 owner 是良性的锁,这样删除、重新入队、优先级更新和唤醒都能顺利通过。
下图展示了如何使用 CPU 入口区来先存放伪造的 rt_mutex_waiter 和 lock 结构,然后同时为 inet6 (下一节)、ROP 栈和栈转换的 JOP 工具提供空间,最后使用一个非常短的 ROP 链来执行 DirtyMode 并安全地停止核心。
借助 inet6_protos[IPPROTO_UDP]
从现在开始,利用路径会根据目标系统而有所不同。对于常规的 x86_64 Linux 内核,我们可以选择一条更短的路径,只需覆盖某个函数表 (或任何包含函数表的对象),因为我们已经泄漏了 KASLR 并准备好进行控制流劫持 (CFH)。
对可写数据的扫描揭示了许多指针表,其相邻的条目满足了上述布局要求。inet6_protos[IPPROTO_UDP] 是一个不错的选择。其相邻条目自然地符合要求,并且触发方式很简单,只需一个无特权的回环数据包。
inet6_protos[16] == NULL // 伪造的 wait_lock -> 未锁定状态inet6_protos[17] == &udpv6_protocol // <- 目标 (IPPROTO_UDP)inet6_protos[18] == NULL // 伪造的 rb_leftmostinet6_protos[19] == NULL // 伪造的 owner
写入之后,inet6_protos[IPPROTO_UDP] 指向了 CEA 页,内核期望在那里找到一个 inet6_protocol。
structinet6_protocol {int (*handler)(struct sk_buff *skb);int (*err_handler)(...);unsignedint flags;};
因此,W0 被重新喷洒为一个伪造的 inet6_protocol。handler 是第一个栈转换 (pivot) 工具,err_handler 未使用,flags 设置为 INET6_PROTO_NOPOLICY | INET6_PROTO_FINAL。一旦我们发送一个回环 IPv6 UDP 数据包 (连接到 ::1 然后写入),内核就会解引用 handler,从而让我们获得程序计数器 (PC) 的控制权。
栈转换与 DirtyMode
我们使用同一个紧凑的 CEA 窗口来容纳多个对象:{伪造的 inet6_protocol、几个 JOP/ 栈转换槽、最终的 ROP 栈}。在 Google 的 lts-6.12.80 内核目标上,我们没有那么幸运地找到一个完美的单一栈转换目标,因此转换链额外需要一次加载 / 调用操作,以便将 CEA 地址加载到 rbp 中,然后通过 mov rsp, rbp; pop rbp; ret 进行栈转换。
一个通过 ret2usr 或完整的 /proc/%P/fd/x 覆盖来实现利用,其所需的工具四字数可能多达十个左右,这太长了。因此,我们使用 DirtyMode 作为最终的利用阶段:仅用一次写入,以一个近乎垃圾的值,来翻转一个权限位。之后,提权操作可以完全在用户空间完成。
此处我们以 core_pattern 这个 sysctl 的模式标志位为目标:
staticstructctl_tablecoredump_sysctls[] = { ... { .procname = "core_pattern", .data = core_pattern, .maxlen = CORENAME_MAX_SIZE, .mode = 0644, .proc_handler = proc_dostring_coredump }, ...};
coredump_sysctls 位于可写内核数据中 (与内核镜像共享相同的 KASLR 偏移)。ROP 链将一个具有宽松权限的值写入 coredump_sysctls[1].mode。任何设置了写权限位 (次低有效位,LSB) 的值都足够。
这里我们使用一个简短的 pop reg; mov [reg], reg; ret 链,再加上 msleep 来安全地暂停被劫持的线程。现在 /proc/sys/kernel/core_pattern 已经全局可写,因此一个无特权的进程可以打开它,写入 |/proc/%P/fd/666 %P,然后故意崩溃一个辅助进程,诱使内核以 root 权限执行我们的程序。
初始的写入原语 (红黑树写入) 无法直接到达 coredump_sysctls[1].mode,因为它受限于写入位置,所以模式位的翻转是在简短的 ROP 阶段完成的。
附录
完整的利用代码可以在我们的 开源安全研究项目 CyberMeowfia 中找到。
更大的 ROP 或 NPerm
kernelCTF 是一场竞赛,最短且可靠的利用链会胜出。基于 NPerm 的内存可以在劫持后提供一个很好的大伪造栈,更复杂的利用路径也能工作,包括 Lukas Maar 的堆-KASLR 泄漏。但每多一个阶段都会增加时间成本。CEA 配合 DirtyMode 是通往“一次写入获胜”的最短路径,在远程环境下,我们仅用约 5 秒就获得了 flag。
缓解措施
补丁
diff --git a/kernel/locking/rtmutex.c b/kernel/locking/rtmutex.c--- a/kernel/locking/rtmutex.c+++ b/kernel/locking/rtmutex.c@@ -1544,6 +1544,8 @@ static bool rtmutex_spin_on_owner(struct rt_mutex_base *lock, * * Must be called with lock->wait_lock held and interrupts disabled. It must * have just failed to try_to_take_rt_mutex().+ *+ * When invoked from rt_mutex_start_proxy_lock() waiter::task != current ! */ static void __sched remove_waiter(struct rt_mutex_base *lock, struct rt_mutex_waiter *waiter)@@ -1551,14 +1553,15 @@ static void __sched remove_waiter(struct rt_mutex_base *lock, { bool is_top_waiter = (waiter == rt_mutex_top_waiter(lock)); struct task_struct *owner = rt_mutex_owner(lock);+ struct task_struct *waiter_task = waiter->task; struct rt_mutex_base *next_lock; lockdep_assert_held(&lock->wait_lock);- raw_spin_lock(¤t->pi_lock);- rt_mutex_dequeue(lock, waiter);- current->pi_blocked_on = NULL;- raw_spin_unlock(¤t->pi_lock);+ scoped_guard(raw_spinlock, &waiter_task->pi_lock) {+ rt_mutex_dequeue(lock, waiter);+ waiter_task->pi_blocked_on = NULL;+ } /* * Only update priority if the waiter was the highest priority@@ -1594,7 +1597,7 @@ static void __sched remove_waiter(struct rt_mutex_base *lock, raw_spin_unlock_irq(&lock->wait_lock); rt_mutex_adjust_prio_chain(owner, RT_MUTEX_MIN_CHAINWALK, lock,- next_lock, NULL, current);+ next_lock, NULL, waiter_task);
此补丁能有效缓解该漏洞的利用,但它遗漏了一种会导致空指针解引用 (NPD) 的场景:一个非顶部的 (non-top) 重排 waiter,在 waiter->task 被设置之前就已持有目标 PI futex,并遇到 -EDEADLK,此时新的 remove_waiter() 会解引用一个空指针 waiter->task。
我们也在 v1 补丁发布前向 security@kernel.org 发送了一个修复方案。其核心是:
static void __sched remove_waiter(struct rt_mutex_base *lock, struct rt_mutex_waiter *waiter) struct rt_mutex_waiter *waiter, struct task_struct *task){ ... raw_spin_lock(¤t->pi_lock); raw_spin_lock(&task->pi_lock); rt_mutex_dequeue(lock, waiter); current->pi_blocked_on = NULL; raw_spin_unlock(¤t->pi_lock); if (task->pi_blocked_on == waiter) task->pi_blocked_on = NULL; raw_spin_unlock(&task->pi_lock); ... rt_mutex_adjust_prio_chain(owner, RT_MUTEX_MIN_CHAINWALK, lock, next_lock, NULL, current); next_lock, NULL, task);}
我们不从 waiter->task 读取任务,而是让调用者传入所属任务 (在自身阻塞路径上是 current,在 rt_mutex_start_proxy_lock() 回滚路径上是代理的目标 task),并且只有当 pi_blocked_on 仍指向这个 waiter 时才清除它。task 总是一个有效的任务,并且清除操作是有条件的,因此这既能解决 UAF 问题,又不会引入 -EDEADLK 空指针解引用漏洞,并且不需要像 74e144274af3 这样的后续补丁。它带有一个微小、良性的行为差异,对正常使用或安全性没有影响。上游更喜欢 v1 中基于 waiter::task 的重写方案。
RANDOMIZE_KSTACK_OFFSET
栈重用步骤依赖于已释放的 waiter 栈帧与后续的 user_auxv 栈帧确定性重叠。开启 RANDOMIZE_KSTACK_OFFSET 后,它们不再重叠,该步骤变成了大约 1/32 (5 位) 的栈偏移猜测。提交的两个默认目标镜像都关闭了此选项。缓解措施测试目标开启了此选项,因此此路径在测试目标下未被使用。
STATIC_USERMODE_HELPER
STATIC_USERMODE_HELPER 会关闭这条特定的 DirtyMode 路径。但相同的思路可以推广到任何 /proc/sys 配置项,只要其 ctl_table::mode 控制访问权限,并且其表格位于可预测的可写内核数据中。
时间线
- 2026-04-18:我们报告了该漏洞并向 security@kernel.org 发送了补丁草案。
- 2026-06-30:Google 确认了我们的 kernelCTF 提交,并奖励我们 92,337 美元。
对于 VEGA 发现的所有漏洞,我们遵循标准的 90+30 天披露政策,详情请参阅我们的 关于页面。
原文:https://nebusec.ai/research/ionstack-part-2/
- END -