概念来源声明:R^X 分页(一个虚拟地址、两个物理页、读与执行分离)的思想与最初的 KPM 实现,来自看雪《基于硬件断点的无痕 hook 之外的另一种无痕 hook 方式》及作者 kkkbbb 的 wxshadow 项目。 原文链接:https://bbs.kanxue.com/thread-290304.htm R^X 分页的思想一句话就能讲完:准备一个 original 物理页(原始字节)和一个 shadow 物理页(被修改的字节),读访问把 PTE 指向 original,取指把 PTE 指向 shadow。ARM64 的 AP 位只约束数据访问、不约束指令预取,于是"仅执行"(--x)和"仅读取"(r--)是可以写进页表的具体比特位。进程的自检(CRC、逐字节比对、MD5)读到的永远是干净的 original,CPU 执行的却是 shadow 上的被修改指令。 这个模型优美、可演示。但当我把它推向真实设备时,发现它有三个隐含假设,而这三个假设在真实世界里全部不成立:
一、在 ARM64 上放置一个断点并不难。 把目标指令替换成 BRK,异常发生后进入内核,记录寄存器、修改上下文,再让程序继续运行——这是许多调试器、Hook 框架和动态插桩系统都能完成的事。 真正困难的问题是: 当原地址必须长期保持为 BRK 时,如何让被替换的原指令继续以原地址语义执行;同时不让临时执行过程破坏断点可达性,不把其他线程带入错误的指令代际,也不在失败时损坏目标进程? 这不是“换一条指令”能够回答的问题。 它同时触及 ARM64 的 PC-relative 语义、异常返回、分支行为、单步状态、I/D Cache 一致性、跨核取指,以及多线程共享执行页上的并发协议。 本文讨论的不是一个新的 BRK handler,而是围绕“被挪走指令如何无损续执行”建立的一套执行架构。 我对子系统的定位不是"藏一个 hook",而是以内核为可信根,对进程的执行内容、读取结果、控制流语义和内核观测通道做一致性虚拟化。落地为一张状态机:
二、Shadow Page 只解决了“看见什么”
在最基本的双页模型中,同一个用户虚拟地址背后保存两份内容:
CPU 执行目标地址时看到 shadow page,因此锚点位置是一条 BRK;需要提供原始内容的路径则可以读取 original page。 这解决了“执行视图”和“读取视图”分离的问题,却立刻制造了另一个问题:BRK 占掉的那条原指令,怎样才能只执行一次,并且保持原有语义? 最直观的做法是:
三、页内 XOL:让原地址始终保持 BRK XOL,即 Execute Out of Line。 它的核心思想是:原指令不在原位置执行,而是在另一个受控位置执行。 在这里,这个位置不是额外分配的用户可执行页,而是 shadow page 页尾预留的 16 字节区域。普通断点命中后,执行路径变为:
这条路径最重要的性质是: XOL 期间,目标虚拟页仍然映射 shadow page,原断点地址的执行视图始终是 BRK。 因此,普通 XOL 不产生 stepping-original 映射,不依赖一次短暂的 original-page 执行窗口,也不会因为原地址取到原指令而削弱锚点本身。 但把指令复制到页尾,只解决了“在哪里执行”,没有证明“执行结果仍然相同”。
四、指令换了地址,语义可能已经变了 把指令搬进 XOL 槽之前,我必须先回答两个 ISA 问题:这条指令的语义是否依赖它原来的位置?它执行完会不会离开受控的收尾路径?
第一类指令把当前 PC 当作隐式输入。它们被搬到 XOL 槽后,即使机器码一个 bit 都没变,语义也可能完全不同:
例如原地址上的 ADRP 应以原 PC 所在的 4 KB 页为基准。如果直接在页尾槽执行,它计算出的基址将属于槽地址。BL 除了跳转,还必须把“原地址 + 4”写入 x30。 条件分支必须使用异常发生时真实的 NZCV 与通用寄存器状态决定方向。
第二类指令不以 PC 为输入,但会逃离收尾路径:BR、BLR、RET 一旦真的跳走,执行流就不会命中槽尾收尾点,页面可能永久停留在 STEPPING 状态。BLR 两头都占——它除了跳走,还要把返回地址写进 x30。
所以 XOL 不能只是一次 memcpy。对上述任一问题答案为“是”的指令,我不让硬件在槽地址照抄执行,而是以原断点地址为语义 PC,在异常帧上直接重建效果:
五、从单一 XOL 演进为三级执行器 随着指令覆盖增加,我把最合理的执行路径从“所有指令都进槽”改成三个层级。
第一层:纯软件执行 对可以确定重建的普通指令,直接在异常上下文中修改 pt_regs 或目标内存,不进入 XOL,不开启硬件单步,也不把页面切到 STEPPING。 当前软件执行器覆盖的类别包括移动宽立即数、加减、移位逻辑、位域、条件选择、部分 load/store 等。 它必须正确处理:
第二层:原地址语义模拟 PC-relative 与控制流指令走专门的分类、条件求值和效果应用路径。 目标地址始终以原断点 VA 计算,而不是以 XOL VA 计算。 这层处理的是“搬动必然改变语义”的指令。
第三层:硬件真实执行 SIMD、原子指令、部分复杂 load/store,以及软件执行器尚未覆盖的编码,不应该被猜测执行。 若指令本身适合页内 XOL,就发布到槽内由硬件执行;如果不能可靠进入或收回 XOL,则回退到 PTE flip-step,让 CPU 在原地址真实执行一条指令。 因此,翻页单步没有被完全删除,而是从默认路径退到了最终真值执行通道。 我把三级执行器的选择顺序概括为:
“不知道”必须导向真实执行或安全退出,不能导向猜测。
六、页尾 16 字节不是槽位,而是一套陷阱垫协议
页内 XOL 很容易被描述成:在页尾放置“原指令 + 尾部 BRK” 但只要考虑普通执行流,这个模型就不成立。 假设某个真实函数的顺序执行流到达 page+0xFEC,下一条自然落到 0xFF0。与此同时,线程 A 正在使用页尾槽执行另一个断点的原指令。 线程 B 取到的将不再是原始页尾代码,而可能是 A 的指令。 这不是“两个断点争同一个槽”的问题,而是普通执行流和 XOL owner 在共享同一个可执行地址区间。 只给断点路径加锁不能解决普通落穿,因为线程 B 根本不会先来申请这把锁。 最终,我把页尾 16 字节定义为 trap-pad:
idle 状态下四个位置全部是 BRK。 active 状态下,只有 0xFF4 短暂成为 owner 指令;0xFF0 是入口守卫,0xFF8 是 XOL 收尾点,0xFFC 继续封闭尾区。 这时槽位不再是一段“空闲代码空间”,而是一小段拥有状态、owner 和进入规则的执行协议。 锁负责串行化槽内容的发布与回收,但锁本身仍不能阻止 non-owner 从用户态进入。因此,还需要为外来执行流建立单独语义。
七、外来执行流只能执行真值页语义 当普通执行流落入 trap-pad,或者程序显式跳入页尾区域,它命中的是私有 BRK。 处理器进入外来通道后,我定下的第一条规则是:如果页面存在 active XOL,且当前线程不是 owner,原地 park/retry,不推进 PC。 这样 non-owner 不会从 handler 返回到 active 槽内继续执行,也不会把 owner 指令当成真实页尾代码。 当页面空闲时,外来通道不读取 shadow 槽内容,而是从 original PFN 读取当前 PC 对应的真值指令,并在一次 handler 中连续消化页尾执行链:
这条通道的处理骨架:
要点:链状态不落地——没有"跨 trap 记住下一 PC 属于谁"的共享标记,每次判断只依赖当前页状态、当前任务和真值页内容,所以多线程落穿互不覆盖。
链长度设置上限,并不是因为正常页尾可能拥有无限指令,而是为了防止尾区内部的自循环分支把异常 handler 困成无限解释器。 超过上限时,正确策略不是把槽内 PC 交还用户态空转,而是退出当前增强状态,恢复原生执行路径。
八、预测器重放:架构状态之外还有微架构现实
先说现象,因为它是我在这套系统里遇到过的最坏的失效形态。
断点在目标函数第一次被调用时正常命中,XOL 流程完整走完;之后同一条调用路径上,锚点永久静默 。打开全部调试输出,静默期零 BRK 异常。现场体检一切正常:state=SHADOW_X、锚点元数据完好、shadow 页里的 BRK 字节原样躺着(0xd42000e0)、live PTE 指向 shadow PFN、漂移收编计数为零。更诡异的是目标程序毫无问题——入口计数器证明第 2 到第 10 次调用确实进入了函数,返回值、副作用、计数器分毫不差。
所有健康指标全绿、功能完全正确、hook 却不触发。这是最坏的情况:我没有任何东西可修。
我按嫌疑从高到低逐个处决假说,每个都配了专门的对照实验:
最后我想用硬件断点直接观测"CPU 到底还执不执行锚点地址"——发现这颗 SoC 的自托管调试根本不可用,perf 硬件断点对正常进程都是零命中。连测量工具都是死的。
五个假说全灭之后,只剩一个自洽的结论:CPU 确实经过了那个地址,但它取到的是不含 BRK 的正确原指令 。
于是有了那个决定性实验。同一个 victim 源,唯一的变量是函数入口指令:
路径的其余部分完全一样。差别只剩一个事实:进过槽的锚点死了,没进过槽的锚点活着。
机制由此浮现:XOL 槽执行期间,CPU 的取指预测器记住了"锚点 VA → 槽 VA → 断点+4"这条取指流。之后的每次调用,对锚点地址的取指在 BRK 被解码之前,就被预测器重定向进槽内路径——而槽里恰好躺着正确的原指令。执行结果架构正确,没有任何误预测恢复点会触发自愈。BRK 永不被解码,钩子静默失效,而 TLB、I-Cache、页表、内容全部健康。 这里必须给出诚实的认识论声明:严格说,"取指预测器重定向"是当前最佳解释,而非微架构级证明。它与精确异常模型自洽——错误路径上的 BRK 在退休前就被冲刷,本就不产生异常;我也无法用计数器单独证明它,因为任何"进了槽但没解码 BRK"的机制都会被同样的修复所掩盖。所以我把它定性为强证据假说 :adrp/mov 单变量二分、TLB 与 icache 假说的专门证伪、以及修复机制的工程有效性,构成证据链;而修复方案本身对这一类根因全部收敛——无论真相是不是预测器。 这个 bug 其实见过一次。更早的一次"锚点只触发一次"事件,当时被归因为 TLB 并就此发明了 XOL——现在回看,那是同一种病,XOL 只是把重定向的目标从原页挪进了槽,没有治好它。
完成一次 XOL 后,trap-pad 会恢复为 [BRK × 4]。 理论上,下一次锚点命中应重新进入标准流程。 但我在真实 ARM64 SoC 上观察到:取指或预测状态会将执行重新带到上一代 XOL 的 0xFF4。此时 0xFF4 已经恢复成 BRK,于是形成一种特殊 trap: 它既可能是上一锚点路径的预测回放,也可能是程序真的直接跳到了页尾 0xFF4。 仅凭当前 PC 和页面空闲状态,我无法对二者做确定性区分。 我采用的是“存活证据优先、实测基率兜底”的来源裁决: 只有 slot_bp_addr 对应的锚点仍然存在,才把这次 0xFF4 trap 解释为 re-arm;否则按外来执行流处理。 回放时也不读取旧槽内容,而是重新从 original PFN 读取该锚点的原指令,并按当前寄存器状态重建一次真实锚点命中。
这个设计的准确描述为: 它不是数学证明,而是基于锚点存活证据和观测基率的工程裁决。 如果把任何 0xFF4 trap 都默认当作回放,合法 direct-entry 可能被错误“瞬移”回锚点。 如果一律当作外来流,真实回放又会执行页尾真值代码,导致跑错函数并丢失 Hook 语义。 两种误判都不无辜,因此我选择了证据更强的一侧。基率本身随负载而变——分支密集负载中,回放曾是每次调用都发生的高频事件;而基准负载里,约三百万次命中的回放计数为零。裁决规则不依赖其中任何一种极端。
九、写入槽位不等于 CPU 已经看见槽位 Trap-pad 每次从 idle 切换到 active,或者从 active 恢复 idle,都属于运行时代码发布。 ARM64 上,CPU 通过 kernel VA 写 shadow page,随后却通过 user VA 取指。 数据写入先进入 D-Cache;I-Cache 可能仍保存上一代槽内容。特别是在 big.LITTLE 和跨核调度场景中,如果只写内存、不执行正确的 Cache 维护,现象通常不是立即崩溃,而是偶发执行旧代指令。 正确的发布顺序是:
发布分两半,各自的对齐地址不同,这点很关键。 D 侧按 kernel VA 做 clean to PoU:跨进程武装时,目标 user VA 未必存在于当前 TTBR0,对一个当前上下文不可达的用户地址执行维护,不能被当成可靠发布协议;而 D-Cache 按物理一致的缓存行清理,与写入口径天然对齐。 I 侧按目标 user VA 做 ic ivau:失效必须与实际取指地址对齐。两半合起来,才等价于一次完整的 __flush_icache_range 语义。 在骁龙 8 Gen3 的实测中,我观察到全局 ic ialluis 并未可靠消除这一场景中的陈旧取指,而按 VA 的 ic ivau 路径能够生效。因此我在同 mm 的 BRK/step 上下文采用按 VA 的维护序列(D 侧 caches_clean_inval_pou,I 侧 ic ivau),而不是把一次全局 I-Cache 失效当作万能答案。 这也说明了 trap-pad 的三个独立正确性层次: 锁决定谁可以写;Cache 协议决定 CPU 看见哪一代;陷阱垫决定谁可以进入。 缺少任何一层,另外两层都不能补救。
十、单步不是一个布尔值,而是一组事务 我的系统里实际存在三种单步通道:
它们的 begin 和 finish 语义不同。 XOL finish 只需要收回 owner、恢复 trap-pad、把 PC 写回原断点后继。 flip-step finish 则必须恢复 shadow PTE,处理 TLB/Cache,并完成对应状态收尾。 如果仅凭调用路径猜测“这是哪一种单步”,一次错误配对就可能让页面永久停在 original mapping,或者把本不需要翻页的 XOL 当成 flip-step 回收。 因此我让 begin 在页状态中写入 step_mode,step handler 根据数据分派对应 finish。 begin/finish 的配对从隐式调用约定变成显式状态机约束。 这看似只是增加了一个枚举,实质上是在定义事务类型。复杂内核路径中,最危险的往往不是某一行代码写错,而是两个各自正确的半流程被错误拼接。
落到数据结构上,页对象与一次 XOL 的建立过程大致是下面的形态:
要点:STEPPING 不是标志位,而是"状态 × 属主 × 锚点 × 模式"的四元组;任何路径判断"我能不能动这页",读的是整个四元组而不是一个 bool。
十一、正确性的底线:宁可失效,不能泄漏
动态插桩系统很容易形成一种危险倾向:为了保住 Hook,异常路径也尝试继续维持 shadow 状态。 但对目标进程而言,最重要的不是“本次观测是否成功”,而是“原程序是否仍按自身语义运行”。 因此我为这套执行层定下同一族降级原则:
我把它概括为一句话: 宁可失效,不可泄漏;宁可撤掉 Hook,也不能伪造目标程序已经正确执行。 这里还有一条实现层必须严格遵守的契约: 只有确认 PTE 已恢复到 original mapping,我才能把“teardown 后原生续跑”当作成立。 一次返回失败、但页面仍保留 shadow 的 teardown,不能被上层误解为已经完成恢复。 失败路径同样需要状态验证,而不是只调用一个名字叫 teardown 的函数。
十二、这套执行视图的价格:真机开销实测
前面所有机制都必须回答一个现实问题:一次命中花多少钱。我在一加ACE5/骁龙8 Gen3 /6.1内核真机上做了同进程 BASE/HOOK 对照实测(CLOCK_MONOTONIC;先跑未挂钩的 BASE,安装裸断点后同项复测 HOOK;call 类 20-100 万次取均值,两轮独立运行取一致值):
辅助事实:预测器回放计数 rearm_count=0(约 300 万次命中零发生——该负载下实测基率为零,但合法跳入的概率不为零,故裁决仍按存活证据);漂移收编 rescue_count=0;全局锁竞争计数 glock_contended≈18 万(竞争存在,但临界区为纳秒级,不改变上表量级)。
由数据得到四个结论:
十三、时序:我说不了谎的那个视图
开销实测按另一个视角再读一遍我们可以发现,它们不只是成本,也是指纹。 异常驱动模型的每一次命中都要付 1-2 次 EL0 异常往返,这是物理下限,不存在优化掉的路线。
弱点在于检测者手里有四根线:
优势还在,但每条都要带场景限定:
检测方仍要付出的代价:
有一种反方向的对抗值得点名:持续自我测量本身就是一种压制。检测者在自己的进程里把自己的函数调用几万次完全合法,没有任何裁判会惩罚它; 子系统若识别测量模式并临时退休 hook 来躲避,防御者只需把测量常态化,hook 就在"被检测"与"不存在"之间二选一,两个结局都是防御者赢。 也有一种被拒绝的对抗:以内核为可信根,理论上可以伪造记账——把异常耗时从任务 stime 中扣除,或对目标进程喂假时钟。 但这会引入新的不一致:/proc/stat 总账对不平、per-CPU 时间对不上,指纹面反而扩大。我选择说透弱点,不去升级对抗。
时序侧信道对第三方检测者是高昂的工程门槛,对有工程决心的自我防御者是可行的工程。 它把"零成本全量扫描"变成了"需要测量装置和误报预算的主动测量",但挡不住时间维度上的积分。 残留的防线是覆盖面、对抗成本,以及用频率纪律换来的时间——是时间,不是豁免。 这是异常驱动模型刻在地基上的代价,我选择公开它而不是假装它不存在。 因为在我的认知里:所谓无痕,是指对所有可能的观察信道,hook 前后系统的可观测行为完全一致。 这个定义下,无痕 hook 不存在。 现实中存活的 rootkit、反作弊对抗、恶意软件,没有谁是真的无痕,大家都活在让对方付不起确认成本的区间里,我们都是住在里面的人。 能把检测变成一场对手可能付不起、或不敢付代价的赌博,本身已经是巨大的战术胜利。
十四、仍然存在的边界
任何把页内真实代码地址复用为 XOL 槽的方案,都应有它的剩余边界。 第一,在 active 的微秒级窗口中,0xFF4 正是 owner 的被挪指令,不是 BRK。 如果另一个线程恰好直接跳入这个精确地址,它不会先进入 handler,因此 trap-pad 无法拦截。 入口守卫解决的是从 0xFF0 落穿以及命中其他 pad 位置的外来流,并不能为 active 0xFF4 创造一个不存在的 trap。 第二,idle 0xFF4 的预测回放与合法 direct-entry,在信息论上并不总能从现有状态确定区分。 当前方案依赖锚点存活证据与负载相关的实测基率,而不声称获得绝对来源证明。 这两个边界的现实风险很低,但“低概率”与“已经证明不可能”是两回事。 我给架构结论划定准确边界,也给后续独立 XOL 页、不可执行原页尾映射或更强 quiesce 方案留下演进空间。
十五、这已经不是一个断点技巧 回头看,这套系统解决的问题已经从“如何命中一个 Hook”变成了以下一组约束:
普通 Hook 关心的是: 我能否在这里截住程序? 这套执行层关心的是: 当我改变了 CPU 看见的指令之后,如何仍然精确维护目标程序原本应该存在的那个世界? 这正是它从一个 Hook 实现走向“执行视图虚拟化”的分界线。
十六、从一条指令的双位置,走向一个进程的双视图
本文只回答了执行层问题: 一条指令怎样跨越原地址与 XOL 地址,却仍保持同一语义? 但当 shadow page 真正进入 Linux 进程地址空间后,系统还必须面对更大的问题: GUP、pagemap、mprotect、fork、exit、VMA 变化、Maple Tree、页表页生命周期、页表锁顺序,以及 teardown 与并发异常之间的闭环。
如果说 XOL 解决的是一条指令如何同时存在于两个执行位置。 那么 MM 一致性解决的就是一个进程如何同时存在于两个可观察现实中。 真正困难的部分从来不只是隐藏一次代码修改,而是让两个世界在内核所有关键路径上长期保持自洽。 本文为执行视图层上篇,MM 内存管理一致性下篇待续
结语BRK 只负责把控制权交给我们。 真正决定系统上限的,是拿到控制权之后,能否无损地把它还回去。 从原地址常驻 BRK,到页内 XOL 从 PC-relative 语义重建,到 trap-pad 从外来流链式消化,到跨核 Cache 发布 从单一路径,到软件执行、语义模拟、硬件真值执行和无损退出——这些机制最终围绕的是同一个目标:
修改执行现实,但不破坏程序语义。 这不是“更隐蔽地放一个断点”。 这是在 ARM64 上,为目标进程建立一层可控、可恢复、尽可能自洽的执行视图。
锚点指令
命中后的执行路径
结果
mov w8, #…(普通指令)
XOL 槽内执行
触发 1 次后永久静默
adrp x9, …(PC-relative)
软件模拟,从不进槽
10/10 次全部命中
场景
BASE
HOOK
说明
普通函数调用
5-9 ns/op
—
基线(bl + 函数体 + ret)
纯软件模拟命中(add 立即数)
5 ns/op
≈0.29 µs/op
一次 BRK 异常往返 + 软件重建,无 PTE/Cache 操作
数据读代码页(读窗双 flip)
5-7 ns/op
≈0.28 µs/op
读 fault + 取指 fault,两次 PTE 切换
XOL 硬件单步命中(ldxr)
9-12 ns/op
≈2.3-2.4 µs/op
两次异常(命中+收尾)+ 槽发布 + D/I Cache 维护
BB trace 命中(含记录)
5 ns/op
≈0.37 µs/op
命中 + ring 记录(emit 增量 ≈ +80 ns)
┌─────────────┐
create ──────► │ │ ◄── resume_shadow (取指 fault)
│ SHADOW_X │
┌──► │ (shadow │ ──┐
│ │ --x 稳态) │ │ 数据读 fault
│ └──────┬──────┘ ▼
│ │ ┌─────────────┐
find_bp 命中 │ └────►│ ORIGINAL │
▼ │ (r-- 读窗) │
┌─────────────┐ └─────────────┘
│ STEPPING │ owner 完成(0xFF8 收尾)
│ (owner 在步)│ ────────────────► 回 SHADOW_X
└──────┬──────┘
│ 楔死(死任务)/写退休/mremap/munmap 前置退休
▼
┌─────────────┐ 控制面重建 ┌─────────────┐
│ DORMANT │ ────────────► │ (重新武装) │
│ (退休) │ └─────────────┘
└──────┬──────┘
│ exit/teardown
▼
NONE (归还 PTE,释放 shadow)
original page:保持程序原始代码
shadow page :包含 BRK、补丁或其他执行侧修改
原地址 BRK
↓
进入内核异常处理
↓
从 original PFN 读取被替换的原指令
↓
发布到同一 shadow page 的 XOL 执行位
↓
异常返回时把 PC 指向 XOL 执行位
↓
执行原指令并命中槽尾 BRK
↓
恢复 PC = 原断点地址 + 4
能够严格软件重建 → 软件执行
位置相关或控制流 → 原地址语义模拟
无法严格证明 → 硬件真实执行
上述路径失败 → 退出增强状态,优先保全目标语义
偏移 idle(无 owner) active(有 owner) 进入者语义
────────────────────────────────────────────────────────────────────
0xFF0 BRK BRK 外来流→链式通道
0xFF4 BRK owner 的被挪指令 owner:执行点
0xFF8 BRK BRK owner:收尾点
0xFFC BRK BRK 外来流→链式通道
int handle_slot_foreign (page, regs)
{
if (page->state == STEPPING && page->stepping_task != current)
return RETRY_TRAP;
for (i = 0 ; i < CHAIN_MAX; i++) {
if ((regs->pc & PAGE_MASK) != page_addr)
return DONE;
if ((regs->pc & ~PAGE_MASK) < XOL_SLOT)
return DONE;
insn = read_truth_page(page, regs->pc);
if (read_failed)
goto teardown;
if (is_pc_relative(insn))
apply_sim_at_original_va(insn, regs);
else if (!emulate(insn, regs))
return flip_step_one(page, regs);
}
teardown:
teardown_page(page);
return DONE;
}
写入新 trap-pad 内容
↓
按 kernel VA clean D-cache to PoU
↓
DSB,保证数据清理完成
↓
按目标 user VA invalidate I-cache to PoU
↓
DSB
↓
ISB
↓
允许执行新一代槽内容
WX_STEP_XOL 页内 XOL,PTE 从未离开 shadow
WX_STEP_FLIP_EMU 模拟兜底,PTE 临时切到 original
WX_STEP_FLIP_TRACE trace 锚点,PTE 临时切到 original
enum page_state {
NONE = 0 ,
SHADOW_X,
ORIGINAL,
STEPPING,
DORMANT,
};
struct shadow_page {
u64 page_addr;
enum page_state state ;
struct task *stepping_task ;
u64 step_bp_addr;
u32 step_mode;
u64 slot_bp_addr;
u64 pfn_original;
u64 pfn_shadow;
atomic_t pte_lock;
atomic_t refcount;
};
spin_lock(&global_lock);
if (page->state != SHADOW_X || page->fork_paused)
goto busy;
page->state = STEPPING;
page->stepping_task = current;
page->step_bp_addr = bp_addr;
page->step_mode = WX_STEP_XOL;
spin_unlock(&global_lock);
slot_write_active(page, bp_addr);
regs->pc = page_addr + 0xFF4 ;
锚点指令
命中后的执行路径
结果
mov w8, #…(普通指令)
XOL 槽内执行
触发 1 次后永久静默
adrp x9, …(PC-relative)
软件模拟,从不进槽
10/10 次全部命中
场景
BASE
HOOK
说明
普通函数调用
5-9 ns/op
—
基线(bl + 函数体 + ret)
纯软件模拟命中(add 立即数)
5 ns/op
≈0.29 µs/op
一次 BRK 异常往返 + 软件重建,无 PTE/Cache 操作
数据读代码页(读窗双 flip)
5-7 ns/op
≈0.28 µs/op
读 fault + 取指 fault,两次 PTE 切换
XOL 硬件单步命中(ldxr)
9-12 ns/op
≈2.3-2.4 µs/op
两次异常(命中+收尾)+ 槽发布 + D/I Cache 维护
BB trace 命中(含记录)
5 ns/op
≈0.37 µs/op
命中 + ring 记录(emit 增量 ≈ +80 ns)
SHADOW_X :映射为 shadow --x。execute-only 由 AP/UXN 位直接构成——AP 位只约束数据访问、不约束取指;FEAT_EPAN 再把这份阻断扩展到 EL1,连内核自己的 uaccess(copy_from_user 的 LDTR)也读不到这页。这是稳态。
ORIGINAL :数据读 fault 触发的短暂窗口,映射为 original r--;下一次取指 fault 立即翻回。读窗口是微秒级的,由硬件 fault 驱动,没有定时器。
STEPPING :某线程正在 XOL 或翻页单步中。页的槽区、状态、属主三者绑定。
DORMANT :退休态。hook 失效但目标语义完整——退休是这台机器里最常见的"失败"形式,而它被设计成一种正常状态而非错误 。 异常路由不用任何 inline hook:BRK 指令带立即数,内核原生 register_user_break_hook 按 imm 自动路由;imm 段是子系统的私有命名空间,路由是一次查表,不与系统里其他 break hook 使用者的语义重叠。单步由 register_user_step_hook 收编。 这意味着子系统自身在目标进程的 original 代码页里零字节落地 ——唯一的痕迹在 shadow 页里,而 shadow 页对进程不可见。
ADR、ADRP;
LDR literal、LDRSW literal;
B、BL;
B.cond;
CBZ、CBNZ;
TBZ、TBNZ。
根据原地址计算分支目标;
正确更新 x30(先读出分支目标寄存器,再写 LR);
从真值页读取 literal;
使用真实 NZCV 求值条件;
间接控制流用真实寄存器求值,绝不放进槽里执行;
最后写回正确的 regs->pc。 这里的原则不是模拟机器码,而是模拟原地址语义。
32/64 位结果截断;
SP 与 ZR 在不同编码位置上的差异;
N/Z/C/V 更新;
地址写回;
load 的符号扩展;
寄存器对和不同寻址模式。 这条路径的价值不只是性能。 它完全避开共享 XOL 槽,因此同页密集断点之间不再为了执行一条常见指令竞争槽位,也减少了预测器重定向与槽代际复用的暴露面。
两个普通落穿线程不会覆盖彼此的链状态;
新一代 XOL 改写槽内容,不影响正在解释的旧落穿链;
non-owner 不会执行 owner 的 active 指令;
跨页分支一旦离开当前页面,立即终止本页解释;
绝不拿旧页的 original PFN 解释新页面的指令。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 4天前
被mb_iiulbilc编辑
,原因: