本文介绍一个基于 ARM64 PTE(Page Table Entry)权限位操控的 Linux 内核模块,通过拦截 do_page_fault 缺页异常实现对用户态进程的完全透明的指令级断点。系统支持执行、读、写三种断点类型,捕获函数调用时的全部参数(GPR x0-x7、SIMD q0-q31、栈参数),并允许在断点命中时实时修改寄存器值。整个过程对目标进程不可感知——不会产生 SIGSEGV、不会修改 VMA、不会触发任何用户态可检测的副作用。
在 Android 游戏逆向工程中,需要在特定函数入口处拦截调用、获取参数、甚至修改参数值以影响游戏行为。常见的方案存在各种局限:
本方案利用 MMU 缺页异常机制:通过修改目标地址的 PTE 权限位制造"违规访问",在 do_page_fault 中拦截并记录上下文后恢复 PTE,CPU 重试指令成功。整个过程在 CPU 异常级别 EL1 完成,EL0 完全无感知。
ARM64 页表条目(PTE)包含多个权限控制位。系统通过修改这些位制造违规访问:
操作流程:
整个项目经历了三个主要阶段:
V1:阻塞模式 + 寄存器修改
V2:纯 One-Shot 非阻塞模式
V3:非阻塞 + 寄存器修改 (当前版本)
所有通信通过 prctl() 系统调用完成(内核模块 hook 了 prctl 的 before 回调):
手动遍历目标进程的页表(PGD → PUD → PMD → PTE),直接操作物理内存:
关键设计:
权限掩码计算:
使用 KernelPatch 的 hook_wrap3 框架在 do_page_fault 前插入回调:
do_page_fault 签名:
安装和卸载:
ARM64 内核模块使用 -mgeneral-regs-only 编译,内核自身绝不使用 NEON/SIMD 寄存器。因此在 do_page_fault 处理期间(EL1),硬件 FPSIMD 寄存器仍保持 EL0 用户态进程的值。
通过内联汇编 stp(Store Pair)一次性 dump 全部 32 个 Q 寄存器:
每条 stp 存储 2 个 128-bit Q 寄存器 (32 字节),16 条指令覆盖 q0-q31。
修改原理:pt_regs 中的 GPR 写回即生效;SIMD 硬件寄存器直接通过内联 asm 写入。
GPR 修改:
SIMD 修改(三种模式):
ins 指令只覆盖目标位域,其余位保持不变。ldr 从对齐缓冲区加载完整 128 位。
do_page_fault hook 上下文不可调用 memremap()/memunmap()(可能触发睡眠)。解决方案:
问题:One-shot 模式下,PTE 恢复后 CPU 重试指令。若此时立即 bp_set re-arm,PTE 又被修改,CPU 还在同一页执行 → 再次触发缺页 → 无限循环。
方案:引入 Sticky Page 状态机:
mod_ready 标志:用户态可能随时调用 PRCTL_BP_MODIFY 更新修改数据,与 do_page_fault 形成竞态。
ARM64 对齐字段的单拷贝原子性保证不会读到撕裂值。Reader 看到 mod_ready=0 则跳过本次修改(安全默认),下次命中生效。
现象: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 之后,从未被拷贝到用户态。
现象:编译 bp_test 时如果使用 -static,运行时报错:
原因:ARM64 Android Bionic libc 要求 TLS(Thread Local Storage)段 64 字节对齐,静态链接下 LLD 无法保证。
解决:移除 -static,使用动态链接即可。
Block mapping(2MB)在 PMD 级别可以处理(直接修改 block descriptor),但 1GB PUD block 罕见且不支持,walk_and_modify_pte 返回 -1。
同一 4KB 页只允许一个活跃断点。原因是 PTE 修改是页级别的——同一页上的两个地址共享同一个 PTE,无法独立控制。
/proc/pid/maps 显示的权限来自 VMA(vm_area_struct),而非 PTE。本系统只修改 PTE,VMA 不变,所有用户态页权限检测(mprotect、/proc/maps、mincore)均不可见。
本文介绍的 MMU 断点系统通过 ARM64 PTE 权限位操控实现了对用户态进程的完全透明拦截。核心优势在于:
该项目从初始的阻塞模式,经过简化到纯 one-shot,最终加入寄存器修改功能,形成了一套完整的调试/修改框架。代码以 KernelPatch KPM 模块形式运行于 APatch 框架,编译于 aarch64-none-elf-gcc + -mgeneral-regs-only 工具链。
完整的代码和使用指南见 user-test/ 和 kpms/prctlhookRWMemoryNew/。
通过测量代码执行延迟来判断是否有断点。
正常 vs 断点的延迟对比:
断点触发路径的额外开销来自:CPU 保存 EL0 上下文 → 向量表跳转 → 栈切换 → 内核 do_page_fault → hook 处理(匹配断点表、dump SIMD、恢复 PTE、TLB flush) → ERET 回 EL0 → CPU 重试 → TLB miss(刚被 flush)。
检测方法(需 Android 内核开启 PMU 用户态访问 PMUSERENR_EL0.EN):
实际困难:One-shot 断点仅第一次触发慢,之后即正常;移动设备上的系统噪声(调度抖动、中断、DVFS 频率调节、thermal throttling)远大于 5μs 的 hook 延迟;冷 TLB 误报难以区分。在 Android 上 timing 检测的可靠性大幅降低。
每次断点命中都是一次 minor page fault。/proc/pid/status 中的 min_flt 计数器可以通过对比正常与异常时期的增量变化来检测异常频率。但噪声极大(正常缺页也计数),仅作为弱信号。
ARM64 PMU 可统计异常事件,但 Android 内核默认不将指令异常事件暴露给 EL0(PMUSERENR_EL0 控制),需内核模块配合才能从用户态读取。
以下聚焦纯本地、纯客户端层面的检测。假设只改本地数据(渲染、动画、客户端预测),服务端不参与校验。
值域 / 边界检查:修改的参数若超出物理/逻辑合法范围直接暴露。
双份存储比对 (Shadow Copy):引擎对关键数据结构维护两份拷贝。
只改了寄存器,内存中的 shadow 副本未同步 → 下一轮检查暴露。
CRC / Checksum:某游戏安全 SDK 在 UE4 中插入大量 CheckIntegrity() 调用,对 ACharacter、AController、UWorld 等关键结构体做周期性 hash。任何外部内存写入都会被检测。
同一帧内多个相互关联的参数必须自洽。
NaN / Inf 注入:错误的 SIMD 写入可能产生非数值。
精度模式:ins vN.s[0] 仅写低 32 位 (float),高 96 位保持不变。如果原本的 qN 高位不是干净的(例如之前被用作 double 或 int64),残留值可被检测。
FPSR 异常标志:ARM64 FPSR 寄存器记录浮点运算异常(IDC/IXC/UFC/OFC/DZC/IOC)。强反作弊周期性读取并清零 FPSR,若在不应该有浮点异常的代码段中出现异常标志 → 有外部干扰。
SP 对齐:ARM64 ABI 要求 SP 必须 16 字节对齐。通过 mod_sp 修改 SP 若未保证对齐 → ldp/stp 触发 SP alignment fault → 被信号处理器捕获。
NZCV 标志位:修改 GPR 不直接影响条件码。但若修改了会触发条件跳转的关键值(如修改 x0 后紧接着 cbz x0),执行流可能偏离预期路径,间接导致崩溃。
单个断点 5μs 可忽略。但如果同时有多个断点,或 hook 了 per-frame 高频函数(每帧调用数百次),累积延迟可超过 1ms。Android 上可通过 Choreographer / FrameMetrics API 或 UE4 内置的 Stats 系统检测帧时间异常。
Android 上的反作弊会在用户态扫描运行环境:
这些检测的是注入框架本身而非寄存器修改行为,但环境异常会触发整体的安全标记。注意:Android GKI 限制下反作弊内核驱动的能力有限,上述检测主要在用户态执行。
| 方案 |
问题 |
ptrace |
性能差,易被检测(TracerPid),需要停止进程 |
| 硬件断点 |
ARM64 只有 4-6 个硬件断点寄存器,数量极其有限 |
| Inline Hook |
需要写代码段(触发 mprotect 或写时复制),XOM 保护下有风险 |
| PLT/GOT Hook |
仅限动态链接函数,无法 hook 内部函数 |
| 组件 |
详情 |
| 目标设备 |
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) |
| 指标 |
数值 |
| 断点槽位上限 |
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) |
| 场景 |
延迟 |
倍数 |
| TLB 热命中(正常) |
1-4 cycles |
基准 |
| TLB 冷命中(正常缺 TLB) |
100-200 cycles |
~50× |
| 断点触发(缺页异常路径) |
5000-20000+ cycles |
50-200× vs 冷 TLB |
| 检测方式 |
可行性 |
难度 |
对 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) |
| 检测手段 |
风险 |
本项目暴露面 |
| 值域校验 (边界/NaN) |
中 |
v_mode 选错致高位垃圾,或值超出合法范围 |
| 双份存储比对 (shadow) |
高 |
只改了寄存器,内存 shadow 未同步 |
| CRC / Checksum |
高 |
同上,引擎周期性 hash 检测 |
| 一致性校验 |
中 |
修改单一参数导致关联参数不自洽 |
| 帧间连续性 |
中 |
硬赋值导致跳变而非渐变 |
| FPSR 异常标志 |
低 |
ins/ldr 不产生浮点运算,FPSR 不受影响 |
| NZCV 污染 |
无 |
不改条件码 |
| SP 对齐 |
低 |
不改 SP 或确保 16 对齐即安全 |
| 帧 timing |
低 |
one-shot + 低频 hook,5μs 可忽略 |
| 环境扫描 |
高 |
框架本身 (APatch/KPM) 需加固隐藏 |
断点类型 PTE 操作 CPU 异常 ESR_EL1.EC
──────────────────────────────────────────────────────────────────────────────
执行断点 PTE_UXN = 1 (bit 54) Instruction Abort (EL0) 0x20
读断点 PTE_USER = 0 (bit 6) Data Abort (EL0) 0x24, WnR=0
写断点 PTE_RDONLY = 1 (bit 7) Permission Fault (EL0) 0x24, WnR=1
[1] 设置断点
bp_set() → walk_and_modify_pte() → 修改 PTE 权限位 → flush TLB
g_bp_table[slot].active = 1
[2] 进程触发
CPU 取指/读写 → MMU 检查 PTE → 权限违规
→ 硬件保存 EL0 上下文到 pt_regs
→ 跳转 do_page_fault(addr, esr, regs)
[3] Hook 拦截 (before_do_page_fault)
→ 匹配 g_bp_table (PID + 页地址 + 访问类型)
→ 读取 x0-x7 (pt_regs) + 栈参数 (copy_from_user) + SIMD q0-q31 (stp 汇编)
→ 应用寄存器修改 (ins/ldr 汇编 + pt_regs 写回)
→ 恢复原始 PTE (写预映射地址)
→ g_bp_table[slot].active = 2
[4] 原始 do_page_fault 执行
→ PTE 已正常 → 返回 0
[5] CPU 重试指令
→ 使用修改后的寄存器 → 函数正常执行
[6] 用户态轮询
PRCTL_BP_QUERY → 获取命中信息 → 重新 PRCTL_BP_SET (re-arm)
* b0d7c59 master, stable — V3: one-shot + 寄存器修改 (PRCTL_BP_MODIFY)
* 6283514 — V2: 纯 one-shot,移除阻塞模式
* c1e0d1b brk-failed — V1: 阻塞模式 + 寄存器修改 (备份)
struct bp_request {
pid_t target_pid;
uint64_t vaddr;
uint32_t bp_type;
int32_t bp_id;
};
struct bp_hit_info {
int32_t bp_id;
uint64_t vaddr, hit_pc, hit_esr;
uint64_t x_regs[8];
uint64_t v_regs[64];
uint64_t sp;
uint64_t stack_args[6];
int32_t active;
};
struct bp_modify {
int32_t bp_id;
uint16_t x_mask;
uint16_t v_mask;
uint8_t v_mode;
uint8_t mod_sp;
uint64_t x_regs[8];
uint64_t v_lo[8];
uint64_t v_hi[8];
uint64_t new_sp;
};
#define BP_MAX_COUNT 32
struct mmu_breakpoint {
pid_t target_pid;
uint64_t vaddr;
uint32_t bp_type;
uint64_t orig_pte;
uint64_t pte_table_kva;
int pte_index;
uint64_t hit_pc, hit_esr, hit_sp;
uint64_t hit_x_regs[8];
uint64_t hit_v_regs[64];
uint64_t hit_stack_args[6];
int active;
uint16_t mod_x_mask, mod_v_mask;
uint8_t mod_v_mode, mod_sp, mod_ready;
uint64_t mod_x_regs[8], mod_v_lo[8], mod_v_hi[8], mod_new_sp;
};
Level 0 (PGD): idx = (vaddr >> 30) & 0x1FF // 39-bit VA, 3 级页表
Level 2 (PMD): idx = (vaddr >> 21) & 0x1FF // PUD 折叠
block mapping? → 直接修改 block descriptor
Level 3 (PTE): idx = (vaddr >> 12) & 0x1FF
new_pte = (orig_pte & ~clear_mask) | set_mask
执行断点: set_mask |= PTE_UXN
读断点: clear_mask |= PTE_USER
写断点: set_mask |= PTE_RDONLY
static void before_do_page_fault(hook_fargs3_t *args, void *udata) {
unsigned long fault_addr = (unsigned long)args->arg0;
unsigned int esr = (unsigned int)args->arg1;
struct pt_regs *regs = (struct pt_regs *)args->arg2;
unsigned int ec = (esr >> 26) & 0x3F;
if (ec != 0x20 && ec != 0x24) return;
int hit_type = (ec == 0x20) ? BP_TYPE_EXEC
: (esr & (1<<6)) ? BP_TYPE_WRITE : BP_TYPE_READ;
...
}
void do_page_fault(unsigned long addr, unsigned int esr, struct pt_regs *regs)
g_bp_fault_func = kallsyms_lookup_name("do_page_fault");
hook_wrap3((void *)g_bp_fault_func, before_do_page_fault, NULL, 0);
hook_unwrap(g_bp_fault_func, before_do_page_fault, NULL);
[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!