导语:GhostLock(CVE-2026-43499)是VEGA团队发现的一个Linux内核漏洞,自2011年起存在于所有主流发行版中。触发该漏洞无需任何特殊内核配置或特权,研究团队将其转化为稳定率高达97%的权限提升和容器逃逸手段,Google在kernelCTF项目中支付了92,337美元奖金。这篇报告将完整披露该漏洞的技术细节和利用过程。
漏洞概述
GhostLock 允许无特权的本地攻击者:
- 仅凭常规线程系统调用,获取一个指向内核栈内存的悬空内核指针
- 向一个几乎任意地址写入指针
- 劫持函数表以获取控制流并最终获得root权限
GhostLock 于 Linux 2.6.39 引入,在 Linux 7.1 中修复,在Linux内核中存在了超过15年。所有未打补丁的Linux发行版均受影响,应尽快升级到最新LTS版本。
【视频占位符:此处需手动插入漏洞演示视频,原文视频地址:https://nebusec.ai/research/ionstack-part-2/】
漏洞分析
概述
GhostLock 随 rtmutex 重构引入(commit 8161239a8bcc——”rtmutex: Simplify PI algorithm and make highest prio task get lock”),在约十五年后的2026年4月才通过 commit 3bfdc63936dd(”rtmutex: Use waiter::task instead of current in remove_waiter()”)修复。受影响版本范围为 v2.6.39-rc1 到 v7.1-rc1,唯一要求是 CONFIG_FUTEX_PI=y,无需任何capability或用户命名空间。
kernel/locking/rtmutex.c 中的 remove_waiter() 会清除 current->pi_blocked_on。在正常的慢路径中这是正确的——current 是拥有该waiter的任务。但在代理路径中这就错了。rt_mutex_start_proxy_lock() 会代表另一个任务将 rt_mutex_waiter 入队,出错时回滚,此时 current 是请求者(requeuer)而非waiter。
waiter对象位于在 FUTEX_WAIT_REQUEUE_PI 中休眠的任务的栈上。FUTEX_CMP_REQUEUE_PI 随后将该waiter代理到目标PI futex上。当rtmutex链遍历报告死锁时,回滚会将waiter从锁中出队,但清除的是请求者的 pi_blocked_on。waiter任务的 pi_blocked_on 仍指向其自身的栈帧,而该栈帧在waiter返回用户空间时已被弹出。此后任何经过该任务的PI链遍历都会跟随这个悬空指针。
根本原因
这与许多生命周期bug属于同一类型:一个函数被一个它从未被设计过的调用者重复使用。
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() 随后清除了错误的任务。
static void __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 是请求重新排队的线程。修复方案是锁定 waiter->task->pi_lock 并清除 waiter->task->pi_blocked_on。这个问题躲过了lockdep的检测——lockdep只检查是否持有了 pi_lock,但没有检查是谁的锁。
触发 -EDEADLK 路径。 要到达 -EDEADLK 回滚,需要构建一个由三个futex字和三个线程组成的PI依赖循环。
f_pi_chain:一个PI futex,先被 waiter 线程锁定f_pi_target:一个PI futex,先被 owner 线程锁定,这是重新排队的目标f_wait:一个普通futex,waiter 用FUTEX_WAIT_REQUEUE_PI在其上阻塞
序列如下:
- 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已经被waiter通过 f_pi_chain 阻塞在后面,因此链遍历闭合了循环 waiter -> f_pi_target -> owner -> f_pi_chain -> waiter。返回 -EDEADLK 并执行有bug的回滚。waiter醒来时带着一个悬空的 pi_blocked_on。
这里唯一重要的时序是:请求者在waiter仍拥有即将被释放的对象时,回滚了waiter的waiter。一旦循环搭建完成,这一步自动发生,之后没有任何时间压力。waiter坐在用户空间,带着一个悬空的 pi_blocked_on,后续触发链遍历的 sched_setattr() 可以随时 firing。UAF窗口完全打开。
问题在于被释放的对象位于内核栈上(如果我们称从futex系统调用返回为”释放”,那就是栈UAF)。为了回收它,我们需要找到一个系统调用,能够在同一栈深度(偏移量)将受控字节重新放到同一栈上。
触发栈UAF
搭建好三futex循环后,waiter任务在用户空间带有指向其旧 FUTEX_WAIT_REQUEUE_PI 栈帧的悬空 pi_blocked_on。接下来的一切都建立在这一个指针之上。
注意:三个线程是为了更好理解。要赢得竞争并触发UAF,只需要一个CPU核心。
GhostLock的初始原语
此时我们持有一个指向已释放内核栈的指针,并且可以随意触发一个将其作为 rt_mutex_waiter 解除引用的内核访问。我们可以向该栈喷洒受控字节,并直接伪造 rt_mutex_waiter。根据伪造的形状,这一次访问会产生多个原语,主要的两个是:
- 向一个任意(但受约束的)地址写入指针
- 向一个任意(但受约束的)地址写入8字节的零
在原语触发前会运行几个指针解引用和完整性检查,触发后内核正常返回,不会崩溃。
所以我们的主要问题,每个都在下面一节回答:
- 如何让被释放的栈内存回来(喷洒)?
- 如何让伪造的
rt_mutex_waiter通过其内置的结构检查,并伪造读起来有效的指针? - 哪个写原语,写到哪里?我们写什么?原语对”任意”地址有什么约束?
漏洞利用详情
漏洞利用摘要
- prefetch -> 泄漏内核镜像偏移和physmap基址
- GhostLock -> 在waiter任务的
pi_blocked_on中留下一个悬空的rt_mutex_waiter - (栈-)UAF回收 -> 使用
PR_SET_MM_MAP回收waiter自己的内核栈,并在释放的帧上伪造一个假的rt_mutex_waiter - 任意地址写入器 -> Rtmutex红黑树擦除:一次受约束的指针写入(我们可以回收其内容),覆写包含函数表的结构:
inet6_protos[IPPROTO_UDP] = <CEA指针> - CPU入口区 -> 在已知直接映射地址将 {伪造的
inet6_protocol、pivot槽、ROP栈} 全部放在一起 - 触发CFH -> 触发一个环回IPv6 UDP数据包,调用经过覆写的handler并pivot
- DirtyMode -> 一次写入翻转
core_pattern的mode位,之后的LPE完全在用户空间
Android呢?
这部分我们聚焦于通用x86 Linux系统的基本漏洞利用步骤。我们的下一篇文章将讨论如何在Android上利用GhostLock——回收栈帧、绕过ASLR和CFI。
所用技巧的背景
Prefetch ASLR泄漏
对给定地址的 prefetch 执行的周期数取决于该地址是否映射在当前页表中,因此无特权进程可以对内核范围进行 prefetch 计时并读出哪些地址被映射。
它在这里有效是因为Linux对其默认内核镜像的基址几乎不随机化(文本基址约9比特熵),因此少量平均就能以接近100%的可靠性恢复KASLR基址。
理论上任何带有 prefetch 且没有适当内核页表隔离的CPU都受影响。但实际上这更多是一种x86技术。kernelCTF镜像保持KPTI关闭,但即使KPTI开启,prefetch 配合 EntryBleed 仍能通过trampoline恢复内核镜像基址。
CEA喷洒与随机化绕过
CEA(CPU入口区)是一个per-CPU的x86结构,存放着入口和异常处理使用的栈和寄存器上下文:在6.2之前,CEA位于一个完全固定的地址,因此我们可以在一个已知的内核地址放置约120字节的连续受控内存。
在Zero项目组发表 Bringing back the stack attack writeup 后,内核开始强烈随机化CEA的虚拟地址(自6.2起)。但CEA的物理偏移是固定的,因此其直接映射别名取决于physmap基址。该直接映射地址可以用 prefetch 泄漏。
重用栈:用 PR_SET_MM_MAP 伪造waiter
悬空对象是waiter自己的栈 rt_mutex_waiter。
struct rt_mutex_waiter {
struct rt_waiter_node tree;
struct rt_waiter_node pi_tree;
struct task_struct *task;
struct rt_mutex_base *lock;
unsigned int wake_state;
struct ww_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] 栈缓冲区。
auxv的布局使得重叠的qword变为:
tree:一个rb节点,形状使得擦除它会将一个选定的子指针提升为树根task:设置为&init_task,一个有效的task_struct,使链遍历的任务解引用安全lock:设置为&inet6_protos[IPPROTO_UDP] - 8,写入目标wake_state:设置为0
auxv由memfd支持,定位使得复制跨越页边界。一个兄弟线程在 prctl 期间竞态执行 fallocate(PUNCH_HOLE) 在尾随页上,这扩展了 copy_from_user 窗口。
clone/setsockopt/pselect/keyctl和其他带有大受控栈局部变量的系统调用也可以做同样的事情。prctl在这里很方便——缓冲区大、对齐且不需要命名空间。
从伪造waiter到一次受控(受限)写入
控制waiter并不能给出任意写入。链遍历只做:
task->pi_blocked_on -> 伪造的waiter
伪造waiter->lock -> 伪造的 rt_mutex_base
rt_mutex_dequeue(lock, waiter) // 在lock->waiters上rb_erase
rt_mutex_dequeue() 是一个rb树擦除,擦除单子根会将该子指针写入根槽。将 lock 指向 target - 8 使 rt_mutex_base 字段与目标指针周围的数据对齐。
target - 8 -> raw_spinlock_t wait_lock (必须读作"未锁定")
target -> waiters.rb_root.rb_node (此槽被写入)
target + 8 -> waiters.rb_leftmost
target + 16 -> owner
写原语本身是一个单一受约束的存储:(uint64_t )target = W0_BASE。
约束也非常严格:目标前的qword必须读作未锁定的自旋锁,即低4字节为零,否则trylock失败且遍历退出不写入。
这里的 W0_BASE 必须指向一个在比较和后续同一次 rt_mutex_adjust_prio_chain() 中的无owner唤醒期间保持有效的对象。我们将其指向CPU入口区的直接映射别名,这有双重收益:
- 写入前:CEA是位于已知地址的受控内存,因此我们可以在
W0伪造一个自洽的假waiter和lock,度过遍历 - 写入后:目标现在指向CEA。一旦遍历结束,
W0不再需要看起来像waiter,因此我们可以重新喷洒CEA
下图展示了CPU入口区如何被用于:首先存放伪造的 rt_mutex_waiter 和 lock 结构,然后同时服务 inet6、ROP栈和JOP gadgets用于栈pivot,最后使用一个非常短的ROP执行DirtyMode并安全地停止核心。

使用 inet6_protos[IPPROTO_UDP]
从现在开始漏洞路径会因目标而异。对于常规x86_64 Linux内核,我们可以选择一个更短的路径——只需覆写某个函数表(或任何包含函数表的对象),因为我们已经泄漏了KASLR并准备好获得CFH。
对可写数据的扫描发现了许多指针表,其邻居满足上述布局。inet6_protos[IPPROTO_UDP] 是一个很好的选择。邻居免费满足,触发是一个trivial的无特权环回数据包。
inet6_protos[16] == NULL // 伪造 wait_lock -> 未锁定
inet6_protos[17] == &udpv6_protocol // <- 目标 (IPPROTO_UDP)
inet6_protos[18] == NULL // 伪造 rb_leftmost
inet6_protos[19] == NULL // 伪造 owner
写入后,inet6_protos[IPPROTO_UDP] 指向CEA页面,内核期望在那里找到一个 inet6_protocol。
struct inet6_protocol {
int (*handler)(struct sk_buff *skb);
int (*err_handler)(...);
unsigned int flags;
};
一旦我们发送一个环回IPv6 UDP(connect 然后 write 到 ::1),内核将解引用 handler 并给我们PC控制。
Pivot与DirtyMode
我们使用同一个紧凑的CEA窗口来存放多个对象:{伪造的 inet6_protocol、几个JOP/pivot槽、最终的ROP栈}。在Google的lts-6.12.80内核目标上,我们没那么幸运找到一个好的单栈pivot目标,因此链多了一个加载/调用以将CEA地址放入 rbp,然后用 mov rsp, rbp; pop rbp; ret pivot。
ret2usr 或完整的 /proc/%P/fd/x 覆写需要约十个gadget qwords,太长了。因此我们使用 DirtyMode 作为最终漏洞阶段:一次写入,几乎是垃圾值,翻转一个权限位。之后,LPE可以完全在用户空间完成。
这里我们以 core_pattern sysctl的mode标志为目标:
static struct ctl_table coredump_sysctls[] = {
...
{ .procname = "core_pattern",
.data = core_pattern,
.maxlen = CORENAME_MAX_SIZE,
.mode = 0644,
.proc_handler = proc_dostring_coredump },
...
};
附录
完整漏洞利用代码可在我们的开源安全研究项目 CyberMeowfia 中找到:https://github.com/NebuSec/CyberMeowfia/tree/main/IonStack/CVE-2026-43499
更大的ROP或NPerm
kernelCTF是一场竞争,最短可靠的链获胜。NPerm支撑的内存可以在劫持后制作一个很好的大型假栈,也有更重的路径可以工作,包括 Lukas Maar 的 heap-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 rt_mutex_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);
不是从 waiter->task 读取任务,而是由调用者传入拥有任务(自阻塞路径上的 current,rt_mutex_start_proxy_lock() 回滚上的代理 task),并且仅当 pi_blocked_on 仍指向该waiter时才清除。
RANDOMIZE_KSTACK_OFFSET
栈重用步骤依赖于已释放的waiter帧和后来的 user_auxv 帧确定性重叠。开启 RANDOMIZE_KSTACK_OFFSET 后它们不再重叠,该步骤变成约1/32(5比特)栈偏移猜测。
STATIC_USERMODE_HELPER
STATIC_USERMODE_HELPER 将关闭这个特定的DirtyMode路径。但同样的思路可以推广到任何其 ctl_table::mode 控制访问且其表位于可预测可写内核数据中的 /proc/sys 旋钮。
时间线
- 2026-04-18:我们向 security@kernel.org 报告了bug并发送了补丁草稿
- 2026-04-20:Bug用另一个补丁修复
- 2026-05-04:修复v1被backport
- 2026-06-30:Google确认了我们的kernelCTF提交
- 2026-07-07:我们发布了此博文
原文出处:Nebula Security 原文链接:https://nebusec.ai/research/ionstack-part-2/ 漏洞编号:CVE-2026-43499(GhostLock) 图片来源:Nebula Security / GitHub (CyberMeowfia)
版权声明:本文由华盟网原创发布,保留所有权利。











暂无评论内容