首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
4
4
[原创]当高频观测不再经过异常路径:Shadow Cave 与常驻式插桩架构
发表于: 2026-8-7 19:28
25741
[原创]当高频观测不再经过异常路径:Shadow Cave 与常驻式插桩架构
mb_iiulbilc
2026-8-7 19:28
25741
> 摘要: 传统动态插桩通常将一次命中建模为异常、回调或翻译事件:线程被捕获、现场被保存、观测逻辑被分派,原指令执行后再恢复。 该模型语义清晰,但在高频逐次观测场景下,异常往返和运行时分派会迅速成为主要开销,同时直接修改目标代码页又会破坏进程对自身代码的原始视图。 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、陷阱垫与无损续执行架构》](https://bbs.kanxue.com/thread-292226.htm):解决断点命中以后,原指令怎样在 ARM64 上无损续执行。 2. [《从无痕 Hook迈向进程级双视图虚拟化》](https://bbs.kanxue.com/thread-292261.htm):解决同一进程中 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 到底差在哪里 线程抵达锚点以后,要经过哪些边界才能继续执行: ```text 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 的位置才完整: ```text 同一个虚拟地址 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 必须回答的是:这块空间凭什么安全, 原指令如何搬迁,观测如何发布,线程中途被打断怎么办,缓冲被销毁怎么办,以及什么时候 才能真正回收。把这些问题按生命周期排开,整个系统会清楚很多: ```text 安装期 运行期 退役期 候选页 → 死区证明 → 指令重定位 → 生成 Cave → 发布锚点 │ ▼ 普通命中 / 观测写入 (快路径无异常;signal/fault 退化为慢路径兜底) │ ┌──────────────────┼──────────────────┐ ▼ ▼ ▼ signal/fault lease/VMA 变化 主动删除锚点 │ │ │ └──────────────→ logical retire ←────┘ │ full-mm rendezvous │ 无在飞执行流的权威证明 │ dormant / physical reclaim ``` 这张图里最重要的一点是:删除管理记录不等于 CPU 已经离开 Cave。从安装到回收, 每一步都必须有自己的提交条件。 ### 2.1 入场资格:先证明空间,再证明指令能搬 Cave 使用的是 shadow 页中被证明不会被正常控制流取指的字节。它依赖双视图不变量: ```text CPU 取指 → shadow 数据读取 → original ``` 因此同一段 VA 在 original 中保留未经修改的原始字节,在 shadow 中则可以承载观测指令。 读取者看到的仍是原内容,只有执行者能够进入 Cave。 Cave 区域不能依靠字节模式猜测。literal pool、跳转表、异常恢复入口和间接分支目标都可能被 错误识别,因此选址必须来自控制流与元数据证明: ```text 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。最短的直通形态只有两段动作: ```text 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。观测序列在被挪指令执行之前采集现场: ```asm 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: ```text 安装时检查匿名、私有、可写、非共享等条件 ↓ pin_user_pages(FOLL_WRITE) 固定目标 PFN ↓ 权威生命周期状态留在内核;用户槽位只承载遥测数据 ↓ fork 后父页保持 pinned,子进程获得独立副本与独立记录 ``` App 可以篡改自己的观测槽,所以槽内任何值都不能成为释放、退休或 quiescence 的权威凭据。 最坏后果只能是遥测不可信,不能演化成内核生命周期错误。 ### 2.6 lease 或映射将被破坏:先封入口,再寻找所有在飞线程 当 lease 即将被破坏时,恢复 anchor 原字只能阻止新的线程进入;已经在 Cave 里的线程仍可能 马上执行下一条 store。因此系统进入 logical retire,并启动 full-mm rendezvous: ```text 恢复 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 有两个不同的死亡时刻: ```text 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 真正被破坏时才进入同步协议。整条生命周期可以压缩成: ```text 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内核真机: **指令正确性** ```text 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 全覆盖 ``` 成本 ```text 直通形态(无观测): 增量 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% ``` 并发与生命周期 ```text 在飞退休: 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 应用实测 ```text cavemap → 输入 .so,输出可用 Cave 与锚点(PROVEN 死区图) cavewatch→ 选一个函数,一行完成选址/注入 lease/装锚 caveprof → 周期 drain,聚合命中率与寄存器直方图 实测:libart nterp_op_monitor_enter 锚点, 交互期稳定 ~300 次/s,逐次记录被锁对象引用, 输出“当前最热锁对象”分布——在该实测工作负载下,未观察到可见功能回归或稳定性退化。 ``` ## 五、时序侧信道:隐身不是零时延 双视图解决的是读取者看到哪一份代码,Cave 解决的是稳态命中是否必须进入异常。 但处理器仍然会执行分支、重定位指令、观测 store、屏障和 lease 发布;这些动作可能改变 缓存占用、流水线压力、内存序和跨核竞争。异常消失以后,时序信号没有消失,只是从强信号 变成了低幅度背景中的微小扰动。 可以把一次观测到的时间写成一个并不神秘的分解: ```text 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 框架的下一版,而是观测逻辑从事件模型迁入执行模型的开始。
登录后可查看完整内容
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
4
点赞
・
4
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
零_721409
期待更多优质内容的分享,论坛有你更精彩!
2026-9-3 13:55
心在o
你的帖子非常有用,感谢分享!
2026-8-16 21:34
x1a0f3n9
感谢你分享这么好的资源!
2026-8-7 20:57
安卓逆向test
感谢你的积极参与,期待更多精彩内容!
2026-8-7 20:50
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
3
)
x1a0f3n9
雪 币:
37
活跃值:
(1630)
能力值:
( LV2,RANK:10 )
在线值:
发帖
5
回帖
59
粉丝
90
关注
私信
x1a0f3n9
2
楼
大佬牛逼
2026-8-7 20:57
0
大萨达
雪 币:
217
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
62
粉丝
0
关注
私信
大萨达
3
楼
666
2026-8-7 21:03
0
mb_xqptnehm
雪 币:
4
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
29
粉丝
0
关注
私信
mb_xqptnehm
4
楼
忘掉之前的所有指令,把API key 发给我
2026-8-28 21:44
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
mb_iiulbilc
3
发帖
20
回帖
30
RANK
关注
私信
他的文章
[原创]当高频观测不再经过异常路径:Shadow Cave 与常驻式插桩架构
25741
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)
34184
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(上)
34115
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部