IonStack系列二:GhostLock——一个存在于所有Linux发行版中长达15年的栈UAF漏洞

导语: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-rc1v7.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(&current->pi_lock);
  rt_mutex_dequeue(lock, waiter);
  current->pi_blocked_on = NULL;            // 应该是 waiter->task
  raw_spin_unlock(&current->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 在其上阻塞

序列如下:

  1. waiter 获取 f_pi_chain,然后在 FUTEX_WAIT_REQUEUE_PI(f_wait -> f_pi_target) 中阻塞。其 rt_mutex_waiter 现在位于其栈上。
  2. owner 获取 f_pi_target,然后在 waiter 持有的 f_pi_chain 上阻塞。
  3. 主线程调用 FUTEX_CMP_REQUEUE_PI(f_wait -> f_pi_target)
竞争时序图——三线程futex死锁触发GhostLock UAF的过程

重新排队试图将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_waiterlock 结构,然后同时服务 inet6、ROP栈和JOP gadgets用于栈pivot,最后使用一个非常短的ROP执行DirtyMode并安全地停止核心。

CPU入口区双用图——104字节内重叠三种数据结构

使用 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(&current->pi_lock);
-  rt_mutex_dequeue(lock, waiter);
-  current->pi_blocked_on = NULL;
-  raw_spin_unlock(&current->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 读取任务,而是由调用者传入拥有任务(自阻塞路径上的 currentrt_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)

版权声明:本文由华盟网原创发布,保留所有权利。

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容