ARM64 页表断点——基于 PTE 缺页异常的透明拦截、上下文捕获与寄存器实时修改
ARM64 页表断点——基于 PTE 缺页异常的透明拦截、上下文捕获与寄存器实时修改
摘要
本文介绍一个基于 ARM64 PTE(Page Table Entry)权限位操控的 Linux 内核模块,通过拦截 do_page_fault 缺页异常实现对用户态进程的完全透明的指令级断点。系统支持执行、读、写三种断点类型,捕获函数调用时的全部参数(GPR x0-x7、SIMD q0-q31、栈参数),并允许在断点命中时实时修改寄存器值。整个过程对目标进程不可感知——不会产生 SIGSEGV、不会修改 VMA、不会触发任何用户态可检测的副作用。
一、项目背景
在 Android 游戏逆向工程中,需要在特定函数入口处拦截调用、获取参数、甚至修改参数值以影响游戏行为。常见的方案存在各种局限:
| 方案 | 问题 |
|---|---|
ptrace |
性能差,易被检测(TracerPid),需要停止进程 |
| 硬件断点 | ARM64 只有 4-6 个硬件断点寄存器,数量极其有限 |
| Inline Hook | 需要写代码段(触发 mprotect 或写时复制),XOM 保护下有风险 |
| PLT/GOT Hook | 仅限动态链接函数,无法 hook 内部函数 |
本方案利用 MMU 缺页异常机制:通过修改目标地址的 PTE 权限位制造”违规访问”,在 do_page_fault 中拦截并记录上下文后恢复 PTE,CPU 重试指令成功。整个过程在 CPU 异常级别 EL1 完成,EL0 完全无感知。
二、技术架构
2.1 运行环境
| 组件 | 详情 |
|---|---|
| 目标设备 | ARM64 Android 手机,已 root |
| 注入框架 | APatch (基于 KernelPatch),加载 KPM 内核模块 |
| 内核模块 | Kernel_prctl.kpm,-mgeneral-regs-only -fno-builtin 编译 |
| 用户态程序 | bp_test,NDK clang++ 交叉编译,通过 prctl 与内核通信 |
| 编译器 | aarch64-none-elf-gcc 11.2 (KPM) / aarch64-linux-android23-clang++ (user) |
2.2 核心原理
ARM64 页表条目(PTE)包含多个权限控制位。系统通过修改这些位制造违规访问:
1 | 断点类型 PTE 操作 CPU 异常 ESR_EL1.EC |
操作流程:
1 | [1] 设置断点 |
三、开发过程
3.1 版本迭代
整个项目经历了三个主要阶段:
V1:阻塞模式 + 寄存器修改
- 断点命中后
before_do_page_fault进入schedule_timeout忙等 - 用户态在此期间读取寄存器并决定修改值
- 问题:阻塞导致游戏卡死、性能差、逻辑复杂
V2:纯 One-Shot 非阻塞模式
- 命中后只记录寄存器快照,PTE 立即恢复
- 用户态轮询查询结果
- 引入 Sticky Page 机制防止无限缺页循环
- 分支:
brk-failed(V1 备份) /stable(V2+)
V3:非阻塞 + 寄存器修改 (当前版本)
- 在 V2 基础上新增
PRCTL_BP_MODIFY - 用户态预先设置修改数据 → 断点命中时内核直接应用
- 支持实时更新:用户态循环调用
PRCTL_BP_MODIFY,每次命中使用最新值 - 支持同时修改多个 GPR (x0-x7)、SIMD (v0-v7)、SP
3.2 分支结构
1 | * b0d7c59 master, stable — V3: one-shot + 寄存器修改 (PRCTL_BP_MODIFY) |
四、关键数据结构
4.1 用户-内核通信协议
所有通信通过 prctl() 系统调用完成(内核模块 hook 了 prctl 的 before 回调):
1 | // 设置断点 |
4.2 内核断点表
1 |
|
五、核心技术实现
5.1 页表遍历与 PTE 修改
手动遍历目标进程的页表(PGD → PUD → PMD → PTE),直接操作物理内存:
1 | Level 0 (PGD): idx = (vaddr >> 30) & 0x1FF // 39-bit VA, 3 级页表 |
关键设计:
- PGD 使用直接内核虚拟地址(线性映射,无需
memremap) - 子表通过
memremap(WB)映射物理页 - 支持 2MB block mapping(PMD 级别),不支持 1GB block(PUD 级别,太罕见)
- 运行态探测页表层级(3 级 vs 4 级),通过检查 init 进程 PGD[1] 条目
权限掩码计算:
1 | 执行断点: set_mask |= PTE_UXN // bit 54, 禁止 EL0 执行 |
5.2 do_page_fault Inline Hook
使用 KernelPatch 的 hook_wrap3 框架在 do_page_fault 前插入回调:
1 | static void before_do_page_fault(hook_fargs3_t *args, void *udata) { |
do_page_fault 签名:
1 | void do_page_fault(unsigned long addr, unsigned int esr, struct pt_regs *regs) |
安装和卸载:
1 | // init |
5.3 SIMD 寄存器读取
ARM64 内核模块使用 -mgeneral-regs-only 编译,内核自身绝不使用 NEON/SIMD 寄存器。因此在 do_page_fault 处理期间(EL1),硬件 FPSIMD 寄存器仍保持 EL0 用户态进程的值。
通过内联汇编 stp(Store Pair)一次性 dump 全部 32 个 Q 寄存器:
1 | uint64_t buf[64] __attribute__((aligned(16))); |
每条 stp 存储 2 个 128-bit Q 寄存器 (32 字节),16 条指令覆盖 q0-q31。
5.4 寄存器修改
修改原理:pt_regs 中的 GPR 写回即生效;SIMD 硬件寄存器直接通过内联 asm 写入。
GPR 修改:
1 | regs->regs[r] = g_bp_table[i].mod_x_regs[r]; |
SIMD 修改(三种模式):
1 | // LO32 — 仅低 32 位 (float) |
ins 指令只覆盖目标位域,其余位保持不变。ldr 从对齐缓冲区加载完整 128 位。
5.5 PTE 预映射优化
do_page_fault hook 上下文不可调用 memremap()/memunmap()(可能触发睡眠)。解决方案:
- 设置时(prctl 上下文,安全):
memremapPTE 表页,存储 KVA + 索引到pte_table_kva/pte_index - 命中时(fault 上下文,不安全):直接
pte_table_kva[pte_index] = orig_pte(纯内存写)
1 | // bp_set() — prctl 上下文 |
5.6 Sticky Page 机制
问题:One-shot 模式下,PTE 恢复后 CPU 重试指令。若此时立即 bp_set re-arm,PTE 又被修改,CPU 还在同一页执行 → 再次触发缺页 → 无限循环。
方案:引入 Sticky Page 状态机:
1 | 断点命中 → 设置 sticky(page, pid, slot) |
5.7 线程安全
mod_ready 标志:用户态可能随时调用 PRCTL_BP_MODIFY 更新修改数据,与 do_page_fault 形成竞态。
1 | Writer (PRCTL_BP_MODIFY): Reader (before_do_page_fault): |
ARM64 对齐字段的单拷贝原子性保证不会读到撕裂值。Reader 看到 mod_ready=0 则跳过本次修改(安全默认),下次命中生效。
六、难点与解决方案
6.1 sp=0 bug — 数据拷贝遗漏
现象:bp_hit_info.sp 始终为 0。
根因:PRCTL_BP_QUERY 的 copy_to_user 逻辑只复制了 header(到 x_regs[7])+ v_regs[64]。而 sp、stack_args[6]、active 这三个字段在 v_regs 之后,从未被拷贝到用户态。
1 | // 修复前 (只拷贝 header + v_regs) |
6.2 TLS 段对齐错误
现象:编译 bp_test 时如果使用 -static,运行时报错:
1 | executable's TLS segment is underaligned: alignment is 8, |
原因:ARM64 Android Bionic libc 要求 TLS(Thread Local Storage)段 64 字节对齐,静态链接下 LLD 无法保证。
解决:移除 -static,使用动态链接即可。
6.3 大页不支持
Block mapping(2MB)在 PMD 级别可以处理(直接修改 block descriptor),但 1GB PUD block 罕见且不支持,walk_and_modify_pte 返回 -1。
6.4 每页一个断点
同一 4KB 页只允许一个活跃断点。原因是 PTE 修改是页级别的——同一页上的两个地址共享同一个 PTE,无法独立控制。
6.5 mprotect 检测规避
/proc/pid/maps 显示的权限来自 VMA(vm_area_struct),而非 PTE。本系统只修改 PTE,VMA 不变,所有用户态页权限检测(mprotect、/proc/maps、mincore)均不可见。
七、性能与限制
| 指标 | 数值 |
|---|---|
| 断点槽位上限 | 32 个全局 |
| 每页断点限制 | 1 个 |
| PTE 修改开销 | ~1μs(一次 memremap + 写 PTE) |
| 缺页处理延迟 | ~5μs(hook 内读取寄存器 + 恢复 PTE + TLB flush) |
| TLB flush 范围 | 本地(tlbi vmalle1),不影响其他 CPU |
| SIMD 修改范围 | v0-v7(函数参数寄存器) |
| 支持页表深度 | 3 级 (39-bit VA) / 4 级 (48-bit VA) |
限制
- 大页(2MB PTE block)+ EXEC 断点:修改 block descriptor 会影响整个 2MB 范围的所有代码
- 同一页上的不同指令需共享断点(同一 PTE)
do_page_fault签名依赖,不同内核版本可能参数顺序不同- 仅支持 EL0 异常(用户态),不支持 EL1 内核态断点
八、使用示例
8.1 设置执行断点并读取参数
1 |
|
8.2 修改浮点参数
1 | // 修改 v3.s[0]=1.0f, v4.s[0]=1.0f |
8.3 实时更新修改值
1 | while (running) { |
九、总结
本文介绍的 MMU 断点系统通过 ARM64 PTE 权限位操控实现了对用户态进程的完全透明拦截。核心优势在于:
- 完全透明:不修改 VMA、不触发信号、不使用
ptrace,对进程不可见 - 数量不限:理论上支持任意多断点(受 PTE 表大小和每页限制约束)
- 完整上下文:捕获全部 GPR、SIMD、栈参数
- 寄存器修改:支持实时、多寄存器的参数篡改
- 低延迟:单次命中 ~5μs kernel overhead
该项目从初始的阻塞模式,经过简化到纯 one-shot,最终加入寄存器修改功能,形成了一套完整的调试/修改框架。代码以 KernelPatch KPM 模块形式运行于 APatch 框架,编译于 aarch64-none-elf-gcc + -mgeneral-regs-only 工具链。
完整的代码和使用指南见 user-test/ 和 kpms/prctlhookRWMemoryNew/。
附录 A:PTE 断点检测方式分析
A.1 时序检测
通过测量代码执行延迟来判断是否有断点。
正常 vs 断点的延迟对比:
| 场景 | 延迟 | 倍数 |
|---|---|---|
| TLB 热命中(正常) | 1-4 cycles | 基准 |
| TLB 冷命中(正常缺 TLB) | 100-200 cycles | ~50× |
| 断点触发(缺页异常路径) | 5000-20000+ cycles | 50-200× vs 冷 TLB |
断点触发路径的额外开销来自:CPU 保存 EL0 上下文 → 向量表跳转 → 栈切换 → 内核 do_page_fault → hook 处理(匹配断点表、dump SIMD、恢复 PTE、TLB flush) → ERET 回 EL0 → CPU 重试 → TLB miss(刚被 flush)。
检测方法(需 Android 内核开启 PMU 用户态访问 PMUSERENR_EL0.EN):
1 | // ARM64 周期计数器 (需要内核启用 PMU 用户态访问) |
实际困难:One-shot 断点仅第一次触发慢,之后即正常;移动设备上的系统噪声(调度抖动、中断、DVFS 频率调节、thermal throttling)远大于 5μs 的 hook 延迟;冷 TLB 误报难以区分。在 Android 上 timing 检测的可靠性大幅降低。
A.2 Minor Page Fault 计数
每次断点命中都是一次 minor page fault。/proc/pid/status 中的 min_flt 计数器可以通过对比正常与异常时期的增量变化来检测异常频率。但噪声极大(正常缺页也计数),仅作为弱信号。
A.3 PMU 异常事件计数
ARM64 PMU 可统计异常事件,但 Android 内核默认不将指令异常事件暴露给 EL0(PMUSERENR_EL0 控制),需内核模块配合才能从用户态读取。
A.4 PTE 异常扫描
理论上反作弊内核驱动可以遍历进程页表检查异常的 PTE 权限位组合。但在 Android 上此方式实际不可行:
- 遍历非当前进程的页表需要访问
task_struct->mm->pgd,遍历所有进程并逐级走页表开销极大,持续扫描不可行 - Android GKI(Generic Kernel Image)限制第三方内核模块的能力,反作弊驱动通常只能做有限的 hook,不具备任意页表扫描权限
- SELinux 策略限制内核模块对用户态进程地址空间的随意检查
- 就算能做到,One-shot 断点的 PTE 异常窗口仅 ~5μs,定时扫描几乎不可能恰好命中
Android 上此检测手段不具备实用性。
A.5 检测矩阵
| 检测方式 | 可行性 | 难度 | 对 one-shot PTE 的影响 |
|---|---|---|---|
| Timing (cycle counter) | 理论可行 | 高(单次噪声大,需统计) | 低(窗口极小,一次慢) |
| Timing (对照相邻页) | 较可靠 | 中(需注入代码) | 低 |
/proc/pid/status minor fault |
弱信号 | 低(噪音多) | 极低 |
| PMU exception count | 较强 | 高(需内核配合) | 中 |
| PTE 异常扫描 | Android 上不可行 | —(GKI + SELinux 限制) | 无 |
/proc/pid/maps |
不可行 | — | 无(VMA 未修改) |
| SIGSEGV handler | 不可行 | — | 无(信号不送达) |
| mprotect 检查 | 不可行 | — | 无(PTE ≠ VMA) |
附录 B:反作弊检测寄存器修改的手段
以下聚焦纯本地、纯客户端层面的检测。假设只改本地数据(渲染、动画、客户端预测),服务端不参与校验。
B.1 直接数值校验
值域 / 边界检查:修改的参数若超出物理/逻辑合法范围直接暴露。
1 | 正常移动速度: 0 ~ 600 |
**双份存储比对 (Shadow Copy)**:引擎对关键数据结构维护两份拷贝。
1 | static float speed; // 运行时使用 |
只改了寄存器,内存中的 shadow 副本未同步 → 下一轮检查暴露。
CRC / Checksum:某游戏安全 SDK 在 UE4 中插入大量 CheckIntegrity() 调用,对 ACharacter、AController、UWorld 等关键结构体做周期性 hash。任何外部内存写入都会被检测。
B.2 一致性校验
同一帧内多个相互关联的参数必须自洽。
1 | 位置变化 vs 速度: |
B.3 浮点指纹检测
NaN / Inf 注入:错误的 SIMD 写入可能产生非数值。
1 | v_mode 选错 → 高位残留垃圾 |
精度模式:ins vN.s[0] 仅写低 32 位 (float),高 96 位保持不变。如果原本的 qN 高位不是干净的(例如之前被用作 double 或 int64),残留值可被检测。
FPSR 异常标志:ARM64 FPSR 寄存器记录浮点运算异常(IDC/IXC/UFC/OFC/DZC/IOC)。强反作弊周期性读取并清零 FPSR,若在不应该有浮点异常的代码段中出现异常标志 → 有外部干扰。
B.4 ARM64 寄存器状态检测
SP 对齐:ARM64 ABI 要求 SP 必须 16 字节对齐。通过 mod_sp 修改 SP 若未保证对齐 → ldp/stp 触发 SP alignment fault → 被信号处理器捕获。
NZCV 标志位:修改 GPR 不直接影响条件码。但若修改了会触发条件跳转的关键值(如修改 x0 后紧接着 cbz x0),执行流可能偏离预期路径,间接导致崩溃。
B.5 帧时间分析
单个断点 5μs 可忽略。但如果同时有多个断点,或 hook 了 per-frame 高频函数(每帧调用数百次),累积延迟可超过 1ms。Android 上可通过 Choreographer / FrameMetrics API 或 UE4 内置的 Stats 系统检测帧时间异常。
B.6 UE4 内置检测
check()/ensure()断言:遍布引擎代码,参数不合理直接触发(如check(DeltaSeconds > 0 && DeltaSeconds < 1.0))- **
FPlatformMisc::IsDebuggerPresent()**:检查/proc/self/status的 TracerPid - 全局指针合法性:
GWorld、GEngine等指针若指向非内核态合法地址 → 异常
B.7 环境完整性检测
Android 上的反作弊(如腾讯 ACE)会在用户态扫描运行环境:
- Root / Magisk / APatch 框架特征(文件系统扫描、挂载点检查)
- SELinux 状态(Permissive = 高风险环境)
- 可疑进程、可疑文件句柄
- Bootloader unlock 状态
/proc/self/maps中的可疑模块
这些检测的是注入框架本身而非寄存器修改行为,但环境异常会触发整体的安全标记。注意:Android GKI 限制下反作弊内核驱动的能力有限,上述检测主要在用户态执行。
B.8 检测手段风险矩阵
| 检测手段 | 风险 | 本项目暴露面 |
|---|---|---|
| 值域校验 (边界/NaN) | 中 | v_mode 选错致高位垃圾,或值超出合法范围 |
| 双份存储比对 (shadow) | 高 | 只改了寄存器,内存 shadow 未同步 |
| CRC / Checksum | 高 | 同上,引擎周期性 hash 检测 |
| 一致性校验 | 中 | 修改单一参数导致关联参数不自洽 |
| 帧间连续性 | 中 | 硬赋值导致跳变而非渐变 |
| FPSR 异常标志 | 低 | ins/ldr 不产生浮点运算,FPSR 不受影响 |
| NZCV 污染 | 无 | 不改条件码 |
| SP 对齐 | 低 | 不改 SP 或确保 16 对齐即安全 |
| 帧 timing | 低 | one-shot + 低频 hook,5μs 可忽略 |
| 环境扫描 | 高 | 框架本身 (APatch/KPM) 需加固隐藏 |
B.9 安全修改的原则
- 选择纯计算中间结果:改不会被写回内存的临时值(寄存器中的计算中间值),避免 shadow copy 不一致
- 值在合法边界内:改 599 而不是 999999;用渐变函数而不是硬赋值
- 同步关联参数:改速度也改位移;改伤害也改护甲扣减和血量变化
- 保持帧间连续性:用 lerp/smooth 逐步逼近目标值
- 避免服务端权威数据:只改纯客户端侧(渲染矩阵、FOV、本地动画)
- v_mode 选对:LO32 写 float 时确保高位干净;Q128 时用完整 16 字节
- 若需要改内存 shadow:配合
writeMem()同步修改内存中的双份拷贝 - 条件性下断点,缩小窗口:不要一直保持断点 armed。用
readMem()持续读取游戏状态(如血量、位置、目标距离),只在满足前置条件时才bp_set,命中后立即处理 + re-arm 或清除。PTE 异常仅存在于”前置条件满足 → 函数被调用”的极短间隙中,将暴露窗口从”永久”压缩到毫秒级。
1 | // 不好的做法: 一直 armed |