首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
10
10
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)
发表于: 2026-7-31 19:44
42986
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)
mb_iiulbilc
2026-7-31 19:44
42986
上一篇文章讨论的是一条 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@209K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6C8K9$3E0T1j5X3u0Q4x3V1k6E0K9%4m8E0M7#2)9J5c8Y4c8J5k6h3g2Q4x3V1k6E0j5i4y4@1k6i4u0Q4x3V1k6C8M7r3#2K6i4K6u0r3N6%4S2K6K9r3q4V1L8%4M7`.">`mkpms/kpms/wxshadow`</a> - <a href="elink@eb3K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6C8K9$3E0T1j5X3u0Q4x3V1k6J5N6i4y4@1c8Y4u0A6k6r3p5`.">`rustFrida`</a> - [文章《linux/android 利用shadow内存无痕hook方法》](https://bbs.kanxue.com/thread-290304.htm)
登录后可查看完整内容
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
10
点赞
・
10
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
灯Den
非常支持你的观点!
2026-9-30 17:02
git_52294AnsaryTanvir
感谢你分享这么好的资源!
2026-9-26 10:32
零_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
雪 币:
357
活跃值:
(9693)
能力值:
( LV7,RANK:102 )
在线值:
发帖
18
回帖
352
粉丝
88
关注
私信
fjqisba
2
楼
我怎么一个字都看不懂
2026-8-3 11:41
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
mb_iiulbilc
3
发帖
21
回帖
30
RANK
关注
私信
他的文章
[原创]当高频观测不再经过异常路径:Shadow Cave 与常驻式插桩架构
34480
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(下)
42925
[原创]从 R^X 到执行虚拟化:ARM64 内核级双视图子系统的工程实践(上)
42886
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部