首页
社区
课程
招聘
[原创]ARM64 页表断点——基于 PTE 缺页异常的透明拦截、上下文捕获与寄存器实时修改
发表于: 2026-6-15 18:35 3391

[原创]ARM64 页表断点——基于 PTE 缺页异常的透明拦截、上下文捕获与寄存器实时修改

2026-6-15 18:35
3391

本文介绍一个基于 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 了 prctlbefore 回调):

手动遍历目标进程的页表(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_QUERYcopy_to_user 逻辑只复制了 header(到 x_regs[7])+ v_regs[64]。而 spstack_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/mapsmincore)均不可见。

本文介绍的 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() 调用,对 ACharacterAControllerUWorld 等关键结构体做周期性 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;   // BP_TYPE_EXEC(4) / READ(1) / WRITE(2)
    int32_t  bp_id;     // 输出: 分配的断点 ID (0-31)
};

// 查询命中
struct bp_hit_info {
    int32_t  bp_id;
    uint64_t vaddr, hit_pc, hit_esr;
    uint64_t x_regs[8];       // x0-x7 整数参数
    uint64_t v_regs[64];      // v0-v31: [N*2]=lo64, [N*2+1]=hi64
    uint64_t sp;              // 栈指针
    uint64_t stack_args[6];   // 栈参数 a13-a18
    int32_t  active;
};

// 寄存器修改
struct bp_modify {
    int32_t  bp_id;
    uint16_t x_mask;          // bit[r]=1 → 修改 x_r
    uint16_t v_mask;          // bit[r]=1 → 修改 v_r
    uint8_t  v_mode;          // LO32(0) / LO64(1) / Q128(2)
    uint8_t  mod_sp;          // 0=不改, 1=修改
    uint64_t x_regs[8];       // x0-x7 新值
    uint64_t v_lo[8];         // v0-v7 低 64 位新值
    uint64_t v_hi[8];         // v0-v7 高 64 位新值 (Q128 用)
    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;        // 保存原始 PTE
    uint64_t pte_table_kva;   // PTE 表预映射 KVA (避免 hook 内 memremap)
    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;          // 0=空闲, 1=armed, 2=triggered

    // 修改缓存 (PRCTL_BP_MODIFY 实时更新)
    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    // bit 54, 禁止 EL0 执行
读断点:   clear_mask |= PTE_USER // bit 6,  EL0 无访问权限
写断点:   set_mask |= PTE_RDONLY // bit 7,  标记只读
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;  // Exception Class

    // 仅处理 EL0 异常
    if (ec != 0x20 && ec != 0x24) return;  // IABT_EL0 / DABT_EL0

    // 确定访问类型
    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)
// init
g_bp_fault_func = kallsyms_lookup_name("do_page_fault");
hook_wrap3((void *)g_bp_fault_func, before_do_page_fault, NULL, 0);

// exit
hook_unwrap(g_bp_fault_func, before_do_page_fault, NULL);

[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!

收藏
免费 0
打赏
分享
最新回复 (4)
雪    币: 497
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
只实现对代码段读取操作相关指令寄存器的修改应该就可以实现绕过crc了
2026-6-15 21:56
0
雪    币: 6400
活跃值: (8262)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
ARM处理器应该也有类似intel的虚拟化技术的吧?可以研究用虚拟化技术来实现无痕断点这些  无痕hook这些
2026-6-21 14:08
0
雪    币: 19
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
没github开源地址吗
2026-6-26 15:10
0
雪    币: 94
活跃值: (5307)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
5
666
2026-6-26 15:36
0
游客
登录 | 注册 方可回帖
返回