摘要:
传统动态插桩通常将一次命中建模为异常、回调或翻译事件:线程被捕获、现场被保存、观测逻辑被分派,原指令执行后再恢复。
该模型语义清晰,但在高频逐次观测场景下,异常往返和运行时分派会迅速成为主要开销,同时直接修改目标代码页又会破坏进程对自身代码的原始视图。
Shadow Cave 建立在 wxshadow 的 original/shadow 双视图之上,将观测代码生成并发布到 shadow 执行视图中的已证明区域,通过页内分支进入 Cave,完成 ARM64 指令重定位、寄存器与状态观测,再返回原始控制流。稳态命中不再依赖异常、单步或跨核同步。signal、同步 fault、fork、lease 破坏和并发退役则由精确 PC 相位、rendezvous、logical retire 与代际 tombstone 协议负责收敛。
本文从控制流证明、指令语义重建、观测事务、生命周期管理和时序侧信道五个方面介绍 Shadow Cave,并给出真机重定位对拍、逐次寄存器观测、并发退役、fork 隔离、signal/fault 矩阵及真实 Android 应用负载验证结果。Shadow Cave 的核心变化并非把传统 Hook 做得更快,而是将观测从拦截之后的处理改写为执行本身的一部分。
先从一个很普通的问题说起。
我们想知道某个函数每次被调用时,寄存器里装了什么。比如在某大型 Android 应用中,想确认 libart 的 monitor_enter 每次拿到的是哪个对象锁
如果每次进入都停下来检查,交互响应很快就会出现肉眼可见的卡顿。
最直接的办法是在门口放一个断点:
线程走到这里,先停下来,进入内核,保存现场,交给 handler 检查,再把原指令补执行后放行。
偶尔检查一次这套办法很好,可如果这个门口每秒经过一百万人,检查本身就会变成主要工作。
如果直接改造门口,把目标指令换成跳板,绕到一段观测代码再回来。异常没有了,速度快了
但任何读取自身代码的完整性检查都会发现,门已经不是原来的门。
于是问题变成了:
能不能既不让每个线程停下来,也不让读取者看到代码被改过,
还能够对百万级命中逐次记录,而不是靠采样碰运气?
这篇文章是前两篇工作的继续。如果是第一次接触这套系统,建议按下面的顺序阅读:
第一篇建立了 BRK/XOL 的异常兜底通道,第二篇让同一个虚拟地址可以由 CPU 取指看到 shadow、
由普通数据读取看到 original。
Shadow Cave 则继续向前走了一步,既然执行者已经活在 shadow世界里
就在这个世界内部修一条只有取指能够进入的旁路,让观测本身成为普通指令流。
在决定做 Cave 之前,我比较过几条现实路线:
如果只需要分支轨迹并且设备开放完整 ETM,硬件 trace 往往更自然
如果只有几个诊断点BRP 也更简单。
Shadow Cave 面向的是更苛刻的交集:普通 Android 设备上的动态部署、
外部 original 视图、百万级逐次命中、可编程寄存器观测,以及长期运行后的 signal、fault、
fork、mprotect 和并发退役正确性。
即使刻意让异常方案占便宜,只把 401ns 当作一次命中的下界,而把 Cave 的 4.5ns 按全量寄存器观测计算
一百万次命中也分别消耗约 401ms 与 4.5ms 单核时间,接近两个数量级
前者还没有加入完整 payload 的聚合和输出。走到这里,问题已经不是再优化一点 handler
而是必须把高频命中移出异常路径。
因此我没有废弃 BRK/XOL,而是把通道分层:
低频、一次性、需要修改寄存器或无法安全重定位的锚点继续走异常通道
满足死区证明与重定位约束的高频观测锚点进入 Cave
前者负责语义兜底,后者承担吞吐。
Shadow Cave 建立在 wxshadow 已有的 original/shadow 双视图、执行页翻转和生命周期框架之上;
本文新增并重点讨论的是页内死区证明、ARM64 指令重定位、lease 观测与事务化退役。
它不是对基础设施来源的重新命名,而是在其上生长出的常驻执行层。
线程抵达锚点以后,要经过哪些边界才能继续执行:
这并不是说 Cave 从此没有异常。线程仍可能在 Cave 内收到 signal
也可能发生同步 fault页面和 lease 也仍会被 mprotect、munmap 或 fork 影响
区别在于这些异常不再是每次观测必经的入口
而是低概率边界条件。快路径与异常处理从此被拆开了。
再叠加双视图Cave 的位置才完整:
因此普通 code cave 与 Shadow Cave 的相似只停留在跳过去再跳回来。
前者通常把补丁和trampoline 放进所有读取者都能看到的地址空间
后者把旁路放在 shadow 执行世界里,让执行者经过它,同时让数据读取者继续面对 original。
所以这里所说的代差不是一句比 BRK 快九十倍就能概括。性能只是结果
真正的变化是观测不再以异常事件的身份出现,而成为另一个物理执行视图中的常驻代码。
其中最重要的不是某一个单点优势,而是这些能力能够同时成立:既保留 original 读取视图,
又让高频观测留在普通执行流;既追求接近原生的稳态成本,也为 signal、fault、fork 和退役
保留明确的收敛路径。Shadow Cave 的价值正是在这个交集,而不是在所有目标上替代 ETM、
BRP、BRK/XOL 或 DBT。
普通 code cave 只回答哪里有块空地。Shadow Cave 必须回答的是:这块空间凭什么安全,
原指令如何搬迁,观测如何发布,线程中途被打断怎么办,缓冲被销毁怎么办,以及什么时候
才能真正回收。把这些问题按生命周期排开,整个系统会清楚很多:
这张图里最重要的一点是:删除管理记录不等于 CPU 已经离开 Cave。从安装到回收,
每一步都必须有自己的提交条件。
Cave 使用的是 shadow 页中被证明不会被正常控制流取指的字节。它依赖双视图不变量:
因此同一段 VA 在 original 中保留未经修改的原始字节,在 shadow 中则可以承载观测指令。
读取者看到的仍是原内容,只有执行者能够进入 Cave。
Cave 区域不能依靠字节模式猜测。literal pool、跳转表、异常恢复入口和间接分支目标都可能被
错误识别,因此选址必须来自控制流与元数据证明:
内核不负责猜测控制流,只验证提交结果:页内、对齐、不与锚点及已有 Cave 重叠,
并且锚点指令属于可重定位集合。直接分支目标可以通过静态工具排除;jump table、BR/BLR
间接目标仍需要编译期地图或人工证明。
空间合格以后,还要证明原指令能够搬过去。真正需要重定位的不是四字节机器码,
而是它在原地址上本来会产生什么语义:
其中最容易错的是 LR:若直接在 Cave 里执行 BL,硬件会把 Cave 后继地址写进 x30,
被调函数返回后就不再回到原控制流。因此 anchor+4 不只是一个返回地址,而是必须重建的
原地址语义。
通过选址与重定位后,系统才能生成 Cave。最短的直通形态只有两段动作:
观测形态则在原指令之前插入状态采集。安装过程遵循“先准备目的地、后发布入口”的顺序:
先在 shadow 死区生成完整 Cave,完成必要的 D-cache clean、I-cache invalidate 与取指同步,
确认代码生成与重定位全部成功后,才把 anchor 发布为 B cave。这样任何已经看到新 anchor 的 CPU,
都只能到达一份已经完成发布的 Cave,而不会撞进半写状态。
锚点发布也是 Cave 生命周期的第一个提交点。在它之前,失败可以直接放弃,目标控制流没有
变化;在它之后,Cave 就成为真实可执行路径,后续修改必须进入 retire 协议,不能再把它
当成一段普通缓冲区覆盖。
线程命中 anchor 后,不制造异常,直接进入 Cave。观测序列在被挪指令执行之前采集现场:
这里的关键不是能保存多少寄存器,而是观察时刻确定:记录发生在原指令之前,一条不多、
一条不少。直通形态没有观测写入;完整形态可以覆盖 31 个 GPR、SP、NZCV 与 q0–q3,
观测序列按实际 payload 生成。
稳态命中没有 IPI、没有 EL0→EL1、没有单步,也不调用外部 agent。读端通过序列号判断记录
是否完整;需要严格多线程快照时使用 per-thread slot,避免多个 writer 交错发布出表面合法、
内部混合的数据。
异常路径曾经免费提供一件事:线程一停,寄存器和执行点天然被冻结。Cave 去掉异常以后,
stp 与 ldp 之间就变成真实存在的临时事务:SP 已经改变,x16/x17 正在借用,观测记录
可能只写了一半,而原指令尚未执行。
因此系统不根据发生了哪种异常猜结果,而根据精确 PC 判断事务提交到了哪一相:
可捕获 signal 原则上延迟投递:先让线程返回 Cave,用几条指令退出临时区,再在干净现场上
投递。SIGKILL 不走“以后再说”的逻辑,因为发送 SIGKILL 不等于任务已经死亡;只有
exit_state、收编完成或任务消失等内核证据才能计为 quiescent。
同步 fault 同样按 PC 分诊:入口栈操作失败时跳过观测并恢复原生语义;观测 store 遇到
COW 或权限老化一类可修复 fault 时回到 anchor 重试;lease 真被拆除则记录错误并进入退役;
被挪原指令自身发生 fault 时,把现场翻译回原地址再交给程序的 handler。原则只有一句:
插桩自己制造的问题,不伪装成目标程序的 SIGSEGV。
观测结果要写到用户页,但用户页会被 munmap、mremap、mprotect、madvise,也会参与 fork、
COW 和 KSM。因此 Cave 不把一个用户 VA 当作永久地址,而把它包装成 lease:
App 可以篡改自己的观测槽,所以槽内任何值都不能成为释放、退休或 quiescence 的权威凭据。
最坏后果只能是遥测不可信,不能演化成内核生命周期错误。
当 lease 即将被破坏时,恢复 anchor 原字只能阻止新的线程进入;已经在 Cave 里的线程仍可能
马上执行下一条 store。因此系统进入 logical retire,并启动 full-mm rendezvous:
对运行线程,task/mm/cpu/pc 四元组必须相互吻合,任何错认都触发重扫;不能从另一颗 CPU
读取一个瞬间的 PC 就宣布安全。对 STOPPED/TRACED 任务使用两阶段提交:锁内快照,锁外
准备恢复材料,再次进入锁后逐字段复验 !on_cpu、任务状态、mm、pc、sp,全部一致才提交。
这堵住了任务已经重新运行,却仍按旧现场修寄存器的 TOCTOU。
这里也不同于 RCU 的普通宽限期:RCU 通常只需等待所有参与者越过某个 quiescent state;
Cave 必须精确证明当前 mm 内没有线程还持有指向旧 Cave 的 PC。一次误判,就可能让下一条
观测 store 落到已经释放或重新映射的页上。因此它需要的是地址相关、任务相关的 in-flight
证明,而不是一个全局宽限期已经过去的推断。
Cave 代码页自身的销毁属于同类问题,但不能在这里偷换成已经完成的保证。当前公开结论限定在
生命周期稳定的 ELF text 页;对可能动态改权、卸载或重映射的 JIT 页,必须让代码页破坏也
进入等价的 in-flight 协议,或者直接拒绝安装。这个边界会在第六节再次说明。
Cave 有两个不同的死亡时刻:
当前实现选择更保守的策略:逻辑退休后的 Cave 保持 DORMANT 至 mm exit。页内偏移采用
append-only:退休 Cave 变成 tombstone,旧偏移永不覆写;重新安装必须选择新偏移,PFN
重建时旧字节仍按相同偏移复制。
这一策略换来一条非常强的不变量:
任何仍持有旧 PC 的线程,无论被延迟多久,都不会执行到另一代 Cave 的内容。
rendezvous 很昂贵,但它只应该属于 lease 销毁和退役时刻。早期实现曾在目标进程的每次
VMA 操作上做全量扫描;大型 Android 应用的分配器和 GC 会产生大量 madvise/munmap,
结果是生命周期保护反过来污染稳态。
最终分工是不触及 lease 与 Cave 页的 VMA 操作快速放行;普通 Cave 命中零 IPI;只有
装锚、拆锚以及 lease 真正被破坏时才进入同步协议。整条生命周期可以压缩成:
这也是为什么 Cave 必须与 BRK/XOL 共存。Cave 处理满足证明条件的高频稳定形态;异常通道
处理一次性语义、寄存器修改、不可重定位指令和失败兜底。不是一条通道包打全场,而是让
每种机制只承担自己最擅长的部分。
传统动态 Hook 的中心动作是拦截。无论拦截发生在异常、trampoline、agent 回调还是
翻译块边界,系统都要先把正常控制流交给另一套处理机制,再决定记录什么、怎样执行原指令、
何时返回。它的能力很灵活,但每次命中都要重新支付一次控制权转移的成本。
执行即观测改变了成本发生的位置。Shadow Cave 不在每次命中时临时组织一次处理过程,
而是在安装期预先完成死区证明、指令重定位、观测序列生成和发布;运行期只执行已经生成好的
普通指令;退役期再集中支付 rendezvous 与回收成本。复杂性没有消失,而是从百万次重复发生的
命中点,被搬到了只发生少数几次的生命周期边界。
这种移动带来三个结构性优势。
第一,稳态成本开始与实际记录多少数据相关,而不再被异常往返或通用回调框架设定一个
很高的固定底价。只需直通时,Cave 可以只有被挪指令和返回分支;需要完整寄存器画像时,
再按需加入对应的 store。观测负载可以伸缩,但控制流模型不需要改变。
第二,执行视图与读取视图不必再妥协。在双视图契约覆盖范围内,完整性检查读取 original,
处理器却在 shadow 中经过 Cave。隐藏不再依赖修改窗口足够短或检查恰好没有发生,
而是由两个物理版本各自保持逻辑自洽。Cave 不是把痕迹擦得更干净,而是让这段插桩代码从一开始
就不属于数据观察者所在的世界。
第三,失败边界变得可以定义。不满足 Cave 执行条件或需要动态修改寄存器的锚点,明确回到
BRK/XOL;signal 和 fault 按 PC 相位收敛;lease 销毁通过 rendezvous 证明无在飞线程;旧 Cave
通过 tombstone 避免代际复用。系统不要求 Cave 覆盖所有情况,而要求每一种情况都有清楚的
通道、提交点和失败出口。
所以 Shadow Cave 不是优化后的 Hook。更准确地说,它弃用的是每次命中都先拦截、再分派
这一稳态模型:安装时把观测编入 shadow 执行世界,命中时直接执行,生命周期结束时再进行统一
收敛。BRK/XOL 仍然存在,但它从默认主路径退到动态语义与异常兜底层。
在双视图契约覆盖的外部读取路径中,目标页仍呈现未经修改的原始代码;在处理器取指路径中,
锚点已经成为通往 Cave 的普通分支。控制流完成原指令重定位、状态观测和结果发布后回到
anchor+4。整个稳态过程不需要异常、不需要单步、不需要每次进入内核,也不需要跨核
rendezvous。
这不再只是如何把 Hook 做得更隐蔽,而是如何在同一个进程内部构造一套能够长期运行、
可观测、可退役,并且对外保持原始视图的平行执行系统。
Shadow Cave 不回答拦截以后怎么办
它回答的是:能不能不拦截,让观测成为执行本身?
以下结果均来自一加ACE5/骁龙8 Gen3 /6.1内核真机:
指令正确性
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。