首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
9
8
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)
发表于: 2026-7-31 19:44
32738
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)
mb_iiulbilc
2026-7-31 19:44
32738
上一篇文章讨论的是一条 ARM64 指令: 原地址保持 BRK 后,原指令如何通过软件语义、页内 XOL 或无损降级被正确执行 上篇链接:https://bbs.kanxue.com/thread-292226.htm 这篇文章讨论的是整个进程: 当取指、读取、写入、GUP、pagemap、mprotect、fork、mremap、munmap 和进程退出同时作用于一个地址空间时,original 与 shadow 两套视图如何保持一致? 前者解决一条指令的语义不能丢。 后者解决一个进程的视图不能乱。 一、拥有两个页面,不等于拥有双视图系统 R^X Shadow Page 的基础思想很直接 对于同一个用户虚拟地址,系统同时保存两个物理页: ```text original PFN 保存真实代码 shadow PFN 保存 BRK、Patch 或其他执行修改 ``` 不同访问目的看到不同页面: ```text CPU 取指 → shadow 代码读取 → original ``` 仅看这两个箭头,它似乎已经是完整的双视图。 但 Linux 中“观察内存”并不只有两条路径。 `/proc/pid/mem`、`process_vm_readv`、ptrace、GUP、pagemap、mprotect、fork、COW、页缺失、VMA 拆分合并、mremap 搬移和进程退出,都可能以不同方式接触同一份映射。 它们不会自动经过同一个统一入口。 因此完整问题不是读操作返回 original,执行操作返回 shadow。 而是对所有能够观察、复制、修改或销毁地址空间的路径,系统都必须定义它应该看到哪个视图,并保证不同路径给出的答案相互一致。 我把这套机制称为进程级双视图虚拟化:为同一进程地址空间维护 original 与 shadow 两套可并存的物理现实,根据访问目的选择视图,并在 MM 生命周期、并发和失败条件下维持跨观察面的一致性。 二、谁应该看到哪个世界 先把系统中的主要观察者列成一张视图矩阵: | 内核路径 | 应呈现的视图 | 主要风险 | |---|---|---| | CPU 取指 | shadow | TLB、I-cache、并发 PTE 切换 | | 普通数据读取 | original | fault 后必须恢复执行视图 | | 写入(POKETEXT/JIT/自修改) | 双视图收敛 original | 写对读侧不可见、执行侧被改的歧义 | | `/proc/pid/mem` | original | 经 GUP 读取实际 PFN | | `process_vm_readv` | original | remote GUP 路径 | | ptrace 读取 | original | GUP/访问进程内存 | | GUP/pin page | original | shadow PFN 和 Patch 泄漏 | | pagemap | original PFN | 它直接读取 PTE,不经过 GUP | | 内核 uaccess(syscall 读写用户页) | original | EPAN 下 EL1 的 LDTR 同样被阻断 | | mprotect | 根据新 VMA 语义重建 | PTE 权限被内核规范化 | | fork 子进程 | original | 子进程不能继承私有 shadow PFN | | munmap/mremap | 前置 retire 还原 original | 内核按原值搬移/zap PTE,不规范化 | | COW | 失效、重建或退出双视图 | 映射身份发生变化 | | exit_mmap | original 后回收 | shadow 页不能进入普通匿名页回收路径 | | teardown | 收敛到 original | 在途 BRK、引用和锁序 | 这张表暴露了一个关键事实 双视图一致性不是某个 Page Fault handler 能够独立完成的功能。 每增加一个观察面,就多出一套并发规则、锁上下文和生命周期约束。 只要其中一条路径仍然直接看到 shadow,**整个无痕结论就不成立**,只要其中一条恢复路径把 PTE 永久留在 original,Hook 又会静默失效。 真正的系统必须同时防止两类错误: ```text 观察侧错误:本应看到 original,却看到了 shadow 执行侧错误:本应执行 shadow,却永久回到了 original ``` 需要说明:这张表只回答“每条路径该看到谁”,没有回答“何时切换”。后文的每一条规则,几乎都对应至少一次真机事故; 三、GUP 隐藏为什么比普通读缺页更危险 普通读取 shadow execute-only 页面时,可以通过权限 Fault 暂时呈现 original。 但 `/proc/pid/mem`、`process_vm_readv` 和 ptrace 等路径通常会进入 GUP。GUP 不一定按照目标进程自己的用户态读权限完成访问,它会沿页表找到实际页面并取得引用。 如果实时 PTE 指向 shadow PFN,那么 GUP 最终可能直接拿到 shadow page。 原始方案中的一个重要技巧是: ```text GUP begin: PTE 临时写回 original 不刷新目标进程 TLB GUP 走表: 读取到 original PFN GUP end: PTE 写回 shadow ``` 不刷 TLB 的意图是GUP 通过页表看到 original,而目标 CPU 仍然从旧 TLB 中使用 shadow。 这个技巧非常巧妙,但它同时建立了一个危险窗口: ```text 页表中的现实:original 某些 CPU TLB 中的现实:shadow 发生 TLB miss 的 CPU:重新走表,可能看到 original ``` 也就是说系统主动允许页表与 TLB 短时间表达不同现实。 要让这个窗口可控,我补上了几个约束。 1. 隐藏窗口映射为 hidden-original 临时放回的 original 不是完整的 `r-x`,而是: ```text original PFN + readable + UXN ``` 数据读取仍能看到干净字节,但其他 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 也不刷"的说法经核实并不成立。 2. `pte_lock` 覆盖整个 begin/end 窗口(读 GUP) GUP begin 成功后,不立即释放 wxshadow 的页面事务锁,而是一直持有到 GUP finish。 这样release、fault、fork、mprotect 和其他 PTE 改写不能插入 begin/end 中间,把隐藏窗口改造成不可恢复状态。 写 GUP 是例外,也必须在协议上单独定义:FOLL_WRITE 的窗口可能长达一次 COW 分配,`pte_lock` 是自旋锁且持锁期间关闭抢占,跨不过去。因此写 GUP 采用"begin 放锁、finish 前重新拿锁"的契约,finish 内按写语义退休页面(详见"写"一章)。读窗持锁、写窗重拿,两种形态都是显式协议,而不是默认行为。 这里需要特别区分两把锁: ```text page->pte_lock wxshadow 自己的单页事务锁 序列化该 shadow 页的状态转换 kernel ptl Linux 页表叶子锁 保护真实 PTE 内容 ``` `page->pte_lock` 保护“这次转换属于哪个事务”,内核 PTL 保护“这一刻 PTE 中写了什么”。 二者职责不同,不能互相代替。 3. 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"字段是编码不是物理页号,直接取用会把野物理页装回页表。 4. fast GUP 是天然闭环 shadow PTE 不带 `PTE_USER`,`pte_access_permitted` 必然拒绝,fast GUP 只能 fallback 到已经隐藏的 slow 路径。这条旁路不需要任何额外代码就是闭合的——但它依赖上游 6.1 的标准行为,如果内核回合了 gup-fast 的权限放宽补丁,需要重新评估。 5. 不再跨调用保存裸 `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 路径。 因此可能出现: ```text /proc/pid/mem → original 内容 pagemap → shadow PFN ``` 两条观察路径分别看似合理,组合起来却暴露了双视图的存在。观察者甚至可以重复采样 PFN,发现页面在 original 与 shadow 之间跳变。 所以 pagemap 需要单独的语义修正: ```text 如果: mm 与地址匹配 wxshadow 页面 页面状态为 SHADOW_X pagemap 读出的 PFN 等于 pfn_shadow 则: 对外报告 pfn_original ``` 还有一层更细的一致性:pagemap 除了 PFN 还暴露 flags。合成 shadow PTE 时必须继承原 PTE 的 `SOFT_DIRTY` 位,否则被 hook 页的 pagemap flags 与同 VMA 其他页不一致,本身就成为检测特征。 这件事揭示了双视图虚拟化的一个基本规律: 不能因为两个接口最终都“与内存有关”,就假设它们经过同一条内核路径。 视图一致性必须按真实调用链审计,而不能按用户态功能名称猜测。 五、mprotect 是一个 PTE 规范化器 `mprotect` 是我认为最容易被低估的一条路径。 shadow PTE 的权限不是普通 VMA 权限的自然产物。它可能是人为构造的 execute-only 映射,而 VMA 本身仍然表达正常代码段语义。 当用户调用 `mprotect` 时,内核会根据新的 `vm_flags` 和 `vm_page_prot` 重新规范化 PTE 权限。 它通常保留 PFN,但重新计算权限位。 于是可能出现: ```text 修改前: shadow PFN + execute-only change_protection 后: shadow PFN + readable/executable ``` BRK 和 Patch 此时不需要经过 GUP,普通用户态读取就可以直接看到(实测 `read[0]=0xd42000e0`,正是我们的 BRK 编码)。 因此mprotect 不能只在结束后“检查一下权限是否变了”。如果仅做事后修复,`change_protection` 与修复之间就存在真实泄漏窗口。 当前实现采用 prepare/resync 两阶段协议。 mprotect prepare 在 `change_protection` 之前,调用者已经持有 `mmap_write_lock`。系统把范围内的代码 shadow 页预先切成: ```text hidden-original original PFN readable UXN ``` 这样接下来内核规范化的是 original 映射,shadow PFN 根本不在 PTE 中。 窗口内: - 数据读取只能看到 original; - shadow 中的 BRK/Patch 不存在泄漏面; - 其他线程取指时被 UXN 阻断; - 执行 Fault 走 park(等待窗口标记清除,有界超时后重试),随 resync 的终态自然收敛。 mprotect resync `change_protection` 完成、VMA 拆分或合并结束后,再根据最终 VMA 语义决定页面命运: ```text 新 VMA 可执行且不可写 → 同步新的权限模板 → 重新安装 shadow execute-only PTE → Hook 继续存活 新 VMA 可写、不可执行或 PROT_NONE → retire → 恢复符合真实 VMA 的 original 映射 ``` 这里使用的依旧是“宁可失效,不可泄漏”的策略。 系统不能为了维持 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,直接复制会带来两个问题: 1. 子进程继承带有 BRK/Patch 的私有 shadow 页面; 2. 父子进程后续 COW 和页面引用关系被 wxshadow 的私有 PFN 污染。 因此,在 `dup_mmap` 持有父进程 `mmap_write_lock` 的上下文中,需要建立 fork 事务: ```text pause: 阻止新的 shadow 状态转换 等待或排空在途 BRK 父进程 PTE 暂时切回 original copy: copy_page_range 复制 original 语义 子进程得到 original 页面 resume: 父进程 PTE 恢复 shadow 清除 fork_paused ``` pause 的收集谓词是这个事务最容易写错的地方。它不能是"状态 == SHADOW_X",而必须是"live PTE == shadow"——XOL 单步期间状态是 STEPPING 但 PTE 没有翻走,ORIGINAL 读窗期间状态是 ORIGINAL 而 VMA 语义要求可执行,这两类页都曾漏出收集范围,导致子进程继承 shadow PFN 或拿到不可执行的映射。对 XOL 在飞的页,pause 前还要做有界 drain 等它收尾。 `fork_paused` 不是普通状态标签,而是一道并发闸门。 BRK、XOL、GUP hide、release 等路径在进入时必须检查它。否则可能发生: ```text fork 已准备复制 original ↓ 另一个 BRK 路径重新安装 shadow ↓ copy_page_range 把 shadow PFN 复制给子进程 ``` 还有一个很容易写出的死锁: `dup_mmap` 已经持有 `mmap_write_lock`,此时内部辅助函数如果再调用 `mmap_read_lock`,就会发生同线程递归锁死。 所以 fork 路径必须有 caller-locked 版本: ```text caller 已持 mmap_write_lock ↓ 内部不得再次获取 mmap_read_lock ↓ 只取得 page->pte_lock、global_lock 和叶子 PTL ``` “这个函数会改 PTE”并不足以定义它的锁行为。必须先定义它从哪个调用上下文进入。 七、一条反复付出代价的不变式:STEPPING ≠ PTE 已翻走 系统里存在三种单步通道: ```text WX_STEP_XOL 页内 XOL:PTE 从不离开 shadow WX_STEP_FLIP_EMU 模拟兜底:PTE 临时切到 original WX_STEP_FLIP_TRACE trace 锚点:PTE 临时切到 original ``` 于是真正的不变式是: ```text STEPPING && step_mode == XOL ⇒ live PTE 恒为 shadow(--x),从不翻页 STEPPING && step_mode == FLIP ⇒ live PTE 是 stepping-original ``` 状态与实时映射的对应关系应该是: | 状态 | live PTE | 收敛责任人 | |---|---|---| | SHADOW_X | shadow `--x` | 稳态 | | ORIGINAL | original `r--` | 下一次取指 fault 翻回 | | STEPPING (XOL) | shadow `--x` | owner 的 XOL 收尾 | | STEPPING (FLIP) | original `r-x` | owner 的 finish | | DORMANT | original | 退休态,控制面可重建 | 几乎所有 MM 消费者最初的假设都是"STEPPING ⇒ PTE 已翻走"或者"只查 SHADOW_X",于是 XOL 引入后每个消费者各漏一个洞: - fork pause 漏收 XOL 页,子进程继承 shadow PFN; - mprotect prepare 盲翻 XOL 在飞页,owner 取指撞 UXN 被 SIGSEGV 误杀;resync 的 retire 先动 PTE 后判 step_active,owner 的控制流被抽页损坏;prepare 跳过的页被 `change_protection` 规范化成用户可读、resync 又不重钉,shadow 永久泄漏; - GUP hide 与 pagemap fixup 在 XOL 期间全部跳过,外部观测者读到 shadow 内容。 修复方式不是逐点打补丁,而是把"XOL ⇒ PTE 恒为 shadow"提升为成文的状态机不变式,然后按"不变式 × 消费者"矩阵逐格重审:begin 族在 GUP/mprotect 窗口内拒绝起步、exec fault 对窗口内 STEPPING 页走 park、GUP 与 pagemap 放行 XOL 形态、prepare 跳过、resync 重钉。 这件事的教训写成一句话:每新增一个执行通道或状态,所有既有消费者必须按矩阵重审一遍——遗漏不会报错,它只会在窄窗口里静静等待。 --- 八、写:第三种访问的收敛规则 视图矩阵里最容易被漏掉的一行是"写"。目标进程写自己的代码(POKETEXT 自愈、JIT、自修改),或者调试器经 ptrace 强写,都同时触碰两个视图:写若落在 shadow,读侧(original)不可见,双视图发散;写若被静默丢弃,目标语义损坏。 逐项推演后,写路径的语义其实是闭环的: ```text VMA 可写(mprotect 改成 rw) → prepare/resync 时 retire,写发生前 Hook 已退休 VMA r-x/r-- 上的写 → 与未插桩一致的 SIGSEGV,不特殊处理 写 fault(COW 等合法写路径) → retire + 原生写继续,双视图收敛 original forced kernel write(ptrace POKETEXT / FOLL_FORCE) → 写 GUP 同样进隐藏:写落 original 或 COW 新页 → finish 按写语义退休:保留 live PFN(含 COW 新页), 恢复权限模板,清洗 shadow,进 DORMANT → debugger 的写对 App 可见,双视图收敛 ``` 不变式只有一条:写后两个视图必须收敛到 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_tables` 和 `do_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 手段都修不干净。 十一、同一状态转换需要三种锁接口 随着调用路径增多,我最终把页表状态转换分成了三类。 1. Self-locking 路径 适用于控制接口、普通 fault 和允许睡眠的上下文: ```text 函数内部: mmap_read_lock → fresh find_vma → page->pte_lock → global_lock → kernel PTL ``` 这些函数不能相信调用者之前取得的 VMA 指针。进入锁域后必须重新 `find_vma`,确保检查和使用属于同一个稳定视图。 2. Caller-locked 路径 适用于 GUP、fork、mprotect 等调用者已经持有 mmap 锁的上下文: ```text 调用者已经持有 mmap_read_lock 或 mmap_write_lock ↓ 内部不得重复获取 mmap 锁 ↓ 直接进入 page->pte_lock 和 PTL ``` 它解决的不是性能问题,而是 rwsem 递归和锁序问题。 3. Atomic 路径 BRK/step handler 运行在关中断、关闭抢占或不能睡眠的异常上下文中。 这类路径: - 不能获取 `mmap_lock`; - 不能执行可能睡眠的 VMA 操作; - 不能现场拆 THP; - 不能依赖一个未固定生命周期的页表指针。 因此它采用另一套安全基础: ```text 建页时: 钉住相关页表页 异常时: 不依赖裸 VMA 指针 沿被固定生命周期的页表层级走表 获取叶子 PTL 校验实时 PFN 原子修改 PTE ``` 三种接口实现的是同一套状态语义,但它们的锁契约不同。 把它们强行合并成一个“万能函数”,通常会得到两种结果之一: - 普通路径安全,异常路径睡眠; - 异常路径勉强能跑,普通路径失去 VMA 和页表生命周期保护。 --- 十二、为什么必须固定页表页,而不是只固定 `mm` `mmgrab()` 可以保证 `mm_struct` 不会被释放,但它不能保证某个地址对应的中间页表页永远存在。 并发 munmap 可以清除映射并释放页表层级。如果异常路径保存或重新解引用未经保护的页表页,就可能发生 UAF。 因此在 shadow 页面建立时,系统会取得相关页表页的引用,并在页面对象最终释放时配对 `put_page()`。 这形成两个不同层面的生命周期: ```text mmgrab / mmdrop 保证 page->mm 指针在页面对象存活期间有效 pin pgtable pages / put_page 保证异常路径走表依赖的页表页对象不会提前释放 ``` `mm_users` 和 `mm_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 语义在并发修改期间保持稳定。 在普通进程上下文中,可靠做法仍然是: ```text 获取 mmap_lock ↓ 重新 find_vma ↓ 在同一锁域内验证范围和权限 ↓ 完成依赖该 VMA 的操作 ``` 在无法获取 mmap 锁的异常上下文中,可以使用 RCU 读侧进行范围预检,避免 VMA 对象在解引用期间被释放。 但 RCU 预检只能回答这个对象在我读取时是否仍然具有生命周期? 它不能独立证明: 这个地址在整个后续事务中仍然属于同一 VMA,并保持相同权限和页表身份。 因此,原子路径中的 VMA 检查只是一道快速预检。真正的提交闸门仍然是: - 被固定生命周期的页表页; - 叶子 PTL; - 实时 PTE; - `expect_pfn` 校验。 这相当于把判断分成两层: ```text RCU/VMA: 地址在逻辑上是否仍可能有效? PTL/live PTE: 即将修改的是否仍是我认识的那份映射? ``` 只有两者都成立,状态转换才允许提交。 需要承认的是,规则确立之后仍然会有漏网调用点:我在控制面建页路径上发现一个裸 `find_vma`(无锁无 RCU,并发 munmap 即 UAF 读),它躲过了此前那一轮 RCU 化。 十四、真正危险的死锁往往跨 CPU 单机测试中最隐蔽的一类问题,是持有 `page->pte_lock` 时进行全局 TLB 或 CPU 同步。 假设 CPU 0: ```text 持有 page->pte_lock ↓ 发送 IPI ↓ 等待 CPU 1 响应 ``` 此时 CPU 1 正在 BRK 异常上下文: ```text IRQ-off ↓ 等待 page->pte_lock ``` CPU 1 因为在等待锁而无法完成 CPU 0 所等待的同步,CPU 0 又不会释放锁,形成跨核 ABBA: ```text CPU 0: 持 pte_lock → 等 CPU 1 CPU 1: IRQ-off → 等 pte_lock ``` 所以 TLB 和 context synchronization 的位置不能只根据“PTE 已经写完”来决定,还必须纳入跨 CPU 锁序。 当前规则是: 在 `page->pte_lock` 内完成最短的状态和 PTE 提交;需要 `flush_tlb_mm()`、`kick_all_cpus_sync()` 等可能等待其他 CPU 的操作,移出该锁的临界区。 这要求状态转换具有明确的提交点。离开锁以后,其他路径可能再次观察页面,因此必须保证: - 软件状态已经与 live PTE 一致; - 引用仍然有效; - 后续同步即使延迟,也不会让另一个路径误判事务类型; - 错误路径仍能退回 teardown。 但"移出临界区"本身不是终点。 两条原子收尾路径的 IPI-wait 确实早已移出 `pte_lock`,落点却仍在 IRQ-off 的 step handler 里——`smp_call_function` 的内核契约明文禁止关中断调用:两个核同时执行 `kick_all_cpus_sync()`,各自关中断等对方应答 IPI,照样跨核 ABBA。 规则要从"IPI-wait 移出锁"补全为"IPI-wait 只出现在可调度上下文";落点上下文和锁序一样,都是规则的一部分。 高并发内核代码的困难,往往不在“用了几把锁”,而在于: 某把锁保护的状态,是否允许在等待另一个 CPU 时保持冻结? 十五、状态机正确,不代表实时 PTE 正确 软件中可能记录: ```text state == SHADOW_X ``` 但实时 PTE 却已经指向 original PFN。 这可能来自历史错误分支、finish 配对错误、并发 mprotect、GUP 隐藏中断或 TLB/PTE 收尾不完整。 如果系统只相信状态字段,就会出现一种非常危险的静默失效: ```text 软件认为 Hook 存活 实时执行已经回到 original 没有 Fault 没有 BRK 没有任何路径主动纠正 ``` 因此双视图状态不能只由枚举值定义。 更完整的状态其实是: ```text 逻辑状态 + step_mode + stepping_task + live PFN + PTE 权限 + VMA 语义 + 生命周期标志 ``` 其中 `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: ```text 如果: state == SHADOW_X live PTE == original PFN 页面仍具备合法 shadow 条件 则: 以 expect_pfn 作为并发闸门 重新安装 shadow 记录 rescue 计数 ``` `rescue_count` 不应该被当作“系统修复能力很强”的成绩。 它更适合作为金丝雀: 正常情况下应当长期为零;一旦非零,说明某条路径曾经打破状态与现实的一致性。 能恢复是一层保险,找出为什么需要恢复才是最终目标。 最后,自愈逻辑自己也需要不变式约束。GUP 隐藏和 mprotect prepare 期间,`state==SHADOW_X && live PTE==original` 是设计内形态而不是漂移——这两个窗口必须用显式标记(`gup_hiding`、`mp_prepare_pending`)告诉 rescue"别收编"。 没有这道区分,自愈系统会把自己的故意窗口当成事故修复,自愈本身变成破坏者。 十六、退出不是释放内存,而是终止所有观察关系 进程进入 `exit_mmap` 后,Linux 会开始拆 VMA、清 PTE 和回收页面。 如果 shadow PFN 仍然安装在普通用户 PTE 中,后续通用回收路径可能把它当成不符合 VMA 语义的页面,产生引用异常或 `Bad page map` 一类问题。 因此,wxshadow 必须在普通 zap 流程之前: 1. 找出属于该 `mm` 的所有 shadow 页面; 2. 阻止新的处理路径取得可提交事务; 3. 处理在途 BRK、XOL、GUP hide 和 release; 4. 恢复 original PTE; 5. 从索引和页面列表摘除; 6. 释放 shadow page、Patch 数据和页表页引用; 7. 最终执行 `mmdrop()`。 这里不能简单遍历列表然后逐个释放。 列表遍历、对象引用和并发 handler 可能交错,因此需要“从全局可发现集合摘除”和“等待对象引用归零”分离: ```text 逻辑删除: dead = true 从 hash/list 摘除 新路径无法再找到 物理释放: 等持有旧引用的 handler 退出 refcount == 0 释放 shadow、页表页和 mm 引用 ``` `dead` 不是“内存已经释放”,而是“对象已经失去对外可发现性”。 这是内核生命周期管理中很重要的区别。 引用归还也有一条铁律:判定、摘除、归还必须在同一临界区内完成。teardown 曾经把"页是否在列表里"的快照拍在临界区之前、把引用归还放在 dead 守卫之外——两个并发 teardown 各自归还一次 list 引用,refcount 下溢,结构体在仍有持有者时被释放。 正确形态只有一句:谁赢得 dead 迁移,谁归还 list 引用。 另外,exit 路径的所有锁获取只能用 trylock:目标 mm 的 mmap 锁可能正被退出流程自己持有,阻塞等待在这里等于自杀;trylock 失败则保留页面、走失败页熔断,绝不让清理过程变成新的死锁源。 十七、卡在 STEPPING 的线程也属于 MM 状态 如果一个任务在 XOL 或 flip-step 中退出、被杀、长期停止或永远不再调度,页面可能永久停在 `STEPPING`。 此时简单等待会让: - release 永远无法完成; - fork 永远无法进入安全复制状态; - 同页其他 BRK 一直重试; - `exit_mmap` 被一个已经不存在的续执行事务拖住。 因此“任务是否还活着”不能只靠保存的 `task_struct *` 判断。页面保存的是裸指针、不持引用——task_struct 释放后槽位可能被 SLUB 复用,指针本身永远"可读",但身份已经换了。证死必须走另一条路:记录 PID 与进入续执行的时间,在 RCU 下按 PID 查证——pid 消失或 `exit_state/TASK_DEAD` 即证死。 系统实际区分三态: - DEAD:按 PID 证死,立即判楔,进入 teardown; - STOPPED(含 ptrace TRACED、cgroup frozen):不判楔,等恢复——否则 teardown 收掉现场,任务苏醒后 PC、PTE、状态机三方不一致,活着的任务被"证死"; - ALIVE:超过合理时间(200ms)兜底判楔。 判楔成立后系统进入 teardown,使页面收敛回 original,而不是永久等待一个不存在的 finish。 这里仍然遵循相同原则: Hook 可以失效,地址空间不能永久卡在中间态。 真机验证:对被锚进程做 SIGSTOP/SIGCONT ×20(命中即停 2 秒再续),hook 不被误拆,进程完整跑完;对多线程锤击中的进程直接 SIGKILL,楔死判定与 exit_mmap 正常收敛,系统无恙。 十八、双视图系统真正需要的是收敛,而不是永不失败 内核中不可能保证所有状态转换永远成功。 VMA 可能已经消失,PFN 可能因 COW 改变,PTE 可能被并发修改,任务可能退出,TLB 同步也可能出现在无法继续原协议的上下文。 因此,系统需要的不是“所有路径都返回 0”,而是每个失败都必须有明确终态。 我最终把失败结果归纳为三类: | 结果 | 系统动作 | |---|---| | 可以维持双视图 | 恢复或重装 shadow | | 不能维持 Hook,但 original 仍有效 | retire/teardown,恢复原生执行 | | 映射本身已经不存在 | 摘除对象,完成生命周期回收 | 禁止出现第四种结果: ```text 软件状态认为是 shadow 实时 PTE 指向 original 对象仍在全局列表 没有任何路径负责后续恢复 ``` 这就是状态漂移。 与三类终态配套的是失败返回值的归并纪律:"忙 / 窗口期 / 形态不符"统一为可重试语义,绝不把内部私有 BRK 误送成用户态 SIGTRAP;ERROR 只留给真正"不是我的断点"。以及一条常被忽略的契约:失败路径同样需要状态验证——只有把"PTE 已恢复到 original"确认到手,"teardown 后原生续跑"才成立,不能只调用了一个名字叫 teardown 的函数就当作完成恢复。 双视图架构的质量不取决于正常路径有多漂亮,而取决于所有异常路径是否最终汇聚到少数可证明的稳定状态: ```text 稳定 shadow 稳定 original 对象已死亡并不可发现 ``` 十九、为什么这已经不再只是 Hook 如果只看使用方式,这套系统仍然可以被叫作无痕 Hook: 原地址放置 BRK; 读取时返回原字节; 命中时观察或修改寄存器; 原指令继续执行。 但从内核内部看,它实际维护的是一组更广泛的映射规则: ```text 取指现实 → shadow 普通读取现实 → original 写入现实 → 双视图收敛 original GUP 现实 → original pagemap 现实 → original PFN syscall 内核读 → original fork 子进程现实 → original mremap/munmap 前 → 前置 retire 还原 original mprotect 后现实 → 根据新 VMA 重新裁决 退出后的现实 → original 或不存在 ``` 为了维持这些规则,系统必须控制: 页面内容; PTE 权限与 PFN; TLB 和 I-cache; VMA 生命周期; 页表页生命周期; `mm_struct` 生命周期; 任务生命周期; 跨路径锁序; 多 CPU 状态同步; 失败后的最终收敛。 Hook 只是其中一个入口。 更准确地说,这套系统正在做的是为同一个进程创建两个可观察现实,并让 Linux MM 中的不同观察者被路由到各自正确的视图。 这就是我所说的进程级双视图虚拟化。 它和传统虚拟机的区别是:它没有虚拟出完整 CPU 和操作系统。 它和普通 Shadow Memory 的区别是:shadow 不只是元数据或检测副本,而是真正参与 CPU 取指和用户态执行。 它和单纯无痕 Hook 的区别是:它不只隐藏修改结果,还必须维持跨内核子系统的一致性。 二十、工程验证 以上规则在一加 ACE5 / 骁龙 8 Gen3 / 6.1 内核真机上验证,关键数据: | 用例 | 结果 | |---|---| | 高并发锚点命中(BB trace,26 锚点) | 500 万次命中记录完整,`rescue_count=0`、`rearm_count=0` | | 多线程并发读被 hook 页(mptest_mt,RX↔RWX 切换压测) | 数亿次并发读,`hits=0` 零泄漏 | | fork × 热锚点(200 次 fork,双线程持续锤击锚页) | 92 万次锚点命中,子进程零信号死亡、结果零错误 | | mremap(MAYMOVE\|FIXED) 搬被锚页 | 进程存活,新址读到原字节、调用正常 | | SIGSTOP/SIGCONT ×20 中断点 | 不误判楔死,hook 存活跑完 | | SIGKILL 锤击中的多线程进程 | 楔死/teardown/exit_mmap 正常收敛,系统无恙 | | ptrace POKETEXT 强写被锚页 | 写对 App 可见,页面干净退休 | | mprotect 四用例(rwx→retire / --x→reassert / r--→retire / mt 压测) | 全部符合预期语义 | | 指令模拟矩阵(emu_victim,35 项×100 轮) | 全部通过 | | XOL 槽外来流多线程(tail_victim) | 单线程落穿 10 万次 + 多线程 20 万次,零错执行 | 结语 原始 wxshadow 完成了最重要的第一步:证明同一个虚拟地址可以在读取与执行之间呈现不同物理页面。 页内 XOL 解决了第二步:执行原指令时,不再要求原地址退出 shadow 世界。 而完整的 MM 重构解决的是第三步: 当 Linux 内核从不同路径观察、复制、修改和销毁这个地址空间时,两个世界仍然必须各自成立,并最终收敛到正确状态。 因此这套子系统工作的重点已经不再是如何把一个 Hook 藏起来 而是如何在 Linux MM 中创建两个现实,并阻止它们在并发和生命周期变化中相互污染 你改变了一个现实,就要对所有观察这个现实的信道负责 指令级对 PC-relative 负责,进程级对 MM 全部路径负责,认识论级对物理世界负责。 一条指令的语义不能丢 一个进程的视图不能乱 这两条约束合在一起,才构成了我理解中的进程级双视图虚拟化。 致谢与参考 本文工作建立在 kkkbbb 开源 wxshadow 的 R^X Shadow Page 基础之上: - <a href="elink@ec0K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6C8K9$3E0T1j5X3u0Q4x3V1k6E0K9%4m8E0M7#2)9J5c8Y4c8J5k6h3g2Q4x3V1k6E0j5i4y4@1k6i4u0Q4x3V1k6C8M7r3#2K6i4K6u0r3N6%4S2K6K9r3q4V1L8%4M7`.">`mkpms/kpms/wxshadow`</a> - <a href="elink@ab4K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6C8K9$3E0T1j5X3u0Q4x3V1k6J5N6i4y4@1c8Y4u0A6k6r3p5`.">`rustFrida`</a> - [文章《linux/android 利用shadow内存无痕hook方法》](https://bbs.kanxue.com/thread-290304.htm)
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
9
点赞
・
8
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
零_721409
你的帖子非常有用,感谢分享!
2026-9-3 13:55
心在o
非常支持你的观点!
2026-8-16 21:29
我的小拇指啊
为你点赞!
2026-8-5 05:18
New对象处
非常支持你的观点!
2026-8-4 18:05
wx_funcrever
期待更多优质内容的分享,论坛有你更精彩!
2026-8-3 17:24
临时test
期待更多优质内容的分享,论坛有你更精彩!
2026-8-3 16:18
by_Lin
感谢你的积极参与,期待更多精彩内容!
2026-8-1 23:31
天地一逆旅
你的帖子非常有用,感谢分享!
2026-8-1 10:16
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
fjqisba
雪 币:
295
活跃值:
(9613)
能力值:
( LV7,RANK:102 )
在线值:
发帖
18
回帖
347
粉丝
87
关注
私信
fjqisba
2
楼
我怎么一个字都看不懂
2026-8-3 11:41
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
mb_iiulbilc
3
发帖
20
回帖
30
RANK
关注
私信
他的文章
[原创]当高频观测不再经过异常路径:Shadow Cave 与常驻式插桩架构
24376
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)
32738
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(上)
32728
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部