GhostLock:潜伏 Linux 内核 15 年的 stack-UAF

GhostLock:潜伏 Linux 内核 15 年的 stack-UAF

securitylinuxkernelvulnerability

数据源:HN + web research · HN

Nebula Security 的研究人员最近把一道老题的答案摊开:Linux 内核里有一个从 2011 年活到现在的栈上悬垂指针(stack-UAF)。他们把漏洞取名为 GhostLock(CVE-2026-43499),并以 97% 稳定率的利用链拿到 Google kernelCTF 的 $92,337 奖金。影响范围是 v2.6.39-rc1 到 v7.1-rc1,也就是几乎所有主流发行版都默认带过的代码。

问题出在 rtmutex 的优先级继承(PI, Priority Inheritance)路径。这个机制本身并不新:当高优先级任务被低优先级任务持有的 futex 阻塞时,内核会把低优先级任务的优先级临时提升,避免优先级反转。为了支持 FUTEX_WAIT_REQUEUE_PI 这种「先等在原 futex,再被重新排队到目标 PI futex」的语义,内核需要代理上锁:一个线程可以在另一个线程睡着的时候,替它把 rt_mutex_waiter 对象挂到目标锁的等待队列上。这个 waiter 对象就躺在被代理线程的栈帧里。

remove_waiter() 这个辅助函数的设计假设,是为「自己阻塞、自己清理」写的。它内部清理的是 current->pi_blocked_on,也就是正在运行线程的 PI 阻塞指针。当代理路径调用它来回滚一个失败的上锁尝试时,current 其实是执行 FUTEX_CMP_REQUEUE_PI 的那个线程,而不是栈上那个 waiter 所属的线程。于是 pi_blocked_on 没有被清掉正确的任务。

等 waiter 线程从 futex 系统调用返回用户态,那块栈帧就消失了;但内核仍然保留着一个指向该栈地址的 pi_blocked_on。下一次 sched_setattr() 之类的路径做 PI 链遍历时,内核会把这个已经「属于用户态」的栈内存当成有效的 rt_mutex_waiter 来解引用。悬垂指针就这样诞生了。

触发这个 bug 需要构造一个 PI 依赖环:三个 futex 字、三个线程、一条 FUTEX_CMP_REQUEUE_PI。具体步骤是:

  • 线程 A 先拿到 f_pi_chain,然后进入 FUTEX_WAIT_REQUEUE_PI(f_wait -> f_pi_target),把 waiter 留在自己的栈上。
  • 线程 B 先拿到 f_pi_target,再阻塞到 f_pi_chain
  • 主线程调用 FUTEX_CMP_REQUEUE_PI(f_wait -> f_pi_target),试图把 A 的 waiter 代理到 f_pi_target

此时链遍历会看到一个环:A -> f_pi_target -> B -> f_pi_chain -> A,于是返回 -EDEADLK 并进入回滚。回滚调用了错误的 remove_waiter(),把 A 的 pi_blocked_on 留在已经释放的栈帧上。A 返回用户态后,UAF 窗口就一直开着,后续任何一次 PI 链遍历都可以把它当自由引用来用。

拿到这个指针后,攻击者需要重新占领同一块栈地址。Nebula 用的是 prctl(PR_SET_MM, PR_SET_MM_MAP, ...):这个调用会把用户提供的 auxv 复制到内核栈上的一个固定大小缓冲区 user_auxv[AT_VECTOR_SIZE],位置刚好和刚才的 rt_mutex_waiter 框架重叠。辅助向量里的内容被精心布置成一段假的 waiter 结构,骗过内核的 rt_mutex_dequeue(),最终把目标地址写成一个指向 CPU Entry Area(CEA)的指针。

CEA 的双重用途:在 104 字节内叠放伪造的 waiter、锁、inet6_protocol 和 ROP 栈 图:Nebula Security 展示的 CEA 复用布局。同一段可控内存先伪造 waiter 和 lock 通过 PI 链检查,再重新喷射为伪造的 inet6_protocol 与 ROP 栈。来源:nebusec.ai/research/ionstack-part-2/

他们选择的目标是 inet6_protos[IPPROTO_UDP]。这个全局函数表指针周围的数据布局恰好满足 rt_mutex_base 的结构要求:前一个 qword 读作未锁的 wait_lock,后面可以伪造 rb_leftmostowner。写入之后,只要发一个本地 IPv6 UDP 包,内核就会调用这个被覆盖的函数指针,控制流被劫持到 CEA。

从 CEA 再往后走,是一段短 ROP。他们没有用传统的大段 ROP 去重开用户态,而是用了一个叫 DirtyMode 的收尾:把 coredump_sysctls[1].mode 的权限位改成可写。之后,/proc/sys/kernel/core_pattern 对普通用户开放,写一个带管道的 core pattern 字符串就能让内核以 root 执行任意二进制文件。整个利用链在 kernelCTF 的远程 LTS 6.12.80 目标上大约 5 秒跑完。

这个漏洞能稳定利用的另一个前提是 RANDOMIZE_KSTACK_OFFSET 默认关闭。如果打开,内核栈入口会加一个 5 位的随机偏移,之前那个 auxv 缓冲区就不一定再覆盖到原来的 waiter 框架上,利用会变成 1/32 的猜测。Nebula 也提到,一些发行版如果启用 STATIC_USERMODE_HELPER,会堵住 DirtyMode 这个具体路径,但同样的思路可以换到任何 ctl_table::mode 保护的可写 sysctl 节点上。

补丁的核心改动很小:在 remove_waiter() 里从 waiter->task 而不是 current 获取目标任务,再用 scoped guard 锁住正确的 pi_lock。15 年来没人发现这个错位,说明问题出在代理路径复用了一个隐含「当前线程即 waiter」的清理函数。这类「老函数遇到新调用场景」的 bug 在大型内核代码里并不罕见。

这件事也提醒我们:安全补丁的回溯范围往往比想象的更大。从 2.6.39 到 7.1 的跨度覆盖了几乎整个现代 Linux 生态,大量长期支持版本仍在运行。Nebula 已经在 4 月向 kernel security 提交了报告,修复在 4 月 20 日进入主线,5 月 4 日开始被 backport。如果你还在跑一个没有被 backport 覆盖的内核,这个提权/容器逃逸路径就还在那儿。

参考链接: