文章总结: NebulaSecurity团队公开了全球首个Android17远程临时root演示项目IonStack,攻击者仅需诱导受害者点击恶意URL即可获取临时root权限。该利用链包含两枚0day漏洞:一枚影响Firefox151.0.2之前版本的浏览器漏洞,另一枚是潜伏Linux内核15年的通用漏洞GhostLock(CVE-2026-43499)。GhostLock本质是stack-UAF漏洞,通过构造死锁循环触发,最终实现权限提升。利用过程包括Prefetch侧信道泄露KASLR、CEA喷射绕过地址随机化、栈复用伪造结构体、受限任意写、覆盖inet6协议函数表劫持控制流,最后通过DirtyMode提权。所有操作在内存中完成,设备重启后权限失效。建议及时更新Firefox和Linux内核以修复漏洞。 综合评分: 85 文章分类: 漏洞分析,红队,内网渗透,渗透测试
安卓17 IonStack全链路0day漏洞利用链
原创
黑鸟 黑鸟
黑鸟
2026年7月9日 23:42 广东
在小说阅读器读本章
去阅读
#
Nebula Security 近期正式获得 Y Combinator 投资,团队以一份震撼性的安全研究成果完成亮相。他们公开了全球首个 Android 17 远程临时 root 演示项目 IonStack,攻击者仅需诱导受害者点击一条恶意 URL,就能在手机运行时获取临时 root 权限,实现对设备的短时完全控制,权限会随设备重启失效。
这不是普通的本地提权演示,而是一条完整的浏览器到内核全链路利用链,全程包含两枚 0day 漏洞。一枚影响 151.0.2 版本之前的 Firefox 浏览器,可通过网页触发代码执行,另一枚是潜伏在 Linux 内核中长达 15 年的通用漏洞 GhostLock,覆盖几乎所有主流 Linux 发行版。IonStack 将两枚漏洞串联打通,完整复现了从用户点击链接到最终拿到内核态临时 root 权限的完整入侵路径,直观展现了这类漏洞的实际攻击威力。值得庆幸的是,两枚漏洞都由 Nebula Security 在被黑产恶意利用前率先发现并推动了修复。
需要特别说明的是,本次演示实现的是临时 root 效果,所有提权操作都在内存中完成,不会修改系统分区与持久化配置,不会刷入修改版系统,也不会留下重启后依然有效的后门,设备一旦重启权限就会完全失效。
全链路攻击的核心:GhostLock 内核漏洞
整条利用链中,最核心也最具破坏力的是编号 CVE-2026-43499 的内核漏洞 GhostLock。这处漏洞随 2011 年的 rtmutex 重构引入,在 Linux 内核中潜伏超过 15 年,从 2.6.39 到 7.1 的所有主线版本全部受影响,主流 Linux 发行版无一幸免。
攻击者无需特殊内核配置,也不需要任何高权限或命名空间能力,仅以普通本地用户身份即可触发漏洞,最终实现稳定的权限提升与容器逃逸。在谷歌 kernelCTF 竞赛中,该漏洞的利用方案达成 97% 的稳定性,为团队赢得了 92337 美元的奖金。
漏洞核心影响
GhostLock 本质是一处 stack-UAF(栈上释放后使用漏洞),攻击者可以通过常规的线程系统调用拿到指向内核栈内存的悬垂指针,进一步构造出受限的任意地址写能力,最终劫持函数表完成控制流劫持拿到 root 权限。
漏洞伴随 2011 年 rtmutex 优先级继承算法重构引入,直到 2026 年 4 月才被修复,触发条件仅需要内核开启 CONFIG_FUTEX_PI 配置,而这是几乎所有发行版的默认选项,全程不需要任何特殊权限或者用户命名空间支持。
漏洞根因分析
被复用的清理函数搞错了清理对象
要理解这个漏洞,先要知道两个基础概念。 第一个是 rtmutex,也就是内核实时互斥锁,用来在内核中实现带优先级继承的互斥同步,避免高优先级任务被低优先级任务长时间阻塞。 第二个是 FUTEX_REQUEUE_PI,这是 futex 系统调用的一个功能,允许把一个线程从等待普通 futex 的队列,重定向到等待一个 PI futex 的队列,这个过程由另一个线程代理完成,也就是代理加锁。
问题出在清理等待者的函数 remove_waiter 上。这个函数最初设计的场景非常单一,就是线程自己阻塞在锁上,等解锁后清理自己身上的等待标记。所以函数里默认认为当前运行的线程 current 就是等待者,清理时直接把 current->pi_blocked_on 置空,标记当前线程不再阻塞在任何 PI 锁上。
但当 Requeue-PI 功能引入后,这个函数被复用到了代理加锁的回滚路径里。此时发起重入队操作的线程是 current,而真正的等待者是另一个正在睡眠的线程。当代理加锁过程中检测到死锁返回 – EDEADLK 错误时,代码会调用 remove_waiter 回滚操作,这时候函数就清错了对象。它把发起重入队线程的 pi_blocked_on 清空了,却没有清空真正等待者的 pi_blocked_on。
而那个真正的等待者,它的 rt_mutex_waiter 结构体本身就分配在自己的内核栈上。等等待线程从 futex 系统调用返回用户态,对应的栈帧会被释放回收,可它的 pi_blocked_on 还指向这块已经释放的栈内存。后续只要有操作遍历这个线程的 PI 优先级链,就会顺着这个悬垂指针访问已经释放的栈空间,栈 UAF 就此触发。
这个问题能躲过 lockdep 检查,因为 lockep 只校验有没有持有 pi_lock,并不会校验持有的是不是对应线程的 pi_lock。
构造死锁循环触发漏洞路径
要触发那条有问题的回滚路径,需要构造一个 PI 锁依赖的闭环,总共用到三个 futex 对象和三个线程。 第一个是 f_pi_chain,PI 类型 futex,先由等待线程持有。 第二个是 f_pi_target,PI 类型 futex,先由持有者线程持有,这是重入队操作的目标锁。 第三个是 f_wait,普通 futex,等待线程会调用 FUTEX_WAIT_REQUEUE_PI 阻塞在这个 futex 上,等待被重定向到目标锁。
完整的触发流程是: 等待线程先持有 f_pi_chain,然后调用 FUTEX_WAIT_REQUEUE_PI 阻塞在 f_wait 上,此时它的栈上已经生成了 rt_mutex_waiter 结构体。 持有者线程先持有 f_pi_target,然后尝试获取 f_pi_chain,因为等待线程正持有这个锁,所以持有者线程会阻塞。 最后主线程调用 FUTEX_CMP_REQUEUE_PI,尝试把等待线程从 f_wait 重入队到 f_pi_target 上。
此时内核沿着依赖链检查会发现闭环,等待线程等 f_pi_target,持有者线程等 f_pi_chain,而 f_pi_chain 在等待线程手里,形成死锁循环,于是返回 – EDEADLK 并执行回滚。回滚过程就会触发前面提到的逻辑错误,等待线程醒来后,pi_blocked_on 依然指向自己即将释放的栈帧。
这个过程没有严格的时间竞态要求,只要把依赖环搭建好,回滚就会自然发生。等待线程回到用户态后,悬垂指针会一直保留,后续随时可以触发 PI 链遍历来触发 UAF,利用窗口非常宽,哪怕单 CPU 核心也能稳定触发。
初始利用能力:从悬垂指针到可控写
触发漏洞后,我们手里有一个指向已释放内核栈的悬垂指针,还能随时触发内核把这个指针当成 rt_mutex_waiter 结构体来访问。接下来只要想办法往这块栈内存重新填入可控数据,就能伪造一个完全受控的 rt_mutex_waiter 结构体。 根据伪造结构的不同,最终可以得到两种基础利用原语,向受限的任意地址写入一个指针,或者向受限的任意地址写入 8 字节零。整个过程内核会正常返回不会崩溃,隐蔽性很强。
完整内核利用链详解
第一步:Prefetch 侧信道泄露 KASLR
现代 Linux 内核都会开启 KASLR(内核地址空间布局随机化),内核代码和数据的加载地址是随机的,要利用就必须先拿到基地址偏移。
这里用到了 prefetch 指令侧信道技巧。CPU 的 prefetch 指令会预取指定地址的数据到缓存,如果地址在当前页表中是映射状态,预取耗时会更短,反之则更长。攻击者可以遍历内核地址范围,逐个测量 prefetch 的耗时,就能判断出哪些地址是真实映射的,从而推算出内核镜像的基地址。
Linux 内核镜像本身的随机化熵只有大约 9 位,通过多次采样平均就能以接近 100% 的准确率还原 KASLR 偏移。这个技巧在 x86 平台上效果最好,即便开启 KPTI 也就是内核页表隔离,配合 EntryBleed 技巧依然可以通过跳板页泄露内核基址。
第二步:CEA 喷射绕过地址随机化
CEA 全称 CPU entry area 也就是 CPU 入口区域,是 x86 架构上每个 CPU 独有的内存区域,用来存放系统调用异常中断的入口栈和寄存器上下文。当用户态触发软中断或者异常时,CPU 会切换到 CEA 里的栈,把寄存器上下文也就是 pt_regs 保存到栈上。也就是说,普通用户程序可以主动触发异常,把可控的寄存器数据写入 CEA 的异常栈里。
在 Linux6.2 版本之前,CEA 的虚拟地址是完全固定的,攻击者可以直接在已知内核地址上放置大约 120 字节的连续可控数据,非常适合伪造结构体和搭建 ROP 栈。6.2 版本之后内核加入了 CEA 虚拟地址随机化,但 CEA 的物理地址是固定的,只要拿到 physmap 也就是内核直接物理映射的基地址,就能通过物理偏移算出 CEA 在直接映射里的地址,同样可以稳定使用。
physmap 基地址同样可以通过 prefetch 侧信道泄露,只是噪声稍大,需要更多次校准,最终依然可以达到极高的准确率。
第三步:栈复用伪造等待者结构体
悬垂的 waiter 结构体在等待线程自己的内核栈上,我们需要找一个系统调用,能在同一个栈深度放下可控数据,覆盖掉已经释放的 waiter 结构体。
这里选用 prctl 的 PR_SET_MM_MAP 功能。这个功能在内核里的实现会把用户提供的 auxv 数组,拷贝到内核栈上一个固定大小的 unsigned long user_auxv 缓冲区里。这个缓冲区的栈深度和之前释放的 waiter 结构体几乎一致,刚好可以用可控的 auxv 数据完整覆盖旧的栈帧。
我们可以提前排布 auxv 里的数据,让覆盖后的结构体各个字段满足要求。tree 字段构造特殊的红黑树节点形态,确保删除操作会把指定指针写入目标位置。task 字段设置为 init_task 也就是系统初始进程的地址,保证后续任务指针解引用安全。lock 字段指向我们想要写入的目标地址减 8 的位置,对齐结构体布局。wake_state 字段置零。
为了扩大利用窗口,还可以让 auxv 数据跨页存放,再用另一个线程在拷贝过程中调用 fallocate 的 PUNCH_HOLE 释放后面的页面,拉长 copy_from_user 的执行时间,给触发 PI 链遍历留出充足时间。 除了 prctl 之外,clone setsockopt pselect keyctl 等带有大尺寸可控栈变量的系统调用都可以实现同样效果,prctl 只是最方便无需命名空间的选项。
第四步:从伪造结构体到受限任意写
控制了 waiter 结构体并不等于直接获得任意写能力。PI 链遍历过程只会执行 rt_mutex_dequeue 操作,也就是把 waiter 从锁的等待红黑树里删除。红黑树删除根节点且节点只有一个子节点时,会把子节点指针直接写入树的根节点位置。
我们把 fake lock 的地址设置为目标地址减 8,刚好让锁的 waiters.rb_root.rb_node 字段对齐到目标地址。只要构造好红黑树节点形态,删除操作就会执行一次写操作,把我们指定的指针 W0_BASE 写入目标地址。
这个写原语有严格的约束条件。目标地址往前 8 字节的位置,必须读取起来像一个未加锁的自旋锁,也就是低 4 字节为 0,否则自旋锁加锁失败,遍历直接退出不会执行写入。目标地址往后的字段,也就是对应 rb_leftmost 和 owner 的位置,不能指向非法地址,否则会触发内核崩溃。简化下来满足这几个条件就可以完成写入: 目标地址前 8 字节的低 32 位为 0 目标地址后 8 字节为 0 目标地址后 16 字节除了最低位其余为 0
我们把 W0_BASE 指向 CEA 的直接映射地址。写入之前 CEA 里已经放好了伪造的 waiter 和 lock 结构体,可以通过所有合法性检查。写入完成后,目标地址就指向了 CEA 这片可控内存,接下来我们可以重新往 CEA 里填充数据,把它改造成目标对象需要的形态,比如函数表结构体。
第五步:覆盖 inet6 协议函数表劫持控制流
满足写入约束的内核对象有很多,inet6_protos [IPPROTO_UDP] 是其中非常好用的一个目标。这是 IPv6 UDP 协议的处理函数表指针,它的内存布局刚好完美匹配写入约束。 它前面的 inet6_protos [16] 为 NULL,刚好对应 fake lock 的 wait_lock 字段,读取起来就是未加锁状态 它本身是我们要写入的目标,对应 rb_root.rb_node 字段 它后面的 inet6_protos [18] 和 inet6_protos [19] 都为 NULL,刚好对应 rb_leftmost 和 owner 字段
写入完成后,inet6_protos [IPPROTO_UDP] 就指向了 CEA 内存。我们在 CEA 里伪造一个 inet6_protocol 结构体,把 handler 字段设置为我们的跳板 gadget 地址,flags 字段设置为合法的协议标志。之后只要往本地环回地址::1 发送一个 IPv6 UDP 数据包,内核就会调用我们伪造的 handler 函数,完成控制流劫持。
第六步:栈 pivot 与 DirtyMode 提权
CEA 里可控的空间大约只有 120 字节,放不下完整的长 ROP 链。这里采用了更简洁的 DirtyMode 方案,不需要复杂的提权 ROP,只需要一次内存写操作修改一个权限位,后续提权全部在用户态完成。
具体目标是 core_pattern 对应的 sysctl 表项的 mode 字段。core_pattern 是控制内核 coredump 文件格式的配置,默认权限是 0644 只有 root 可写。我们通过短 ROP 把它的权限改成全局可写,之后普通用户就能修改这个配置。
具体执行流程是,先通过几段 JOP gadget 完成栈 pivot,把栈切换到 CEA 里我们布置好的 ROP 栈上,然后执行短 ROP 链,把 coredump_sysctls [1].mode 的地址放到目标寄存器,把带可写权限的值写入这个地址,最后调用 msleep 让劫持的内核线程安全休眠,避免内核崩溃。
权限修改完成后,用户态程序就可以直接向 /proc/sys/kernel/core_pattern 写入管道命令,然后故意触发一个子进程崩溃产生 coredump。内核会以 root 身份执行我们指定的程序,最终完成本地权限提升拿到 root 权限。 之所以不直接用最初的红黑树写原语改 mode 字段,是因为目标地址的布局不满足写入约束,必须通过控制流劫持后用 ROP 完成。
这套提权全程仅修改运行时内存中的权限标志,没有对系统分区做任何写入操作,属于典型的内存级临时 root,设备重启后所有内核态修改都会重置,系统恢复到原本的安全状态。
官方的修复思路很直接,把 remove_waiter 函数里所有操作 current 的地方,改成操作 waiter->task 也就是真正的等待者任务。这样不管是自身阻塞路径还是代理加锁路径,清理的都是正确的任务对象。
不过第一版官方补丁存在一个小问题,在某些特定死锁场景下,waiter->task 还没被赋值就会进入回滚流程,会触发空指针解引用。Nebula Security 团队也提交了自己的修复方案,由调用方主动传入对应的任务指针,并且加了校验只有 pi_blocked_on 确实指向当前 waiter 时才清空,从根源避免了空指针问题,同时也不会引入其他副作用,最终上游选择了重写 waiter::task 的版本。(大概率还能ai挖出洞来)
栈偏移随机化 RANDOMIZE_KSTACK_OFFSET
栈复用的前提是两次栈帧的位置精确重合。如果开启 RANDOMIZE_KSTACK_OFFSET 也就是内核栈偏移随机化,每次系统调用的栈起始位置会有随机偏移,两次栈帧就很难对齐,栈复用的成功率会降到大约 1/32,大幅提高利用难度。本次 kernelCTF 的默认目标没有开启这个选项,缓解目标则开启了该配置。
静态用户态助手 STATIC_USERMODE_HELPER
这个配置可以禁止通过 core_pattern 执行任意用户态程序,能堵住本次利用用到的 DirtyMode 路径。但这类思路可以推广到所有 /proc/sys 下的配置节点,只要对应的 ctl_table 结构体在可写内核数据段且地址可预测,就可以用同类思路修改权限,所以这只是针对单个利用路径的缓解,不能根治同类攻击。
GhostLock 是非常典型的代码复用引入的生命周期漏洞,一个原本设计场景单一的辅助函数,在新功能复用后没有修正隐含的假设,最终酿成了影响全版本的严重漏洞,并且在核心代码里潜伏了 15 年才被发现。而 IonStack 全链路利用的出现,也再次说明浏览器端的内存安全问题与内核侧的古老漏洞结合,能够形成极具威胁的远程攻击能力。
需要再次明确,本次 IonStack 演示的临时 root 能力,仅能在设备运行期间获取最高权限,无法实现持久化控制,也不会破坏系统完整性。目前主流 Linux 发行版与 Firefox 浏览器都已经陆续推送了修复补丁,建议用户尽快升级到最新版本,规避相关漏洞风险。
https://nebusec.ai/research/ionstack-part-2/ 相关漏洞在github有poc,可自测。
后续 Nebula Security 团队还会公开 GhostLock 漏洞在 Android 平台上的完整利用细节,涉及更复杂的栈帧复用和 CFI 绕过技巧。
往期:拆解FBI完整溯源黑客组织核心成员的技术链与时间线
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:黑鸟 黑鸟 黑鸟《安卓17 IonStack全链路0day漏洞利用链》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论