GhostLock:一个在全部Linux发行版中存在15年的栈UAF漏洞
摘要
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
相似文章
X AI KOLs Following
GhostLock (CVE-2026-43499) 是一个15年历史的Linux内核0day漏洞,被用于IonStack全链利用,影响从物联网到桌面设备的所有Linux设备。Nebu Security赢得了92,337美元的漏洞赏金,并在GitHub上发布了利用代码。
Wired
来自Nebula Security的AI工具VEGA发现了Linux内核中一个存在15年之久的释放后使用漏洞(GhostLock),该漏洞允许任何登录用户获得root权限。该漏洞自2011年存在,已于4月修补,但部署进度不均衡。
Lobsters Hottest
CVE-2026-31431(Copy Fail)是Linux内核中的一个本地提权漏洞,影响自2017年以来的所有主流发行版,允许非特权用户通过AF_ALG加密子系统对任何可读文件的页缓存进行确定性的4字节写入,从而获得root shell访问权限。
Lobsters Hottest
本文详细介绍了在网络调度子系统(red调度器)中发现并利用的一个Linux内核零日漏洞,将一个受限的slab释放后使用(UAF)转化为完全的物理内存读写,最终实现root权限提升。该漏洞存在了2.5年,于2026年6月被修复。
Hacker News Top
一份名为“Dirty Frag”的报告详细描述了一种通用的 Linux 本地权限提升(LPE)漏洞。该漏洞通过串联两个内核错误,可在主要发行版上获取 root 访问权限。披露信息指出,由于保密期失效,目前尚无针对此关键安全问题的补丁。