首页
社区
课程
招聘
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)
发表于: 2天前 2191

[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)

2天前
2191

上一篇文章讨论的是一条 ARM64 指令:
原地址保持 BRK 后,原指令如何通过软件语义、页内 XOL 或无损降级被正确执行
上篇链接:https://bbs.kanxue.com/thread-292226.htm
这篇文章讨论的是整个进程:
当取指、读取、写入、GUP、pagemap、mprotect、fork、mremap、munmap 和进程退出同时作用于一个地址空间时,original 与 shadow 两套视图如何保持一致?
前者解决一条指令的语义不能丢。
后者解决一个进程的视图不能乱。

一、拥有两个页面,不等于拥有双视图系统
R^X Shadow Page 的基础思想很直接
对于同一个用户虚拟地址,系统同时保存两个物理页:

不同访问目的看到不同页面:

仅看这两个箭头,它似乎已经是完整的双视图。
但 Linux 中“观察内存”并不只有两条路径。

/proc/pid/memprocess_vm_readv、ptrace、GUP、pagemap、mprotect、fork、COW、页缺失、VMA 拆分合并、mremap 搬移和进程退出,都可能以不同方式接触同一份映射。
它们不会自动经过同一个统一入口。
因此完整问题不是读操作返回 original,执行操作返回 shadow。
而是对所有能够观察、复制、修改或销毁地址空间的路径,系统都必须定义它应该看到哪个视图,并保证不同路径给出的答案相互一致。
我把这套机制称为进程级双视图虚拟化:为同一进程地址空间维护 original 与 shadow 两套可并存的物理现实,根据访问目的选择视图,并在 MM 生命周期、并发和失败条件下维持跨观察面的一致性。

二、谁应该看到哪个世界
先把系统中的主要观察者列成一张视图矩阵:

这张表暴露了一个关键事实
双视图一致性不是某个 Page Fault handler 能够独立完成的功能。
每增加一个观察面,就多出一套并发规则、锁上下文和生命周期约束。
只要其中一条路径仍然直接看到 shadow,整个无痕结论就不成立,只要其中一条恢复路径把 PTE 永久留在 original,Hook 又会静默失效。

真正的系统必须同时防止两类错误:

需要说明:这张表只回答“每条路径该看到谁”,没有回答“何时切换”。后文的每一条规则,几乎都对应至少一次真机事故;

三、GUP 隐藏为什么比普通读缺页更危险
普通读取 shadow execute-only 页面时,可以通过权限 Fault 暂时呈现 original。
/proc/pid/memprocess_vm_readv 和 ptrace 等路径通常会进入 GUP。GUP 不一定按照目标进程自己的用户态读权限完成访问,它会沿页表找到实际页面并取得引用。
如果实时 PTE 指向 shadow PFN,那么 GUP 最终可能直接拿到 shadow page。
原始方案中的一个重要技巧是:

不刷 TLB 的意图是GUP 通过页表看到 original,而目标 CPU 仍然从旧 TLB 中使用 shadow。
这个技巧非常巧妙,但它同时建立了一个危险窗口:

也就是说系统主动允许页表与 TLB 短时间表达不同现实。
要让这个窗口可控,我补上了几个约束。

临时放回的 original 不是完整的 r-x,而是:

数据读取仍能看到干净字节,但其他 CPU 如果发生 TLB miss 并试图从该映射取指,会因为 UXN 进入执行 Fault,而不是静默绕过 Hook 执行原代码。
执行 Fault 再进入 rescue 路径,等待隐藏事务结束并恢复 shadow。

这里有两个细节
第一,hidden-original 的权限模板必须显式清除 DBM 位——ARM64 上 RDONLY|DBM 在硬件语义下是可写(硬件自动置脏位而不产生写 fault)。模板从 live PTE 继承权限位时若不清除,"只读窗口"实际是可写的,窗口内目标线程的写会直接落进 original PFN、绕过写 fault 退休路径。
第二,"begin 不换不刷"是经高通骁龙8 Gen 3 实测的目标平台取舍,不是 ARM64 架构正确性结论:它的安全性论据是 hidden-original 的 UXN 使 TLB-miss 取指必 fault,两个共存翻译执行任一都收敛;但 ARM ARM 对同 VA+ASID 冲突翻译允许实现定义行为,移植其他平台需重评或改严格 BBM。finish 一侧则始终走完整 BBM(切换前后两次 TLBI),"end 也不刷"的说法经核实并不成立。

写 GUP 是例外,也必须在协议上单独定义:FOLL_WRITE 的窗口可能长达一次 COW 分配,pte_lock 是自旋锁且持锁期间关闭抢占,跨不过去。因此写 GUP 采用"begin 放锁、finish 前重新拿锁"的契约,finish 内按写语义退休页面(详见"写"一章)。读窗持锁、写窗重拿,两种形态都是显式协议,而不是默认行为。

这里需要特别区分两把锁:

page->pte_lock 保护“这次转换属于哪个事务”,内核 PTL 保护“这一刻 PTE 中写了什么”。
二者职责不同,不能互相代替。

finish 写回必须重新校验
begin 用 expect_pfn 校验"此刻 PTE 确实指向 shadow"才动手;finish 写回 shadow 之前,同样要校验 live PTE 仍然等于 begin 写入的 original PFN——窗口内映射可能被并发改动(zap 后被回收换成 swap entry、COW 换新页)。
"begin 校验、finish 不校验"曾是一个真实的不对称漏洞:失配时不回写、交 rescue 收编。另外,取 PFN 之前必须先确认 PTE present——swap/migration entry 的"PFN"字段是编码不是物理页号,直接取用会把野物理页装回页表。

fast GUP 是天然闭环
shadow PTE 不带 PTE_USERpte_access_permitted 必然拒绝,fast GUP 只能 fallback 到已经隐藏的 slow 路径。这条旁路不需要任何额外代码就是闭合的——但它依赖上游 6.1 的标准行为,如果内核回合了 gup-fast 的权限放宽补丁,需要重新评估。

不再跨调用保存裸 ptep
旧式实现如果在 begin 阶段取得一个 PTE 指针,然后把裸指针带到 finish 阶段,就默认了两次调用之间页表层级不会被拆除、替换或回收。
这个假设在并发 munmap、mprotect、THP 变化和页表回收下并不可靠。
当前实现只保存 PTE 的值,不保存跨阶段裸指针。finish 时重新走表、重新取得 fresh ptep,并在对应 PTL 下写回。
这不是代码风格上的变化,而是生命周期模型的变化:
页表项内容可以跨阶段保存,页表项地址不能在没有生命周期保证时跨阶段保存。

四、为什么 pagemap 不能依赖 GUP 隐藏

完成 GUP 隐藏后,/proc/pid/mem 可以读到 original,似乎已经足够。
/proc/pid/pagemap 不走 GUP。
它直接读 PTE 并从中提取 PFN——修正点必须挂在 pte_to_pagemap_entry 这类"PTE → pagemap entry"的转换路径上,而不是任何 GUP 路径。
因此可能出现:

两条观察路径分别看似合理,组合起来却暴露了双视图的存在。观察者甚至可以重复采样 PFN,发现页面在 original 与 shadow 之间跳变。
所以 pagemap 需要单独的语义修正:

还有一层更细的一致性:pagemap 除了 PFN 还暴露 flags。合成 shadow PTE 时必须继承原 PTE 的 SOFT_DIRTY 位,否则被 hook 页的 pagemap flags 与同 VMA 其他页不一致,本身就成为检测特征。

这件事揭示了双视图虚拟化的一个基本规律:
不能因为两个接口最终都“与内存有关”,就假设它们经过同一条内核路径。
视图一致性必须按真实调用链审计,而不能按用户态功能名称猜测。

五、mprotect 是一个 PTE 规范化器

mprotect 是我认为最容易被低估的一条路径。
shadow PTE 的权限不是普通 VMA 权限的自然产物。它可能是人为构造的 execute-only 映射,而 VMA 本身仍然表达正常代码段语义。
当用户调用 mprotect 时,内核会根据新的 vm_flagsvm_page_prot 重新规范化 PTE 权限。
它通常保留 PFN,但重新计算权限位。
于是可能出现:

BRK 和 Patch 此时不需要经过 GUP,普通用户态读取就可以直接看到(实测 read[0]=0xd42000e0,正是我们的 BRK 编码)。
因此mprotect 不能只在结束后“检查一下权限是否变了”。如果仅做事后修复,change_protection 与修复之间就存在真实泄漏窗口。
当前实现采用 prepare/resync 两阶段协议。

mprotect prepare

change_protection 之前,调用者已经持有 mmap_write_lock。系统把范围内的代码 shadow 页预先切成:

这样接下来内核规范化的是 original 映射,shadow PFN 根本不在 PTE 中。

窗口内:

mprotect resync
change_protection 完成、VMA 拆分或合并结束后,再根据最终 VMA 语义决定页面命运:

这里使用的依旧是“宁可失效,不可泄漏”的策略。
系统不能为了维持 Hook,违反用户真实要求的 VMA 权限;也不能在无法重建安全 shadow 时继续留下一个可读的 Patch 页面。
mprotect 由此不再是一个需要回避的破坏者,而变成了一个权限语义 oracle:
Linux 先告诉我们这个 VMA 最终应该是什么,双视图层再决定是否仍有条件维持 shadow。

还有一个工程细节:prepare/resync 还必须尊重后文将定义的不变式——STEPPING 不是单一形态。XOL 单步期间 live PTE 仍是 shadow,prepare 必须跳过这类页、resync 必须在收尾后重钉 --x;否则 owner 会被 SIGSEGV 误杀,或者 shadow 被规范化成用户可读造成永久泄漏。

六、fork:不能让子进程继承另一个宇宙

fork 复制地址空间时,copy_page_range 看到什么,子进程就可能继承什么。
如果父进程此刻的 PTE 指向 shadow PFN,直接复制会带来两个问题:

因此,在 dup_mmap 持有父进程 mmap_write_lock 的上下文中,需要建立 fork 事务:

pause 的收集谓词是这个事务最容易写错的地方。它不能是"状态 == SHADOW_X",而必须是"live PTE == shadow"——XOL 单步期间状态是 STEPPING 但 PTE 没有翻走,ORIGINAL 读窗期间状态是 ORIGINAL 而 VMA 语义要求可执行,这两类页都曾漏出收集范围,导致子进程继承 shadow PFN 或拿到不可执行的映射。对 XOL 在飞的页,pause 前还要做有界 drain 等它收尾。

fork_paused 不是普通状态标签,而是一道并发闸门。
BRK、XOL、GUP hide、release 等路径在进入时必须检查它。否则可能发生:

还有一个很容易写出的死锁:
dup_mmap 已经持有 mmap_write_lock,此时内部辅助函数如果再调用 mmap_read_lock,就会发生同线程递归锁死。
所以 fork 路径必须有 caller-locked 版本:

“这个函数会改 PTE”并不足以定义它的锁行为。必须先定义它从哪个调用上下文进入。

七、一条反复付出代价的不变式:STEPPING ≠ PTE 已翻走

系统里存在三种单步通道:

于是真正的不变式是:

状态与实时映射的对应关系应该是:

几乎所有 MM 消费者最初的假设都是"STEPPING ⇒ PTE 已翻走"或者"只查 SHADOW_X",于是 XOL 引入后每个消费者各漏一个洞:

修复方式不是逐点打补丁,而是把"XOL ⇒ PTE 恒为 shadow"提升为成文的状态机不变式,然后按"不变式 × 消费者"矩阵逐格重审:begin 族在 GUP/mprotect 窗口内拒绝起步、exec fault 对窗口内 STEPPING 页走 park、GUP 与 pagemap 放行 XOL 形态、prepare 跳过、resync 重钉。
这件事的教训写成一句话:每新增一个执行通道或状态,所有既有消费者必须按矩阵重审一遍——遗漏不会报错,它只会在窄窗口里静静等待。

八、写:第三种访问的收敛规则

视图矩阵里最容易被漏掉的一行是"写"。目标进程写自己的代码(POKETEXT 自愈、JIT、自修改),或者调试器经 ptrace 强写,都同时触碰两个视图:写若落在 shadow,读侧(original)不可见,双视图发散;写若被静默丢弃,目标语义损坏。
逐项推演后,写路径的语义其实是闭环的:

不变式只有一条:写后两个视图必须收敛到 original。至于写后 Hook 是否复活,那是控制面议题——必须按新代码重新定位锚点,内核不按旧地址盲锚新内容。
真机验证:ptrace POKETEXT 改写被锚代码页后,victim 自读即写入值,写后页面干净退休。

九、munmap/mremap:映射被搬走或拆掉时

fork 复制映射,mremap 和 munmap 则直接搬移和销毁映射,它们的问题更原始:
move_page_tables 按原值逐条 copy_present_pte,不做任何规范化。自定义的 --x shadow PTE 被原样搬到新 VA——既触发 print_bad_pte,又在新地址留下一份不受任何状态机结算的 shadow 映射(真机 UAF splat 实锤)。munmap 的 zap 同族:rmap 对匿名页做引用结算时,遇到自定义 PTE 形态同样会算错。
修复是第九个内核挂钩点:preflight retire。在 move_page_tablesdo_mas_munmap 的 zap 之前,把区间内所有 shadow 页还原 original——内核随后搬移/拆除的就是普通映射,双视图层在控制面决定是否在新位置重建。
真机验证:对被锚代码页做 mremap(MAYMOVE|FIXED) 搬到 +2MB 新址,进程存活,新址读到原字节,调用正常返回。

十、EL1 也在读你的页

execute-only 的阻断依赖 ARM64 的 AP 位只约束数据访问、不约束取指;FEAT_EPAN 再把这份阻断从 EL0 扩展到 EL1。这意味着内核自己的 uaccess 也读不到 shadow 代码页——copy_from_user 在 ARM64 上走 LDTR,同样被 EPAN 拦截。
真机现象:目标进程执行 write(1, 代码页内的字符串字面量, n),syscall 的 copy_from_user 陷入 DABT_CUR(EL1 数据异常);如果只分类 EL0 的 fault,内核原流程看到"PTE present 但访问被拒",把它当作已解决的 spurious fault 重试——syscall 无限循环,进程 R 态烧满 stime,SIGKILL 都杀不进去。
这是"CPU 视图与 syscall 视图一致性"类缺陷的最后一个洞:GUP hide 只覆盖 /proc/pid/mem 那条路,copy_from_user 根本不走 GUP。
修复:fault 拦截扩展识别 DABT_CUR 权限 fault。EL1 读——指令字从内核文本侧读取,复用 load 模拟引擎从 original PFN 取真值、写回 EL1 异常帧的目标寄存器;无法模拟则交还内核(uaccess extable 修复为 -EFAULT)。EL1 写——与 EL0 写同语义,退休 Hook 后内核继续写。
CPU 视图与 syscall 视图的一致性就是隐身性的全部。
反面对照是 VMA-less ghost memory 路线的架构级硬伤——CPU 认 PTE 能访问,find_vma 族 syscall 必返回 -ENOMEM,"CPU 说能读、syscall 说没有"是廉价阳性指纹,任何 EL1/EL2 手段都修不干净。

十一、同一状态转换需要三种锁接口

随着调用路径增多,我最终把页表状态转换分成了三类。

这些函数不能相信调用者之前取得的 VMA 指针。进入锁域后必须重新 find_vma,确保检查和使用属于同一个稳定视图。

它解决的不是性能问题,而是 rwsem 递归和锁序问题。

三种接口实现的是同一套状态语义,但它们的锁契约不同。
把它们强行合并成一个“万能函数”,通常会得到两种结果之一:

十二、为什么必须固定页表页,而不是只固定 mm

mmgrab() 可以保证 mm_struct 不会被释放,但它不能保证某个地址对应的中间页表页永远存在。
并发 munmap 可以清除映射并释放页表层级。如果异常路径保存或重新解引用未经保护的页表页,就可能发生 UAF。
因此在 shadow 页面建立时,系统会取得相关页表页的引用,并在页面对象最终释放时配对 put_page()
这形成两个不同层面的生命周期:

mm_usersmm_count 也不能混为一谈。

这里需要的是对象生命周期引用,而不是让退出中的用户地址空间继续表现为活跃进程。因此使用 mmgrab/mmdrop 固定 mm_count,并在 wxshadow 页面引用归零时释放。
否则,一个 teardown 失败后残留的 page->mm 指针,可能在原 mm_struct 被 SLUB 复用后错误匹配另一个进程。
这类错误往往不会立即崩溃,它会让一个旧双视图对象开始干预一个完全无关的新地址空间。

还有一个悬在头顶的边界需要记录:THP。挂接路径建页时已拆分块映射,但 hooked 匿名页在页面生命周期内仍可能被 khugepaged 重新 collapse 成 2MB——PMD 变 trans_huge 后原子走表全部失败,状态机收不回来。目前的取舍是:目标场景的代码页不在 THP 策略内,记录在册;若未来覆盖匿名数据页的高频场景,需要在 collapse 路径再加一个挂钩点。

十三、Maple Tree 之后,裸 find_vma 更不能当作永久证明
较新的 Linux 内核使用 Maple Tree 管理 VMA。
这改变了遍历结构,却没有改变一个基本事实:
VMA 指针的生命周期安全,不等于 VMA 语义在并发修改期间保持稳定。
在普通进程上下文中,可靠做法仍然是:

在无法获取 mmap 锁的异常上下文中,可以使用 RCU 读侧进行范围预检,避免 VMA 对象在解引用期间被释放。
但 RCU 预检只能回答这个对象在我读取时是否仍然具有生命周期?
它不能独立证明:
这个地址在整个后续事务中仍然属于同一 VMA,并保持相同权限和页表身份。
因此,原子路径中的 VMA 检查只是一道快速预检。真正的提交闸门仍然是:

这相当于把判断分成两层:

只有两者都成立,状态转换才允许提交。
需要承认的是,规则确立之后仍然会有漏网调用点:我在控制面建页路径上发现一个裸 find_vma(无锁无 RCU,并发 munmap 即 UAF 读),它躲过了此前那一轮 RCU 化。

十四、真正危险的死锁往往跨 CPU
单机测试中最隐蔽的一类问题,是持有 page->pte_lock 时进行全局 TLB 或 CPU 同步。
假设 CPU 0:

此时 CPU 1 正在 BRK 异常上下文:

CPU 1 因为在等待锁而无法完成 CPU 0 所等待的同步,CPU 0 又不会释放锁,形成跨核 ABBA:

所以 TLB 和 context synchronization 的位置不能只根据“PTE 已经写完”来决定,还必须纳入跨 CPU 锁序。

当前规则是:
page->pte_lock 内完成最短的状态和 PTE 提交;需要 flush_tlb_mm()kick_all_cpus_sync() 等可能等待其他 CPU 的操作,移出该锁的临界区。
这要求状态转换具有明确的提交点。离开锁以后,其他路径可能再次观察页面,因此必须保证:

但"移出临界区"本身不是终点。
两条原子收尾路径的 IPI-wait 确实早已移出 pte_lock,落点却仍在 IRQ-off 的 step handler 里——smp_call_function 的内核契约明文禁止关中断调用:两个核同时执行 kick_all_cpus_sync(),各自关中断等对方应答 IPI,照样跨核 ABBA。
规则要从"IPI-wait 移出锁"补全为"IPI-wait 只出现在可调度上下文";落点上下文和锁序一样,都是规则的一部分。

高并发内核代码的困难,往往不在“用了几把锁”,而在于:
某把锁保护的状态,是否允许在等待另一个 CPU 时保持冻结?

十五、状态机正确,不代表实时 PTE 正确

软件中可能记录:

但实时 PTE 却已经指向 original PFN。
这可能来自历史错误分支、finish 配对错误、并发 mprotect、GUP 隐藏中断或 TLB/PTE 收尾不完整。
如果系统只相信状态字段,就会出现一种非常危险的静默失效:

因此双视图状态不能只由枚举值定义。
更完整的状态其实是:

其中 step_mode 值得单独解释。系统有三条单步通道(XOL / FLIP_EMU / FLIP_TRACE),它们的 begin 和 finish 语义不同:XOL finish 只收状态,flip finish 必须恢复 shadow PTE 并处理 TLB/Cache。
如果仅凭调用路径猜测"这是哪一种单步",一次错误配对就可能让页面永久停在 original,或者把本不需要翻页的 XOL 当成 flip 回收。所以 begin 在页状态中写入通道标识,step handler 按数据分派对应 finish——begin/finish 的配对从隐式调用约定变成显式状态机约束。
当前实现还增加了状态漂移检测与 rescue:

rescue_count 不应该被当作“系统修复能力很强”的成绩。
它更适合作为金丝雀:
正常情况下应当长期为零;一旦非零,说明某条路径曾经打破状态与现实的一致性。
能恢复是一层保险,找出为什么需要恢复才是最终目标。

最后,自愈逻辑自己也需要不变式约束。GUP 隐藏和 mprotect prepare 期间,state==SHADOW_X && live PTE==original 是设计内形态而不是漂移——这两个窗口必须用显式标记(gup_hidingmp_prepare_pending)告诉 rescue"别收编"。
没有这道区分,自愈系统会把自己的故意窗口当成事故修复,自愈本身变成破坏者。

十六、退出不是释放内存,而是终止所有观察关系

进程进入 exit_mmap 后,Linux 会开始拆 VMA、清 PTE 和回收页面。
如果 shadow PFN 仍然安装在普通用户 PTE 中,后续通用回收路径可能把它当成不符合 VMA 语义的页面,产生引用异常或 Bad page map 一类问题。
因此,wxshadow 必须在普通 zap 流程之前:

这里不能简单遍历列表然后逐个释放。

列表遍历、对象引用和并发 handler 可能交错,因此需要“从全局可发现集合摘除”和“等待对象引用归零”分离:

dead 不是“内存已经释放”,而是“对象已经失去对外可发现性”。
这是内核生命周期管理中很重要的区别。

引用归还也有一条铁律:判定、摘除、归还必须在同一临界区内完成。teardown 曾经把"页是否在列表里"的快照拍在临界区之前、把引用归还放在 dead 守卫之外——两个并发 teardown 各自归还一次 list 引用,refcount 下溢,结构体在仍有持有者时被释放。
正确形态只有一句:谁赢得 dead 迁移,谁归还 list 引用。
另外,exit 路径的所有锁获取只能用 trylock:目标 mm 的 mmap 锁可能正被退出流程自己持有,阻塞等待在这里等于自杀;trylock 失败则保留页面、走失败页熔断,绝不让清理过程变成新的死锁源。

十七、卡在 STEPPING 的线程也属于 MM 状态

如果一个任务在 XOL 或 flip-step 中退出、被杀、长期停止或永远不再调度,页面可能永久停在 STEPPING
此时简单等待会让:

因此“任务是否还活着”不能只靠保存的 task_struct * 判断。页面保存的是裸指针、不持引用——task_struct 释放后槽位可能被 SLUB 复用,指针本身永远"可读",但身份已经换了。证死必须走另一条路:记录 PID 与进入续执行的时间,在 RCU 下按 PID 查证——pid 消失或 exit_state/TASK_DEAD 即证死。
系统实际区分三态:

判楔成立后系统进入 teardown,使页面收敛回 original,而不是永久等待一个不存在的 finish。
这里仍然遵循相同原则:

Hook 可以失效,地址空间不能永久卡在中间态。
真机验证:对被锚进程做 SIGSTOP/SIGCONT ×20(命中即停 2 秒再续),hook 不被误拆,进程完整跑完;对多线程锤击中的进程直接 SIGKILL,楔死判定与 exit_mmap 正常收敛,系统无恙。

十八、双视图系统真正需要的是收敛,而不是永不失败

内核中不可能保证所有状态转换永远成功。
VMA 可能已经消失,PFN 可能因 COW 改变,PTE 可能被并发修改,任务可能退出,TLB 同步也可能出现在无法继续原协议的上下文。
因此,系统需要的不是“所有路径都返回 0”,而是每个失败都必须有明确终态。
我最终把失败结果归纳为三类:

禁止出现第四种结果:

这就是状态漂移。
与三类终态配套的是失败返回值的归并纪律:"忙 / 窗口期 / 形态不符"统一为可重试语义,绝不把内部私有 BRK 误送成用户态 SIGTRAP;ERROR 只留给真正"不是我的断点"。以及一条常被忽略的契约:失败路径同样需要状态验证——只有把"PTE 已恢复到 original"确认到手,"teardown 后原生续跑"才成立,不能只调用了一个名字叫 teardown 的函数就当作完成恢复。
双视图架构的质量不取决于正常路径有多漂亮,而取决于所有异常路径是否最终汇聚到少数可证明的稳定状态:

十九、为什么这已经不再只是 Hook

如果只看使用方式,这套系统仍然可以被叫作无痕 Hook:
原地址放置 BRK;
读取时返回原字节;
命中时观察或修改寄存器;
原指令继续执行。
但从内核内部看,它实际维护的是一组更广泛的映射规则:

为了维持这些规则,系统必须控制:

页面内容;
PTE 权限与 PFN;
TLB 和 I-cache;
VMA 生命周期;
页表页生命周期;
mm_struct 生命周期;
任务生命周期;
跨路径锁序;
多 CPU 状态同步;
失败后的最终收敛。

Hook 只是其中一个入口。

更准确地说,这套系统正在做的是为同一个进程创建两个可观察现实,并让 Linux MM 中的不同观察者被路由到各自正确的视图。
这就是我所说的进程级双视图虚拟化。
它和传统虚拟机的区别是:它没有虚拟出完整 CPU 和操作系统。
它和普通 Shadow Memory 的区别是:shadow 不只是元数据或检测副本,而是真正参与 CPU 取指和用户态执行。
它和单纯无痕 Hook 的区别是:它不只隐藏修改结果,还必须维持跨内核子系统的一致性。

二十、工程验证

以上规则在一加 ACE5 / 骁龙 8 Gen3 / 6.1 内核真机上验证,关键数据:

结语
原始 wxshadow 完成了最重要的第一步:证明同一个虚拟地址可以在读取与执行之间呈现不同物理页面。
页内 XOL 解决了第二步:执行原指令时,不再要求原地址退出 shadow 世界。
而完整的 MM 重构解决的是第三步:
当 Linux 内核从不同路径观察、复制、修改和销毁这个地址空间时,两个世界仍然必须各自成立,并最终收敛到正确状态。
因此这套子系统工作的重点已经不再是如何把一个 Hook 藏起来
而是如何在 Linux MM 中创建两个现实,并阻止它们在并发和生命周期变化中相互污染
你改变了一个现实,就要对所有观察这个现实的信道负责
指令级对 PC-relative 负责,进程级对 MM 全部路径负责,认识论级对物理世界负责。

一条指令的语义不能丢
一个进程的视图不能乱
这两条约束合在一起,才构成了我理解中的进程级双视图虚拟化。


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

收藏
免费 2
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回