GhostLock:一个在全部Linux发行版中存在15年的栈UAF漏洞

Hacker News Top 新闻

摘要

GhostLock(CVE-2026-43499)是一个存在15年的Linux内核栈释放后使用漏洞,影响所有发行版,可导致本地权限提升与容器逃逸。文中给出了详细的利用技术。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/11 00:14

# IonStack 第二部分:GhostLock——一个在 ALL Linux 发行版中存在了15年的栈UAF 来源:https://nebusec.ai/research/ionstack-part-2/ 1. https://nebusec.ai/ 2. 研究 (https://nebusec.ai/research) 3. IonStack 第二部分:GhostLock——一个在 ALL Linux 发行版中存在了15年的栈UAF > GhostLock (CVE-2026-43499) 是 VEGA (https://nebusec.ai/vega/) 发现的一个 Linux 内核漏洞,自 2011 年以来存在于所有主要发行版中。触发该漏洞无需任何特殊内核配置或权限。通过将其转化为 97% 稳定的权限提升和容器逃逸,谷歌在 kernelCTF 中奖励了我们 92,337 美元。本文详细介绍了该漏洞利用的技术细节。 ## 漏洞概述 GhostLock (CVE-2026-43499) 允许非特权本地攻击者: - 仅通过常规线程系统调用获取悬空的内核指针指向内核栈内存。 - 将一个指针写入几乎任意的地址。 - 劫持函数表以实现控制流劫持,最终获得 root 权限。 GhostLock 在 Linux 2.6.39 中引入,在 Linux 7.1 中修复。它在 Linux 内核中存在了超过 15 年。**每个未打补丁的 Linux 发行版**都受影响,应考虑升级到最新的 LTS 版本。 您的浏览器不支持视频标签。 ## 漏洞分析 ### 概述 GhostLock 是在 `8161239a8bcc` (https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=8161239a8bcc)(“rtmutex: Simplify PI algorithm and make highest prio task get lock”)中随着 rtmutex 重构引入的,然后在大约十五年间未被触及,直到 2026 年 4 月通过 `3bfdc63936dd` (https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=3bfdc63936dd4773109b7b8c280c0f3b5ae7d349)(“rtmutex: Use waiter::task instead of current in remove_waiter()”)修复。受影响范围是 `v2.6.39-rc1` 到 `v7.1-rc1`,唯一要求是 `CONFIG_FUTEX_PI=y`,且不需要任何特权或用户命名空间。 `kernel/locking/rtmutex.c` 中的 `remove_waiter()` 会清除 `current->pi_blocked_on`。在正常的慢路径中这是正确的,因为 `current` 是拥有该 waiter 的任务。但在代理路径中则是错误的。`rt_mutex_start_proxy_lock()` 会代表另一个任务将 `rt_mutex_waiter` 入队,并在出错时回滚,此时 `current` 是重启者而非 waiter。该 waiter 对象位于在 `FUTEX_WAIT_REQUEUE_PI` 中休眠的任务栈上。然后 `FUTEX_CMP_REQUEUE_PI` 将该 waiter 代理到目标 PI futex 上。当 rtmutex 链遍历报告死锁时,回滚会将 waiter 从锁中出队,但会清除重启者的 `pi_blocked_on`。waiter 任务的 `pi_blocked_on` 仍指向其自身的栈帧,而该栈帧在 waiter 返回到用户空间时就会被弹出。此后任何经过该任务的 PI 链遍历都将跟随悬空指针。 ### 根本原因 这与许多其他生命周期漏洞具有相同的形式:一个函数被一个从未为其编写的调用者重用。辅助函数 `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()` 回滚,这是一个被误用的辅助函数。 ```c 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()` 清除了错误的线程。 ```c 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`,waiter 用 `FUTEX_WAIT_REQUEUE_PI` 阻塞的普通 futex。 顺序如下: 1. waiter 获取 `f_pi_chain`,然后在 `FUTEX_WAIT_REQUEUE_PI(f_wait -> f_pi_target)` 中阻塞。其 `rt_mutex_waiter` 现在在其栈上。 2. owner 获取 `f_pi_target`,然后在 `f_pi_chain` 上阻塞,而 waiter 持有 `f_pi_chain`。 3. 主线程调用 `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`。这里唯一重要的是:重启者在 waiter 仍然拥有即将被释放的对象时回滚 waiter,而一旦循环准备好,这就会自动发生。解析后就没有时间压力了。waiter 在用户空间中保持悬空的 `pi_blocked_on`,而后续遍历链的 `sched_setattr()` 可以随时触发。UAF 窗口非常宽。 问题在于释放的对象位于内核栈上(如果我们对从 futex 系统调用 `ret` 称为“释放”,那就是栈-UAF)。要回收它,我们需要找到一个系统调用,能够将受控字节放回同一栈的相同深度(偏移)处。 ### 触发栈-UAF 构建三个 futex 的循环后,waiter 任务返回用户空间,其 `pi_blocked_on` 悬空指向旧的 `FUTEX_WAIT_REQUEUE_PI` 帧。之后的一切都依赖于这一个指针。 > 注意,三个线程是为了更好地理解。要赢得竞态并触发 UAF,你只需要一个 CPU 核心。 #### GhostLock 的初始原语 到这一步,我们持有一个指向已释放内核栈的指针,并且我们可以随意触发一个内核访问,该访问将这个指针解引用为 `rt_mutex_waiter`。我们可以将受控字节喷洒到该栈上,并直接伪造 `rt_mutex_waiter`。根据我们伪造的形状,这次访问会提供几个原语,主要有两个: - 向任意(但有限制的)地址写入一个指针。 - 向任意(但有限制的)地址写入 8 个零字节。 在原语触发之前会经过几个指针解引用和完整性检查,触发后内核正常返回,不会崩溃。因此我们的主要问题是(每个问题在下面的章节中回答): - 如何回收已释放的栈内存(喷洒)? -> 重用栈 (https://nebusec.ai/research/ionstack-part-2/#reusing-the-stack-forging-the-waiter-with-pr_set_mm_map) - 如何让伪造的 `rt_mutex_waiter` 通过其内建的结构检查,并伪造出可读的指针? -> 从伪造的 waiter 到一次写入 (https://nebusec.ai/research/ionstack-part-2/#from-fake-waiter-to-one-controlled-limited-write) - 使用哪个写入原语,以及向哪里写入什么?该原语对“任意”地址施加了什么限制? -> 使用 inet6_protos (https://nebusec.ai/research/ionstack-part-2/#use-inet6_protosipproto_udp-to-help) ## 利用细节 ### 利用总结 - **prefetch** -> 泄露内核镜像偏移和 physmap 基址。 - **GhostLock** -> 在 waiter 任务的 `pi_blocked_on` 中留下一个悬空的 `rt_mutex_waiter`。 - **(栈-)UAF 回收** -> 使用 `PR_SET_MM_MAP` 回收 waiter 自身的内核栈,并在释放的帧上伪造一个 `rt_mutex_waiter`。 - **任意地址写入器** -> Rtmutex 红黑树擦除:一次受限的指针写入(我们可以回收其内容),覆盖包含函数表的结构体:`inet6_protos[IPPROTO_UDP] = `。 - **CPU entry area** -> 将 {伪造的 `inet6_protocol`,枢轴槽,ROP 栈} 全部放在一个已知的 direct-map 地址上。 - **触发 CFH** -> 触发一个环回 IPv6 UDP 包,通过覆盖的处理程序和枢轴进行调用。 - **DirtyMode** -> 一次写入翻转 `core_pattern` 的模式位,然后剩下的 LPE 完全是用户空间。 Android 呢? 这部分我们专注于通用 x86 Linux 系统的基本利用步骤,下一篇博客将讨论如何在 Android 上利用 GhostLock、回收栈帧、绕过 ASLR 和 CFI。 ### 使用的技巧背景 #### Prefetch ASLR 泄露 对某个地址进行 `prefetch` 会根据该地址在当前页表中是否映射而运行不同的周期数,因此非特权进程可以对内核范围进行 `prefetch` 计时,并读出哪些地址被映射(prefetch 论文 (https://gruss.cc/files/prefetch.pdf) 有详细信息)。这里之所以有效,是因为 Linux 几乎不随机化其默认内核镜像的基址(文本基址约 9 位熵),所以一点平均就能以近乎 100% 的可靠性恢复 KASLR 基址。理论上任何具有 `prefetch` 且没有适当内核页表隔离的 CPU 都会受影响。但在实践中,它更是一种 x86 技术(除非 ARM 目标关闭了 KPTI)。kernelCTF 镜像保持 KPTI 关闭。 > kernelCTF 镜像保持 KPTI 关闭,但即使 KPTI 开启,结合 `prefetch` 和 EntryBleed (https://www.willsroot.io/2022/12/entrybleed.html) 仍然可以通过跳板恢复内核镜像基址。 #### CEA 喷洒和随机化绕过 CEA(CPU entry area)是一个 per-CPU 的 x86 结构,包含用于入口和异常处理的栈和寄存器上下文:在异常、中断或系统调用时,CPU 切换到 CEA 中的栈,入口代码将寄存器帧(`pt_regs`)保存到那里。非特权用户程序可以触发一个软件异常,将其自身的寄存器上下文写入 CEA 异常栈上的 `pt_regs` 中。在 `6.2` 之前,CEA 位于完全固定的地址,因此我们可以在已知的内核地址放置约 120 字节连续的受控内存,这对于伪造结构、吸收沿途指针解引用的副作用以及构建 ROP 栈非常方便。在 Project Zero 的 Bringing back the stack attack (https://googleprojectzero.blogspot.com/2022/12/exploiting-CVE-2022-42703-bringing-back-the-stack-attack.html) 文章之后,内核开始强烈随机化 CEA 的虚拟地址(自 `6.2` 起)。但 CPU entry area 的虚拟地址从未被需要,因为 CEA 的物理偏移是固定的,所以其 direct-map 别名可以从 physmap 基址推导出来(@kqx (https://kqx.io/writeups/zenerational/) 使用了相同的观察)。该 direct-map 地址很容易通过 `prefetch` 泄露,加上候选边缘归一化和对预测 CEA 页的检查以拒绝相邻别名。(direct-map 泄露比文本泄露噪音更大,可能需要更多微调,但最终在目标上非常准确。)因此我们总是可以计算出 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`。 ```c struct rt_mutex_waiter { struct rt_waiter_node tree; // rb node, lives in lock->waiters 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]` 栈缓冲区中。该缓冲区大致位于与释放的 waiter 相同的栈深度,因此它是一个大的、自然对齐的、无命名空间的受控 qword 块,正好落在旧对象的上面。auxv 被布置成重叠的 qword 成为: - `tree`,一个 rb 节点,形状使得擦除它时将选定的子指针(下面的 `W0_BASE`)提升到树根。 - `task`,设置为 `&init_task`,一个有效的 `task_struct`,以便链遍历的任务解引用安全。 - `lock`,设置为 `&inet6_protos[IPPROTO_UDP] - 8`,即写入目标。 - `wake_state`,设置为 `0`。 auxv 由 memfd 支持,并定位在跨越页边界的位置。一个兄弟线程在 `prctl` 期间对尾页竞赛执行 `fallocate(PUNCH_HOLE)`,这会拉伸 `copy_from_user` 窗口。伪造的 waiter 停留在栈上,同时在另一个 CPU 上,一个消费者线程对 waiter 触发 `sched_setattr()` 来遍历 PI 链。竞态窗口很宽,我们相信 GhostLock 在单核 CPU 上也同样可利用。 > `clone`/`setsockopt`/`pselect`/`keyctl` 以及其他具有大型受控栈局部变量的系统调用以相同方式工作。这里 `prctl` 只是方便。缓冲区大、对齐,且不需要命名空间。这里还有一些更有用的系统调用,可以在我们的开源 PoC 代码 (https://github.com/NebuSec/CyberMeowfia/blob/main/IonStack/CVE-2026-43499/poc/poc.c) 中回收栈帧。 ### 从伪造的 waiter 到一次受控(有限)写入 控制 waiter 并不提供任意写入。链遍历只做: ``` task->pi_blocked_on -> fake waiter fake waiter->lock -> fake rt_mutex_base rt_mutex_dequeue(lock, waiter) // rb_erase on lock->waiters ``` `rt_mutex_dequeue()` 是一个红黑树擦除,擦除一个单子根会将那个子节点写入根槽。将 `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 ``` 伪造的 waiter 的 rb 节点形状使得擦除只向 `rb_root.rb_node` 写入一个子指针。写入原语本身是一个受限的存储:`*(uint64_t *)target = W0_BASE`。 **这些限制也相当严格**:目标之前的 qword 必须读为未锁定的自旋锁,即低 4 字节为零,否则 trylock 失败,遍历退出而不写入。目标之后的 qword(`rb_leftmost`, `owner`)不能将遍历引导到不受控制的顶部 waiter 或 owner。那里一个未映射的值会触发错误并且使系统崩溃。等效的目标地址约束大致如下(*target 将被写入一个指针): ``` *(u32 *)(target - 0x08) == 0 *(u64 *)(target + 0x08) == 0 // simplified ((*(u64 *)(target + 0x10)) & ~1ULL) == 0 // 然后我们可以做: *(u64 *)t ar

相似文章

CVE-2026-31431: Copy Fail

Lobsters Hottest

CVE-2026-31431(Copy Fail)是Linux内核中的一个本地提权漏洞,影响自2017年以来的所有主流发行版,允许非特权用户通过AF_ALG加密子系统对任何可读文件的页缓存进行确定性的4字节写入,从而获得root shell访问权限。

Dirtyfrag:通用 Linux 本地权限提升漏洞

Hacker News Top

一份名为“Dirty Frag”的报告详细描述了一种通用的 Linux 本地权限提升(LPE)漏洞。该漏洞通过串联两个内核错误,可在主要发行版上获取 root 访问权限。披露信息指出,由于保密期失效,目前尚无针对此关键安全问题的补丁。