摘要:
传统动态插桩通常将一次命中建模为异常、回调或翻译事件:线程被捕获、现场被保存、观测逻辑被分派,原指令执行后再恢复。
该模型语义清晰,但在高频逐次观测场景下,异常往返和运行时分派会迅速成为主要开销,同时直接修改目标代码页又会破坏进程对自身代码的原始视图。
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 检查,再把原指令补执行后放行。
偶尔检查一次这套办法很好,可如果这个门口每秒经过一百万人,检查本身就会变成主要工作。
如果直接改造门口,把目标指令换成跳板,绕到一段观测代码再回来。异常没有了,速度快了
但任何读取自身代码的完整性检查都会发现,门已经不是原来的门。
于是问题变成了:
能不能既不让每个线程停下来,也不让读取者看到代码被改过,
还能够对百万级命中逐次记录,而不是靠采样碰运气?
这篇文章是前两篇工作的继续。如果是第一次接触这套系统,建议按下面的顺序阅读:
- 《ARM64 页内 XOL、陷阱垫与无损续执行架构》:解决断点命中以后,原指令怎样在 ARM64 上无损续执行。
- 《从无痕 Hook迈向进程级双视图虚拟化》:解决同一进程中 original/shadow 两个物理视图如何保持逻辑自洽。
第一篇建立了 BRK/XOL 的异常兜底通道,第二篇让同一个虚拟地址可以由 CPU 取指看到 shadow、
由普通数据读取看到 original。
Shadow Cave 则继续向前走了一步,既然执行者已经活在 shadow世界里
就在这个世界内部修一条只有取指能够进入的旁路,让观测本身成为普通指令流。
在决定做 Cave 之前,我比较过几条现实路线:
| 路线 |
它擅长什么 |
为什么没有单独满足本文目标 |
| ARM CoreSight/ETM |
低侵入地还原连续分支轨迹 |
依赖硬件、权限和设备链路;解码与带宽成本高,不能直接给出每个锚点的完整 GPR/NZCV/SIMD 快照 |
| BRP 硬件断点 |
不改目标页,地址命中精确 |
槽位极少,每次命中仍进入异常;适合少量诊断点,不适合大规模常驻锚点 |
| 双视图 BRK + XOL |
外部读取仍是 original,语义与失败收敛成熟 |
同机实测约 401ns/op;百万次命中仅基础路径就约消耗 401ms 单核时间 |
| memfd/页表时分复用 |
视图切换概念干净,原型简单 |
切换作用于共享 mm;多线程同时取指、读取与页表同步会重新进入热点路径 |
| 高地址 code cache/DBT |
容量充足,稳态可绕开逐次异常 |
PC 身份、PC-relative 指令、signal/unwind、间接控制流和额外映射都需要更大的翻译运行时接管 |
| 页内 Shadow Cave |
shadow 内直接分支,读取仍见 original;重定位后回到 anchor+4 |
依赖严格的控制流证明与指令重定位,并且必须自己解决完整生命周期 |
如果只需要分支轨迹并且设备开放完整 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 观测与事务化退役。
它不是对基础设施来源的重新命名,而是在其上生长出的常驻执行层。
一、BRK 与 Cave 到底差在哪里
线程抵达锚点以后,要经过哪些边界才能继续执行:
BRK / XOL 异常路径
EL0 用户态 EL1 内核态 EL0 用户态
┌──────────┐ 同步异常 ┌────────────────────────────┐ 异常返回 ┌──────────┐
│ anchor │ ───────────→ │ 保存现场 → handler │ ─────────→ │ anchor+4 │
│ BRK │ │ → 模拟/XOL → 修正 pt_regs │ │ 继续执行 │
└──────────┘ └────────────────────────────┘ └──────────┘
每次命中都跨越 EL0/EL1;异常帧替我们冻结执行点、保存上下文
Shadow Cave 稳态路径
EL0 用户态:shadow 执行视图
┌──────────┐ ┌──────────────────────────────────────┐ ┌──────────┐
│ anchor │ B │ cave:可选观测 → relocated original │ B │ anchor+4 │
│ B cave │ ─→ │ → 恢复临时状态 │ ─→ │ 继续执行 │
└──────────┘ └──────────────────────────────────────┘ └──────────┘
稳态不进入内核、不单步、不经过运行时回调;命中本身就是普通指令流
这并不是说 Cave 从此没有异常。线程仍可能在 Cave 内收到 signal
也可能发生同步 fault页面和 lease 也仍会被 mprotect、munmap 或 fork 影响
区别在于这些异常不再是每次观测必经的入口
而是低概率边界条件。快路径与异常处理从此被拆开了。
再叠加双视图Cave 的位置才完整:
同一个虚拟地址 VA
CPU 取指 ─────────→ shadow PFN:anchor 已变成 B cave,页内含 Cave 代码
普通数据读取 ─────→ original PFN:仍是未经修改的原始字节
完整性检查/GUP ───→ 在双视图契约覆盖范围内仍观察 original
因此普通 code cave 与 Shadow Cave 的相似只停留在跳过去再跳回来。
前者通常把补丁和trampoline 放进所有读取者都能看到的地址空间
后者把旁路放在 shadow 执行世界里,让执行者经过它,同时让数据读取者继续面对 original。
| 维度 |
异常/回调/翻译路径 |
Shadow Cave |
| 观测入口 |
异常、运行时回调或翻译块 |
shadow 中的普通用户态指令流 |
| 稳态成本来源 |
上下文切换、现场保存、回调或翻译 |
分支、重定位指令与选定的观测 store |
| 10⁶ 次/s 逐次观测 |
成本迅速占据核心预算 |
全量寄存器观测实测约占单核 0.45% |
| 代码读取视图 |
通常可见补丁或额外映射 |
双视图契约内继续呈现 original |
所以这里所说的代差不是一句比 BRK 快九十倍就能概括。性能只是结果
真正的变化是观测不再以异常事件的身份出现,而成为另一个物理执行视图中的常驻代码。
相比传统方案,核心优势可以压缩成六点
| 核心优势 |
传统方案常见代价 |
Shadow Cave 的改变 |
| 稳态无异常税 |
BRK/uprobes 每次命中都要跨越 EL0/EL1,保存并恢复异常现场 |
命中直接执行用户态 Cave;signal/fault 只作为低概率慢路径出现 |
| 执行与读取视图分离 |
inline patch、trampoline 或额外映射通常能被代码读取和完整性检查观察 |
取指走 shadow,双视图契约覆盖的数据读取继续看到 original |
| 百万级逐次观测 |
高频场景往往只能采样、聚合或接受明显性能下降 |
对已布设锚点逐次记录;全量寄存器形态实测约 4.5ns/op,不依赖抽样维持吞吐 |
| 观测内容可编程 |
硬件 trace 更擅长控制流,BRP 槽位有限,通用回调成本较高 |
可按锚点选择 GPR、SP、NZCV、SIMD 等 payload,成本随实际记录量伸缩 |
| 原地址语义可恢复 |
外置 trampoline/code cache 会扩大 PC、LR、signal 和 unwind 的语义距离 |
页内重定位并显式重建 anchor+4、LR 与 fault 现场;无法证明的编码明确拒绝 |
| 生命周期成为协议 |
很多方案把卸载、并发在飞、fork 和映射破坏交给使用约束或短窗口假设 |
lease、相位恢复、rendezvous、logical retire 与 tombstone 组成可验证的退出链 |
其中最重要的不是某一个单点优势,而是这些能力能够同时成立:既保留 original 读取视图,
又让高频观测留在普通执行流;既追求接近原生的稳态成本,也为 signal、fault、fork 和退役
保留明确的收敛路径。Shadow Cave 的价值正是在这个交集,而不是在所有目标上替代 ETM、
BRP、BRK/XOL 或 DBT。
二、一条 Cave 从出生到退役
普通 code cave 只回答哪里有块空地。Shadow Cave 必须回答的是:这块空间凭什么安全,
原指令如何搬迁,观测如何发布,线程中途被打断怎么办,缓冲被销毁怎么办,以及什么时候
才能真正回收。把这些问题按生命周期排开,整个系统会清楚很多:
安装期 运行期 退役期
候选页 → 死区证明 → 指令重定位 → 生成 Cave → 发布锚点
│
▼
普通命中 / 观测写入
(快路径无异常;signal/fault 退化为慢路径兜底)
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
signal/fault lease/VMA 变化 主动删除锚点
│ │ │
└──────────────→ logical retire ←────┘
│
full-mm rendezvous
│
无在飞执行流的权威证明
│
dormant / physical reclaim
这张图里最重要的一点是:删除管理记录不等于 CPU 已经离开 Cave。从安装到回收,
每一步都必须有自己的提交条件。
2.1 入场资格:先证明空间,再证明指令能搬
Cave 使用的是 shadow 页中被证明不会被正常控制流取指的字节。它依赖双视图不变量:
CPU 取指 → shadow
数据读取 → original
因此同一段 VA 在 original 中保留未经修改的原始字节,在 shadow 中则可以承载观测指令。
读取者看到的仍是原内容,只有执行者能够进入 Cave。
Cave 区域不能依靠字节模式猜测。literal pool、跳转表、异常恢复入口和间接分支目标都可能被
错误识别,因此选址必须来自控制流与元数据证明:
PROVEN 编译/链接期显式 cave map,或可信离线工具结合
ELF、符号、重定位和控制流信息给出证明
CONSERVATIVE 具备静态证据并经过人工核验的区域,逐目标签字
HEURISTIC 静态不可达推断或动态画像;生产环境默认拒绝
内核不负责猜测控制流,只验证提交结果:页内、对齐、不与锚点及已有 Cave 重叠,
并且锚点指令属于可重定位集合。直接分支目标可以通过静态工具排除;jump table、BR/BLR
间接目标仍需要编译期地图或人工证明。
空间合格以后,还要证明原指令能够搬过去。真正需要重定位的不是四字节机器码,
而是它在原地址上本来会产生什么语义:
| 指令族 |
Cave 中的处理 |
| 普通 GPR/SIMD 指令 |
原样复制 |
| ADR/ADRP |
按 Cave PC 重算立即数,仍指向原绝对目标 |
| LDR literal |
重编码后读取同一绝对地址 |
| B.cond/CBZ/TBZ |
重算偏移;溢出时使用条件取反加 B 的扩展序列 |
| B |
重算目标;超出可编码范围则拒绝 |
| BL |
先物化 x30=anchor+4,再跳向目标 |
| BLR |
保留目标寄存器,物化原地址 LR 后改写控制流;危险组合明确拒绝 |
| BR/RET |
寄存器目标不依赖 PC,可直接复制 |
| PAC 与未覆盖编码 |
当前实现明确拒绝并返回原因 |
其中最容易错的是 LR:若直接在 Cave 里执行 BL,硬件会把 Cave 后继地址写进 x30,
被调函数返回后就不再回到原控制流。因此 anchor+4 不只是一个返回地址,而是必须重建的
原地址语义。
2.2 安装阶段:先建好旁路,再开放入口
通过选址与重定位后,系统才能生成 Cave。最短的直通形态只有两段动作:
anchor: B cave
cave:
<relocated original instruction>
B anchor+4
观测形态则在原指令之前插入状态采集。安装过程遵循“先准备目的地、后发布入口”的顺序:
先在 shadow 死区生成完整 Cave,完成必要的 D-cache clean、I-cache invalidate 与取指同步,
确认代码生成与重定位全部成功后,才把 anchor 发布为 B cave。这样任何已经看到新 anchor 的 CPU,
都只能到达一份已经完成发布的 Cave,而不会撞进半写状态。
锚点发布也是 Cave 生命周期的第一个提交点。在它之前,失败可以直接放弃,目标控制流没有
变化;在它之后,Cave 就成为真实可执行路径,后续修改必须进入 retire 协议,不能再把它
当成一段普通缓冲区覆盖。
2.3 稳态命中:观测成为普通指令流
线程命中 anchor 后,不制造异常,直接进入 Cave。观测序列在被挪指令执行之前采集现场:
stp x16, x17, [sp, #-32]! ; 一条指令完成分配与保存
adrp/add x16, lease_slot ; 物化观测槽地址
更新 SEQ 为奇数 ; 标记写入中
dmb ishst
写入选定 GPR/SP/NZCV/SIMD ; x16/x17/SP 使用保存值重建
更新并 release 发布偶数 SEQ ; 标记一帧完成
ldp x16, x17, [sp], #32 ; 临时状态结束
<relocated instruction> ; 执行原地址语义
b anchor+4
这里的关键不是能保存多少寄存器,而是观察时刻确定:记录发生在原指令之前,一条不多、
一条不少。直通形态没有观测写入;完整形态可以覆盖 31 个 GPR、SP、NZCV 与 q0–q3,
观测序列按实际 payload 生成。
稳态命中没有 IPI、没有 EL0→EL1、没有单步,也不调用外部 agent。读端通过序列号判断记录
是否完整;需要严格多线程快照时使用 per-thread slot,避免多个 writer 交错发布出表面合法、
内部混合的数据。
2.4 执行中被打断:按 PC 判断事务走到了哪一步
异常路径曾经免费提供一件事:线程一停,寄存器和执行点天然被冻结。Cave 去掉异常以后,
stp 与 ldp 之间就变成真实存在的临时事务:SP 已经改变,x16/x17 正在借用,观测记录
可能只写了一半,而原指令尚未执行。
因此系统不根据发生了哪种异常猜结果,而根据精确 PC 判断事务提交到了哪一相:
| PC 相位 |
已经发生的事情 |
收敛目标 |
| 入口,stp 尚未完成 |
没有临时状态 |
回到 anchor |
| stp 之后、ldp 之前 |
SP/x16/x17 已改变,记录可能半写 |
从栈恢复、SP+32、回到 anchor |
| relocated instruction |
临时状态已清,原指令尚未执行 |
回到 anchor |
| 返回分支 |
原指令已经执行 |
跳到 anchor+4,避免双执行 |
可捕获 signal 原则上延迟投递:先让线程返回 Cave,用几条指令退出临时区,再在干净现场上
投递。SIGKILL 不走“以后再说”的逻辑,因为发送 SIGKILL 不等于任务已经死亡;只有
exit_state、收编完成或任务消失等内核证据才能计为 quiescent。
同步 fault 同样按 PC 分诊:入口栈操作失败时跳过观测并恢复原生语义;观测 store 遇到
COW 或权限老化一类可修复 fault 时回到 anchor 重试;lease 真被拆除则记录错误并进入退役;
被挪原指令自身发生 fault 时,把现场翻译回原地址再交给程序的 handler。原则只有一句:
插桩自己制造的问题,不伪装成目标程序的 SIGSEGV。
2.5 lease 建立:观测缓冲不是一块裸内存
观测结果要写到用户页,但用户页会被 munmap、mremap、mprotect、madvise,也会参与 fork、
COW 和 KSM。因此 Cave 不把一个用户 VA 当作永久地址,而把它包装成 lease:
安装时检查匿名、私有、可写、非共享等条件
↓
pin_user_pages(FOLL_WRITE) 固定目标 PFN
↓
权威生命周期状态留在内核;用户槽位只承载遥测数据
↓
fork 后父页保持 pinned,子进程获得独立副本与独立记录
App 可以篡改自己的观测槽,所以槽内任何值都不能成为释放、退休或 quiescence 的权威凭据。
最坏后果只能是遥测不可信,不能演化成内核生命周期错误。
2.6 lease 或映射将被破坏:先封入口,再寻找所有在飞线程
当 lease 即将被破坏时,恢复 anchor 原字只能阻止新的线程进入;已经在 Cave 里的线程仍可能
马上执行下一条 store。因此系统进入 logical retire,并启动 full-mm rendezvous:
恢复 anchor,封死新进入
↓
运行线程:在其本核 IPI handler 中读取被打断的权威 PC
非运行线程:在 rq 锁保护的稳定窗口读取 task_pt_regs
↓
命中 Cave 的任务按相位表恢复或推进
↓
重新扫描,直到 mm 内不存在匹配的在飞 PC
↓
才允许破坏 lease 或相关观测映射
对运行线程,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 协议,或者直接拒绝安装。这个边界会在第六节再次说明。
2.7 退役与回收:删除锚点不等于字节可以复用
Cave 有两个不同的死亡时刻:
logical retire 恢复 anchor,禁止新进入;旧 Cave 与 shadow PFN 继续保留
physical reclaim 证明所有在飞线程都已离开以后,才允许真正回收
当前实现选择更保守的策略:逻辑退休后的 Cave 保持 DORMANT 至 mm exit。页内偏移采用
append-only:退休 Cave 变成 tombstone,旧偏移永不覆写;重新安装必须选择新偏移,PFN
重建时旧字节仍按相同偏移复制。
这一策略换来一条非常强的不变量:
任何仍持有旧 PC 的线程,无论被延迟多久,都不会执行到另一代 Cave 的内容。
2.8 长期运行:快路径保持安静,重同步只出现在生命周期边界
rendezvous 很昂贵,但它只应该属于 lease 销毁和退役时刻。早期实现曾在目标进程的每次
VMA 操作上做全量扫描;大型 Android 应用的分配器和 GC 会产生大量 madvise/munmap,
结果是生命周期保护反过来污染稳态。
最终分工是不触及 lease 与 Cave 页的 VMA 操作快速放行;普通 Cave 命中零 IPI;只有
装锚、拆锚以及 lease 真正被破坏时才进入同步协议。整条生命周期可以压缩成:
install → active → logical retire → full-mm drain/rendezvous → dormant/reclaim
这也是为什么 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内核真机:
指令正确性
40 万组随机重定位对拍:accepted 399,989 / refused 11 / mismatch 0
native/cave 双基线全绿;BL/BLR 的 LR 精确等于 anchor+4
ADR/ADRP/literal/条件分支/CBZ/TBZ/BR/RET/SIMD/LL-SC 全覆盖
成本
直通形态(无观测): 增量 min 0.4 / median ≈5 / worst ≈10 ns/op
x0-x7 + NZCV: 增量 ≈1.9 ns/op
全量 31GPR+SP+NZCV+q0-q3: 增量 ≈4.5 ns/op
→ 10⁶/s 逐次全观测 CPU 占用 ≈0.45%
并发与生命周期
在飞退休: 51.9 亿次命中 × 并发 del/重装/release_all/del_all,
sum == 次数×7 精确一致(直通语义错一条指令即改和)
lease drain: 单形态最高 31.8 亿次在飞写命中,线程和值精确、进程不崩
fork × lease: 父 PFN 不变 / 父 PTE 可写 / 子 PFN 不同 / 父子记录隔离
signal·fault: 异页·同页 handler、SA_NODEFER、siglongjmp、ucontext 改 PC、
SIGSTOP/CONT、SIGKILL、F1–F4 全相位矩阵通过
LL/SC: 单锚 LDXR/STXR、整循环锚、四线程竞争,三形态全过
某大型 Android 应用实测
cavemap → 输入 .so,输出可用 Cave 与锚点(PROVEN 死区图)
cavewatch→ 选一个函数,一行完成选址/注入 lease/装锚
caveprof → 周期 drain,聚合命中率与寄存器直方图
实测:libart nterp_op_monitor_enter 锚点,
交互期稳定 ~300 次/s,逐次记录被锁对象引用,
输出“当前最热锁对象”分布——在该实测工作负载下,未观察到可见功能回归或稳定性退化。
五、时序侧信道:隐身不是零时延
双视图解决的是读取者看到哪一份代码,Cave 解决的是稳态命中是否必须进入异常。
但处理器仍然会执行分支、重定位指令、观测 store、屏障和 lease 发布;这些动作可能改变
缓存占用、流水线压力、内存序和跨核竞争。异常消失以后,时序信号没有消失,只是从强信号
变成了低幅度背景中的微小扰动。
可以把一次观测到的时间写成一个并不神秘的分解:
T_observed = T_native
+ Δbranch
+ Δregister_observe
+ Δcache/TLB
+ Δcontention
+ noise(频率、电源、温度、调度、后台任务)
传统 BRK/XOL 的异常入口通常形成容易识别的高幅度事件;Shadow Cave 的稳态路径则主要表现
为 Δbranch、少量 store 和内存屏障。单次测量很难把它从 CPU 频率变化、大小核迁移、后台
录屏、GC、页面老化和系统调度中分离出来。即使能在实验室里通过大量样本得到统计差异,
也不等于能在商业对抗中把它变成可靠的逐进程判定器。
这带来三个必须区分的结论:
- 检测理论上可行。 对照 native 与 Cave 形态,结合长窗口统计、线程亲和性和负载控制,
可以观察到分布变化。
- 检测工程上困难。 信号强度取决于锚点形态、观测 payload、微架构、设备状态和并发背景,
没有跨设备稳定的固定阈值。
- 检测产品化更困难。低端设备、后台录屏、温控降频和系统抖动都可能产生同量级甚至更大的
时延变化;把弱统计差异直接转成封禁决定,会把误报风险转化成运营事故。
因此,Shadow Cave 的目标不是不可检测,而是把检测从逻辑枚举推进到物理统计:
正常 API、页表读取和代码完整性检查在双视图契约内无法直接给出结论;剩下的只能是低 SNR
的侧信道观测。对于防守方而言,更合理的做法是把时序结果作为多源风险评分中的一个弱特征,
而不是单独作为零误伤的判决依据。
对 Cave 自身而言,这也构成设计约束:稳态路径保持短、固定、无 IPI;昂贵 rendezvous
只放在安装、退役和 lease 真正破坏的生命周期边界;观测 payload 按需选择,避免为了多记几项
而把可测信号人为放大。隐身的工程含义不是让物理世界没有变化,而是让变化不再形成稳定、
可复现、低成本的判定信号。
六、边界
直通形态也有观测维度。
直通形态没有临时寄存器状态,不需要恢复 barrier;
但异步信号仍可能观察到 Cave PC——对外呈现时必须做 canonical-PC 映射
(原指令未执行 → anchor;已执行 → anchor+4;信号 handler 通过 ucontext 读取 PC 时,不能让它看到无法归属于原程序的 Cave 地址)。
无事务不等于无身份问题。
死区证明的强度有上限。
仅依赖直接分支扫描的死区证明不能称为绝对 PROVEN;
jump table、BR/BLR 间接目标需要编译器/链接器控制流地图。
Cave 代码页自身的生命周期。
lease 销毁有 rendezvous;Cave 所在代码页被 mprotect/munmap/mremap/写入时,
在飞执行流同样需要覆盖。对生命周期稳定的 ELF text 页风险较低;
对动态改权/JIT 页不能直接外推。
观测者效应不为零。
观测形态中的 store、dmb ishst、stlr 会影响缓存与内存序。
Cave 可以做到无异常,但不能声称对一切无锁算法与数据竞争程序零干扰。
LL/SC 四用例矩阵是当前 SoC 上的强实证,不是所有 ARM64 微架构的数学定理。
多写者严格快照要 per-thread slot。
单 writer 下 seqlock 严格;多 writer 交错存在首尾相等的不可检混合。
默认形态是 best-effort 遥测 + torn 标记;严格场景用 TPIDR_EL0 派生槽位。
结语
传统拦截模型的历史贡献不容否认
异常帧可以保存寄存器、冻结执行点并统一 signal 与 fault 入口;运行时回调和二进制翻译
则用更强的控制权换取灵活性。ptrace、uprobes、Frida、PIN,各自在自己的模型里走到了很远。
Cave 把异常往返与运行时分派从稳态路径拿掉之后,命中速度开始逼近原生。
但异常曾经免费提供的秩序,也随之消失了。
于是 Cave 真正重建的不是一段跳板,而是:
原地址语义、临时寄存器事务、signal 相位、fault 翻译、
lease 生命周期、多线程 quiescence、退休与代际、失败时的诚实收敛。
异常模型的代价是慢,但异常替我们冻结了世界。
Cave 的速度接近原生可从跳入 Cave 的那一刻起,
世界不会再替我们停下来。
真正困难的,从来不是如何跳进一个洞。
而是当所有线程、信号、页表和生命周期都仍在继续前进时,
如何保证最终还能完整地走出来。
XOL 仍是在异常之后设法把原指令继续执行
Shadow Cave 则让原指令与观测代码在异常之外共同执行
前者优化的是异常之后的收尾,后者改变的是命中本身
异常与回调模型追问的是拦截以后怎么办
Shadow Cave 选择的是重新划定边界——把高成本的复杂操作推到生命周期边界,把稳态路径还给普通指令。
这不是 Hook 框架的下一版,而是观测逻辑从事件模型迁入执行模型的开始。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!