首页
社区
课程
招聘
[原创]当高频观测不再经过异常路径:Shadow Cave 与常驻式插桩架构
发表于: 4天前 517

[原创]当高频观测不再经过异常路径:Shadow Cave 与常驻式插桩架构

4天前
517

摘要:
传统动态插桩通常将一次命中建模为异常、回调或翻译事件:线程被捕获、现场被保存、观测逻辑被分派,原指令执行后再恢复。
该模型语义清晰,但在高频逐次观测场景下,异常往返和运行时分派会迅速成为主要开销,同时直接修改目标代码页又会破坏进程对自身代码的原始视图。
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 检查,再把原指令补执行后放行。
偶尔检查一次这套办法很好,可如果这个门口每秒经过一百万人,检查本身就会变成主要工作。

如果直接改造门口,把目标指令换成跳板,绕到一段观测代码再回来。异常没有了,速度快了
但任何读取自身代码的完整性检查都会发现,门已经不是原来的门。

于是问题变成了:

能不能既不让每个线程停下来,也不让读取者看到代码被改过,
还能够对百万级命中逐次记录,而不是靠采样碰运气?

这篇文章是前两篇工作的继续。如果是第一次接触这套系统,建议按下面的顺序阅读:

  1. 《ARM64 页内 XOL、陷阱垫与无损续执行架构》:解决断点命中以后,原指令怎样在 ARM64 上无损续执行。
  2. 《从无痕 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 去掉异常以后,
stpldp 之间就变成真实存在的临时事务: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、页面老化和系统调度中分离出来。即使能在实验室里通过大量样本得到统计差异,
也不等于能在商业对抗中把它变成可靠的逐进程判定器。

这带来三个必须区分的结论:

  1. 检测理论上可行。 对照 native 与 Cave 形态,结合长窗口统计、线程亲和性和负载控制,
    可以观察到分布变化。
  2. 检测工程上困难。 信号强度取决于锚点形态、观测 payload、微架构、设备状态和并发背景,
    没有跨设备稳定的固定阈值。
  3. 检测产品化更困难。低端设备、后台录屏、温控降频和系统抖动都可能产生同量级甚至更大的
    时延变化;把弱统计差异直接转成封禁决定,会把误报风险转化成运营事故。

因此,Shadow Cave 的目标不是不可检测,而是把检测从逻辑枚举推进到物理统计:
正常 API、页表读取和代码完整性检查在双视图契约内无法直接给出结论;剩下的只能是低 SNR
的侧信道观测。对于防守方而言,更合理的做法是把时序结果作为多源风险评分中的一个弱特征,
而不是单独作为零误伤的判决依据。

对 Cave 自身而言,这也构成设计约束:稳态路径保持短、固定、无 IPI;昂贵 rendezvous
只放在安装、退役和 lease 真正破坏的生命周期边界;观测 payload 按需选择,避免为了多记几项
而把可测信号人为放大。隐身的工程含义不是让物理世界没有变化,而是让变化不再形成稳定、
可复现、低成本的判定信号。

六、边界

  1. 直通形态也有观测维度。
    直通形态没有临时寄存器状态,不需要恢复 barrier;
    但异步信号仍可能观察到 Cave PC——对外呈现时必须做 canonical-PC 映射
    (原指令未执行 → anchor;已执行 → anchor+4;信号 handler 通过 ucontext 读取 PC 时,不能让它看到无法归属于原程序的 Cave 地址)。
    无事务不等于无身份问题。

  2. 死区证明的强度有上限。
    仅依赖直接分支扫描的死区证明不能称为绝对 PROVEN;
    jump table、BR/BLR 间接目标需要编译器/链接器控制流地图。

  3. Cave 代码页自身的生命周期。
    lease 销毁有 rendezvous;Cave 所在代码页被 mprotect/munmap/mremap/写入时,
    在飞执行流同样需要覆盖。对生命周期稳定的 ELF text 页风险较低;
    对动态改权/JIT 页不能直接外推。

  4. 观测者效应不为零。
    观测形态中的 store、dmb ishst、stlr 会影响缓存与内存序。
    Cave 可以做到无异常,但不能声称对一切无锁算法与数据竞争程序零干扰。
    LL/SC 四用例矩阵是当前 SoC 上的强实证,不是所有 ARM64 微架构的数学定理。

  5. 多写者严格快照要 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 框架的下一版,而是观测逻辑从事件模型迁入执行模型的开始。


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
免费 2
打赏
分享
最新回复 (2)
雪    币: 843
活跃值: (1225)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
大佬牛逼
3天前
0
雪    币: 217
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
666
3天前
0
游客
登录 | 注册 方可回帖
返回