Skip to Content
Shadow Cheat Engine功能分类极致优化硬件断点

极致优化硬件断点

Scope: 这一页只讲 Extreme 分支的 VBAR takeover + ihook 方案

本页核心: 自己接管 VBAR_EL1,自己写异常向量页,ihook 任意 EL1 kernel function,用 PIC payload 跑自定义逻辑


1. 定位

极致 HWBP 的设计目标只有一条: 命中时不进内核 C 代码

走的是硬件机制:

  1. 配好 DBGBCR/DBGWCR,让 EL0 执行时自然进异常
  2. 异常进来 VBAR_EL1 + 0x400我们自己的向量页 → 保存寄存器 → ring buffer → ERET
  3. 内核 entry.Skernel_entry / el0_sync_handler / do_debug_exception / signal delivery 全部跳过

源文件:

文件职责
kern_vbar_engine.h通用 VBAR+0x400 handler 搭建框架, EC dispatch, 页表/模块化 slot
kern_extreme_hwbp.hEC=0x30/0x34/0x32 (BP/WP/SS) 自建 handler,ring buffer,9+2 thread 软件多路复用
bp_hook/kern_bp_hook.hEC=0x20/0x21 (IABORT) → ihook → PIC payload
bp_hook/arm64_reloc.hDobby 风格静态重定位 (B/BL, ADR/ADRP, LDR literal, MOVZ/MOVK)
bp_hook/bp_hook.c独立 ko 入口 (miscdevice + ioctl)
bp_hook/bp_ctl.c用户态 CLI

本页就围绕这几个文件。


2. VBAR_EL1 takeover

2.1 ARM64 异常向量基本盘

VBAR_EL1 是一个 2 KiB 的向量表(16 条 entry × 128 B),每条 entry 的开头允许放不超过 32 条指令(0x80 字节),超出靠 B label 跳到真正的 handler。布局相关偏移:

偏移含义
+0x000Current EL with SP0
+0x200Current EL with SPx
+0x400Lower EL (EL0) AArch64 Sync ← 我们改的就是这里
+0x480Lower EL IRQ

正常内核里, vectors+0x400 的第一条指令是 B el0_sync。我们把它原子替换B our_handler_page,保存原字节以便卸载时还原。

2.2 patch 三件套

kern_vbar_engine.hkern_extreme_hwbp.h / kern_bp_hook.h 都走同一个 pattern:

g_x.vectors_addr = kln("vectors"); /* kallsyms_lookup_name */ g_x.handler_page = call_module_alloc(N * PAGE_SIZE); memset(handler_page, 0, ...); /* ... 填充 asm 指令 bytes 到 handler_page ... */ call_set_memory_x(handler_addr, N); flush_icache_range(handler_addr, handler_addr + N*PAGE_SIZE); /* 关键一步: 保存原字节 + 原子 patch */ g_x.orig_vec_insn = *(u32 *)(vectors_addr + 0x400); u32 b_insn = make_b(vectors_addr + 0x400, handler_addr); call_patch_text_nosync((void *)(vectors_addr + 0x400), b_insn); on_each_cpu(ipi_flush_icache, NULL, 1);

四条关键 helper(通过 klnkern_kpatch.h 里 resolve):

  • kallsyms_lookup_name → 拿 vectors 符号 VA
  • module_alloc(size) → 分配内核模块空间(RWX 前身,vmalloc 区域)
  • set_memory_x(addr, npages) → 把页标 X
  • patch_text_nosync(addr, insn) → 对正在执行的代码做单 insn 原子 patch

IPI ic iallu; dsb nsh; isb 让所有 CPU 看到新 insn。

2.3 B 指令的 ±128 MB 限制

B 的 imm26 有符号左移 2 是 ±128 MB,vmalloc 区通常和 vectors 在同 128 MB 范围(两者都在 kernel linear map 附近),所以一条直接 B 够用:

static u32 make_b(unsigned long from, unsigned long to) { long off = (long)(to - from); if (off < -0x8000000L || off >= 0x8000000L) return 0; return 0x14000000 | ((u32)(off >> 2) & 0x03FFFFFF); }

如果未来超范围,备选方案是在 +0x400 放一个 LDR X16, [PC,#8]; BR X16; .quad handler_addr 三指令 trampoline —— 但会占掉 +0x400..+0x40B 三条 insn,需要把 fallthrough 锚点从 +0x40C 挪到 +0x410。目前不需要。

2.4 卸载协议

call_patch_text_nosync((void *)(vectors_addr + 0x400), g_x.orig_vec_insn); on_each_cpu(ipi_flush_icache, NULL, 1); synchronize_rcu(); /* 等所有 CPU 离开老 handler */ vfree(handler_page);

synchronize_rcu 很关键 —— 某个 CPU 可能刚 fetch 到老 B our_handler 还没执行完,必须等它 ERET 出去才能 vfree handler_page。否则 EL1 prefetch abort 内核直接 oops。


3. 异常向量 handler 字节级实现

所有 handler 都是手写 arm64 机器码(C 无法保证不起栈帧、不起 prologue、不访问 GOT),以 u32 h[idx++] = 0xXXXXXXXX 编码。以下是 extreme HWBP 的真实 handler (kern_extreme_hwbp.h:242 起)。

3.1 Dispatcher (vectors+0x400 第一条 B 跳来的地方)

stp x0, x1, [sp, #-16]! ; 0xA9BF07E0 借 SP_EL1 栈顶 spill 两 reg mrs x0, esr_el1 ; 0xD5385200 lsr x1, x0, #26 ; 0xD35AFC01 → EC 字段 cmp x1, #0x30 ; 0xF100C03F b.eq bp_path ; EC=0x30: BP from EL0 cmp x1, #0x34 ; 0xF100D03F b.eq wp_path ; EC=0x34: WP from EL0 cmp x1, #0x32 ; 0xF100C83F b.eq ss_path ; EC=0x32: Software Step cmp x1, #0x20 ; 0xF100803F b.eq pte_path ; EC=0x20: IABORT (交给 ihook 逻辑) ldp x0, x1, [sp], #16 ; 不关我们的事 b vectors+0x40C ; ← fallthrough 回内核正常路径

注意最后一条 b vectors+0x40C —— 我们抢的是第一条 insn,原向量 entry 从 +0x404 开始还有 31 条 insn 可用。回到 +0x40C 是跳过 +0x400..+0x408 三条(因为原向量里前几条是 prologue 的一部分,直接接到 kernel_entry 的主体 B 跳转)。具体锚点取决于 kernel 版本,6.6.89 实测 +0x40C 刚好是 B el0_sync 之前一条。

3.2 WP 超快路径 (ULTRA-FAST, 代价摊到 SS)

AC 测延迟的方式是 STR 指令前后取 cntvct_el0。关键观察:WP 异常在 STR 完成后才 fire,所以只要让 STR → WP exception → ERET → next insn 这段尽可能短,SS 异常再慢也不会被 AC 计入 delta。

wp_path: adr x0, data_base ; 一条 ADR 定位 handler_page+0x600 ldr x1, [x0, #8] ; wp_hits add x1, x1, #1 str x1, [x0, #8] mrs x1, elr_el1 str x1, [x0, #0x10] ; saved_elr, SS 阶段用 mrs x1, dbgwcr0_el1 str x1, [x0, #0x80] ; saved_wcr and x1, x1, #~1 msr dbgwcr0_el1, x1 ; 关 WP, 下一条不再 trap mrs x1, mdscr_el1 orr x1, x1, #1 ; MDSCR.SS=1 msr mdscr_el1, x1 mrs x1, spsr_el1 orr x1, x1, #0x200000 ; SPSR.SS=1 msr spsr_el1, x1 mov x1, #1 str x1, [x0, #0x40] ; stepping=1 (WP owner) ldp x0, x1, [sp], #16 eret ; ← AC 看到的 STR 完成点

寄存器 save 推迟到 SS 路径做,因为 AC 测不到 SS 的时间。

MDSCR.SS / SPSR.SS 这些 single-step 位只在这里一笔带过(是关断点、让 CPU 往前跨一条的必要动作)。完整的 stepping 语义由 ARM 架构定义,详细走位属于 内核 PTE 断点 的讨论范围 —— 那边有更深入的 stepping 分析。本页不展开。

3.3 SS 路径 (延迟写 ring)

ss_path: adr x0, data_base ldr x1, [x0, #0x40] ; stepping cmp x1, #1 b.eq wp_ss_path ; 延迟做 WP 的寄存器保存 cmp x1, #2 b.eq bp_ss_path ; BP 的恢复 BCR cmp x1, #3 b.eq pte_ss_path ldp x0, x1, [sp], #16 b vectors+0x40C ; 非我们的 SS

wp_ss_path 里做完整 33-reg save,再恢复 dbgwcr0_el1、清 MDSCR.SS,ERET 回用户态(回到 STR 的下一条,但此时 AC 测延迟已经读完了)。

3.4 Ring buffer 结构 (kern_extreme_hwbp.h:51-113)

资源
EHWBP_TOTAL_PAGES290 (1 代码+数据, 289 ring)
EHWBP_RING_SIZE4096 (power-of-2)
EHWBP_RING_ENTRY_SIZE288 B (272 payload 对齐到 32)
每 entryu32 pid; u32 tid; u64 regs[33] (x0-x30, sp, pc)
Ring basehandler_page + 0x1000,缓存在 DATA_BASE+0x78
ring_idxDATA_BASE+0x18,atomic LDADD x0, x0, [x1] 递增

Data area 内偏移:

+0x00 bp_hits +0x08 wp_hits +0x10 wp_last_elr +0x18 ring_idx +0x20..+0x38 saved_wcr0..3 +0x40 stepping +0x48..+0x70 saved_bcr0..5 +0x78 ring_base_cache +0x80 saved_wcr (单槽位快路径) +0x88 saved_bcr +0x90..+0xC8 PTE 噪声过滤字段 (和 ptebp 集成时用)

atomic 递增用 LSE LDADD 而非 load/cmpxchg —— 多核 SMP 下 per-CPU 同时命中也不乱序。

3.5 EMIT_REG_SAVE 宏

33 个寄存器一次保存的关键片段(展开后约 30 条 insn):

stp x2, x3, [x0, #16] stp x4, x5, [x0, #32] stp x6, x7, [x0, #48] ... (stp 到 x28,x29) str x30, [x0, #240] mrs x1, sp_el0 ; str x1, [x0, #248] mrs x1, elr_el1 ; str x1, [x0, #256] ← PC ldr x1, [sp] ; str x1, [x0] ← 原 x0 (spill 那条) ldr x1, [sp, #8] ; str x1, [x0, #8] ← 原 x1

栈上那两条 ldr x1, [sp] / ldr x1, [sp, #8] 把最开始 dispatcher 处 spill 的 x0/x1 取回来补进 ring —— 这是为什么整段里只敢用 x0/x1 两个 scratch。


4. 9-thread HWBP + 2-thread WP 软件多路复用

ARM64 per-CPU 只有 6 个 HW breakpoint 槽 (DBGBCR0-5) 和 4 个 watchpoint 槽 (DBGWCR0-3)。它们是每线程独立 schedule 的 —— 内核在 __switch_to 时重装 BCR/WCR。Changeling 分支的实战配置:

  • HWBP × 9: 跨 9 个游戏线程各装一套(render thread、game thread、audio、net、2× worker…),每线程独占 DBGBCR0 一个槽就够
  • WP × 2: 盯 2 个数据地址(coord、ammo),同一进程内的每个线程都装,DBGWCR0/1 各一个

4.1 slot → thread 展开 (kern_extreme_hwbp.h:900-965)

rcu_read_lock(); leader = pid_task(find_vpid(slot.pid), PIDTYPE_PID)->group_leader; get_task_struct(leader); rcu_read_unlock(); hw_breakpoint_init(&attr); attr.bp_addr = slot.addr; attr.bp_len = HW_BREAKPOINT_LEN_4; attr.bp_type = hw_type; /* R/W/RW */ attr.exclude_kernel = 1; rcu_read_lock(); for_each_thread(leader, t) { if (nr >= EHWBP_MAX_THREADS) break; /* 256 cap */ /* skip if already registered */ get_task_struct(t); rcu_read_unlock(); wp = k_register_user_hw_breakpoint(&attr, NULL, NULL, t); /* overflow=NULL! */ ... rcu_read_lock(); } rcu_read_unlock();

关键: overflow_handler = NULL。内核 hw_breakpoint 框架只负责 context-switch 时装载 DBGBCR/DBGWCR,命中回调根本不会被调 —— 因为 VBAR handler 在 kernel_entry 之前就 ERET 了。这是和 perf_event_open 路径的本质不同: 我们借内核”装寄存器”,但不借它”处理命中”。

register_user_hw_breakpoint / perf_event_open 的用户态直接用法属于 用户态硬件断点

4.2 新线程刷新

目标进程随时 pthread_create,新线程默认没装 BCR/WCR。poll 路径每 20 次 (~1 Hz) 调一次 ehwbp_refresh_threads(slot):

/* kern_extreme_hwbp.h:1017 */ if (++poll_counter >= 20) { poll_counter = 0; for (i = 0; i < EHWBP_MAX_WRP; i++) if (g_ehwbp.slots[i].active) ehwbp_refresh_threads(i); }

refresh 里重走 for_each_thread,跳过已注册的 task。实测 UE4 游戏线程从 30 涨到 120 都稳。

4.3 slot 数据结构

struct { uint64_t addr; int access; int pid; bool active; int nr_threads; struct { struct perf_event *perf_wp; struct task_struct *task; } threads[EHWBP_MAX_THREADS]; /* 256 */ } slots[EHWBP_MAX_WRP]; /* 4 */

软件层这样组织后,逻辑上”一个 slot 监视一个 VA,物理上展开到 256 线程”就是多路复用的全部 —— 硬件槽位 4 个,软件槽位 4 × 线程数


5. ihook: inline hook for EL1 kernel function

ihook 和 HWBP 是两条并行的 VBAR 路径,都走 +0x400 但吃不同的 EC。ihook 吃 EC=0x20/0x21 (IABORT from EL0)。

5.1 核心 trick: 页整页 no-exec

bp_hook.ko 不改 target 代码字节,也不写 BRK/B detour —— 它把 target 所在整页的 PTE 设成 UXN=1 (user-non-executable)。任何 EL0 fetch 该页 → IABORT → vectors+0x400 → 我们的 handler → 查 hook table → MSR ELR_EL1, payload_va; ERET 跳到 payload。

零页面修改,字节级扫描检测完美绕过

5.2 同页非 hook 指令的 sspath

同页上 hook 之外的指令也会 IABORT。Handler 在 hook table 扫不到 PC 匹配时走 sspath:

  1. 临时把 PTE 改回 executable (local tlbi vale1 只本 CPU 看到放行)
  2. 开 single-step 位让 CPU 跨一条
  3. ERET → CPU 跑一条 → step exception 回来
  4. Step handler 把 PTE 改回 UXN=1, broadcast tlbi vae1is, 清 step bit,ERET

整套 sspath ~20 cycles,代价存在但 acceptable —— 且只在同页不是 hook 的指令执行时付出。hit 路径不走 sspath,只 15 cycles。

5.3 Hit 快路径 (bp_hook/kern_bp_hook.h:260-295)

最热的是 slot-0 单 hook 的情况,handler 里有专门的 FAST_HIT:

ia_path: adr x0, data_base mrs x1, far_el1 ; 故障 PC ldr x0, [x0, #0x40] ; hook[0].hook_va cmp x0, x1 b.ne slow_scan ; slot 1..31 扫一遍 adr x0, data_base ldr x1, [x0, #0x48] ; hook[0].payload_va msr elr_el1, x1 ; ← 重定向 PC ldr x1, [x0, #0x00]; add x1,x1,#1; str ... ; hit++ ldp x0, x1, [sp], #16 eret ; → payload_va

单 hook 场景 ia_patheret13 条指令,~15 cycles (~780 ns @ 19.2 MHz tick)。Slow scan 循环 32 个 slot,最坏情况 ~30 cycles。

5.4 hook table 布局

BPH_HOOK_TABLE_OFF = 0x40, 每条 entry 32 字节:

偏移字段
+0hook_va (0=空槽)
+8payload_va
+16page_va (hook_va & ~0xFFF,用于快速页匹配)
+24asid (进程匹配,0 = 任意)

最大 32 个 hook、32 个独立 trapped page。handler 里的 scan 是纯 asm 循环,无 C call。

5.5 hook 安装流程 (内核侧)

/* bph_add_hook(pid, hook_va, payload_va): */ /* 1. walk PTE: */ pte_t *ptep = bph_walk_pte(mm, hook_va); saved_pte = __ptep_get(ptep); /* 2. build trap_pte: saved_pte 的基础上 set UXN */ trap_pte = __pte(pte_val(saved_pte) | PTE_UXN); /* 3. 写 hook_table / page_table (asm handler 直接消费) */ hook_entry[0].hook_va = hook_va; hook_entry[0].payload_va = payload_va; hook_entry[0].page_va = hook_va & PAGE_MASK; hook_entry[0].asid = ASID(mm); page_entry[0].page_va = hook_va & PAGE_MASK; page_entry[0].cached_ptep = ptep; page_entry[0].saved_pte = saved_pte; page_entry[0].trap_pte = trap_pte; /* 4. 切 PTE → trap */ __set_pte(ptep, trap_pte); bph_tlb_flush_page(mm, hook_va);

bph_tlb_flush_pagetlbi vae1is + dsb ish 广播,让所有 CPU 立刻看到 no-exec。


6. PIC payload (position-independent code)

payload 是目标进程地址空间里的代码,必须 PIC —— 加载地址是 BP_BUF 动态分配的,编译期不知道。

6.1 编译命令

aarch64-linux-gnu-gcc -O2 -fPIC -nostdlib -fno-stack-protector \ -Wl,-shared -Wl,-e,payload_entry \ -o payload.bin payload.c objcopy -O binary --only-section=.text payload.bin payload.raw
  • -fPIC: 所有函数/全局引用走 GOT/PLT 间接,无硬编码地址
  • -nostdlib: 自带 syscall wrapper,不引入 glibc
  • -fno-stack-protector: 禁用 canary(会引用 __stack_chk_guard TLS)
  • 禁用: static 变量、TLS、string literals(都会变成 ADRP+ADD 固定地址,relocation 代价大)

6.2 hook_ctx 进入契约

VBAR handler ERET 到 payload 时:

PC = payload_va ELR_EL1 = payload_va x0-x30 = 目标指令被打断前的原值 (handler 只用 SP_EL1 栈 spill,未碰用户寄存器) sp = 用户原栈 原始 hook_va = 丢失,payload 若需 return 要自己保存 SPSR_EL1 = EL0t 原值(未改 DAIF/SS 等)

payload 结尾如果要跳回 hook_va 的下一条继续执行,需要一个 detour stub:

detour_stub: <relocated hook_va 的第 1 条 insn> ; arm64_reloc 改过位移 LDR X16, [PC, #8] BR X16 .quad hook_va + 4

这个 stub 本身也放在 VMA gap 分配的 RWX 页里。

6.3 VMA gap 装载 (bp_hook.h:56-67)

struct bp_buf_req { pid_t pid; unsigned long size; unsigned long out_addr; /* [out] */ }; #define BP_BUF _IOWR(BP_IOC_MAGIC, 6, struct bp_buf_req)

内核侧走 do_mmap 在目标 mm 里找空洞 (找两个相邻 vma 之间的 gap,避开 ELF 文件区/stack/heap),建 anon VMA with VM_READ|VM_WRITE|VM_EXEC,返回 out_addr

用户态拿到 out_addr 之后:

ioctl(devfd, BP_WRITE, &{.addr = out_addr, .buf = payload_raw, .len = payload_size}); ioctl(devfd, BP_HOOK, &{.pid = pid, .hook_va = target, .payload_va = out_addr});

为什么用 VMA gap 而不是 mmap 一个新段: mmap 会被目标进程的 /proc/self/maps 看到,多一条可疑 region。gap 利用已有 maps 区域之间的未用空间,stealth 高。

6.4 payload 写 syscall 的姿势

因为 -nostdlib,payload 自己写 wrapper:

static inline long sys_write(int fd, const void *buf, long len) { register long x8 asm("x8") = 64; /* __NR_write */ register long x0 asm("x0") = fd; register long x1 asm("x1") = (long)buf; register long x2 asm("x2") = len; asm volatile("svc #0" : "=r"(x0) : "r"(x0), "r"(x1), "r"(x2), "r"(x8) : "memory"); return x0; }

千万不要在 payload 里调用 libc —— 没链接、没 GOT、直接炸。


7. arm64_reloc.h 重定位宏

PIC payload 里最难的一环是把原 hook_va 那条 PC-relative 指令搬到新地址arm64_reloc.h 按 Dobby 思路做了一套静态重定位。

7.1 覆盖的指令族

类型编码识别重定位策略
B / BL (imm26)(insn & 0x7C000000) == 0x14000000LDR X16, [PC,#8]; BR/BLR X16; .quad target
B.cond (imm19)(insn & 0xFF000010) == 0x54000000反转 cond B.!cond +12; LDR X16, [PC,#data]; BR X16
CBZ/CBNZ(insn & 0x7E000000) == 0x34000000反转 CB +12; LDR X16; BR X16
TBZ/TBNZ(insn & 0x7E000000) == 0x36000000同上 (imm14)
ADR/ADRP (imm21)(insn & 0x1F000000) == 0x10000000MOVZ Xd,h3 LSL 48 + MOVK h2/h1/h0
LDR (literal) 32/640x18.../0x58...LDR X16, [PC,#data]; LDR Rt, [X16]
LDR (literal) SIMD0x1C/5C/9C...同上 + FP reg
LDRSW (literal)0x98...同上
PRFM (literal)0xD8...直接 NOP (prefetch 只是 hint)
其它原样 emit

7.2 emit_mov_imm64 (核心原语)

把任意 64-bit 立即数加载到 Xd。用在 ADR/ADRP 重定位、B target 计算等:

static inline void emit_mov_imm64(struct reloc_ctx *ctx, int rd, uint64_t imm) { uint16_t h0 = imm & 0xFFFF; uint16_t h1 = (imm >> 16) & 0xFFFF; uint16_t h2 = (imm >> 32) & 0xFFFF; uint16_t h3 = (imm >> 48) & 0xFFFF; /* MOVZ Xd, #h3, LSL#48 */ emit32(ctx, 0xD2E00000 | ((uint32_t)h3 << 5) | rd); if (h2 || h1 || h0) emit32(ctx, 0xF2C00000 | ((uint32_t)h2 << 5) | rd); /* MOVK h2 LSL 32 */ if (h1 || h0) emit32(ctx, 0xF2A00000 | ((uint32_t)h1 << 5) | rd); /* MOVK h1 LSL 16 */ if (h0) emit32(ctx, 0xF2800000 | ((uint32_t)h0 << 5) | rd); /* MOVK h0 */ }

7.3 reloc_ctx

struct reloc_ctx { uint64_t orig_pc; /* 当前原指令的 PC */ uint8_t *buf; /* 输出 buffer */ uint64_t buf_va; /* buffer 的 VA */ int buf_max; int code_off; };

reloc_init / reloc_insn / reloc_finish: 为一条原指令生成任意长度的替换序列, code_off 动态推进, 用 X16 (IP0, AAPCS64 scratch) 作中转寄存器 —— 调用点只能假设 X16 会被 clobber,不能依赖。

7.4 为什么选 X16

AAPCS64 规定 X16/X17 (IP0/IP1) 是函数间调用的临时寄存器,linker veneer 也用它们。目标 hook 指令通常是函数内部指令,这个时候 X16 没有活动值 —— 用它最安全。用 X0-X7 会打断参数传递,用 X19-X28 会打断 callee-saved。


8. emit_mov_imm64 bug — 完整案例

2026-03-31 DFM session 通宵 debug,这是 extreme ihook 栈上最血的 bug。所有 ihook 只要装上 payload,目标进程就 SIGILL 或内核 panic。团队第一反应: “TerSafe AC 在扫 payload”。查了一整天反检测,全是死胡同。

8.1 现象

  • bp_ctl hook 返回成功, bp_ctl status 显示 hit=1
  • 目标进程立刻崩: SIGILL (userspace) 或 kernel IABORT (VBAR 自建页错位)
  • 反汇编 payload 前 20 字节正常,往后越看越错:应该是 BR X16 的位置变成 MOVK
  • 只在某些 payload_va 触发:比如 0x7A4C_0000_1234 就崩, 0x7A4C_1234_5678 就不崩

8.2 根因

emit_mov_imm64 生成 1-4 条 MOVZ/MOVK,数量依赖立即数 —— 高位为 0 的几个字可以省掉。但上层调用点按 4 条 (16 字节) 固定偏移消费后续 byte:

/* 调用点期望布局 (B/BL relocation): */ offset 0: LDR X16, [PC, #8] (4 bytes) offset 4: BR X16 (4 bytes) offset 8: .quad target (8 bytes) total: 16 bytes /* 如果中间混进 emit_mov_imm64 (比如 ADRP relocation): */ offset 0: MOVZ X16, #h3 LSL 48 (必发) offset 4: MOVK X16, #h2 LSL 32 (可能省) offset 8: MOVK X16, #h1 LSL 16 (可能省) offset 12: MOVK X16, #h0 (可能省)

当 target 是 0x...._FFFF_FFFF_FFFF_0000 这种 h0==0 的值, emit_mov_imm64 只发 3 条指令 (12 字节),可接下来调用点又写 BR X16code_off + 16。结果:

  1. buffer 里的字节排布被错位
  2. 后续 BR 覆盖到本该是 MOVK 的位置 —— 但那条 MOVK 其实没被 emit
  3. 或者更糟: BR 被覆盖掉,CPU fetch 到 MOVK 就继续往下走,执行到任意栈垃圾

8.3 为什么看着像 AC

  • payload 前几个字节总是完好 (MOVZ 必发)
  • 某些偏移后字节”被篡改”(实际是 emit 布局串位)
  • 只在部分 target 触发 (h 各字为 0 的才出错)
  • crash PC 的反汇编看上去像”代码被注入过”

所有症状都符合 AC 扫 payload 篡改内存的假设。

8.4 定位过程

最后靠 IDA 拆 dmesg 里 hex dump 的 payload raw bytes vs 编译出的 payload.bin raw bytes 字节级 diff,才发现”装载前 / 装载后”字节不一致 —— 但不在目标 process 的 VMA 里,是 reloc 输出 buffer 本身就错了。所以根本没 AC 什么事。

8.5 修复 (arm64_reloc.h)

最直接: 固定永远发 4 条 MOVZ/MOVK:

emit32(ctx, 0xD2E00000 | ((uint32_t)h3 << 5) | rd); /* MOVZ h3 LSL 48 */ emit32(ctx, 0xF2C00000 | ((uint32_t)h2 << 5) | rd); /* MOVK h2 LSL 32 */ emit32(ctx, 0xF2A00000 | ((uint32_t)h1 << 5) | rd); /* MOVK h1 LSL 16 */ emit32(ctx, 0xF2800000 | ((uint32_t)h0 << 5) | rd); /* MOVK h0 */

代价: 低位为 0 的立即数多发 8 字节 no-op MOVK,零性能影响。

8.6 教训

不要让 emit helper 的长度依赖参数、而消费侧又用硬编码偏移。 要么 helper 固定长度,要么消费侧用 ctx->code_off 动态跟踪位置。

参考 feedback_emit_mov_bug.md —— 这个 bug 误判成”AC 检测”浪费整整一天。核心 workflow 变化:

  1. reloc 单元测试必须覆盖 h0/h1/h2/h3 各自为 0 的排列组合
  2. 每次动 arm64_reloc.h 后用 hook_666 最小 demo 跑一遍
  3. 字节级 diff: payload source → reloc output → target VMA bytes → /proc/pid/maps 读取的 bytes,四处对比

9. bp_hook.ko 独立模块

9.1 为什么独立成 ko

  1. VBAR 冲突隔离: shadow_ce.ko 的 VBAR 主要处 DABORT / WP,而 bp_hook.ko 处 IABORT。两个 handler 各自 patch vectors+0x400 的 B 跳转 —— 不能同时在线,必须互斥 activate。独立成 ko 做到运行时按需加载。
  2. 可选性: 只用 HWBP 的用户不需要 ihook 的 code path,减少 attack surface。
  3. 调试周期: ihook + PIC payload 是整个 extreme 栈里最容易出 bug 的部分,独立 ko rmmod bp_hook && insmod bp_hook.ko 即可重置,不动 shadow_ce 主线。

9.2 Makefile (bp_hook/Makefile)

obj-m := bp_hook.o ccflags-y += -I$(src)/.. KDIR := /root/kernel_build/kernel_workspace/kernel_platform/common OUTDIR := $(KDIR)/out CLANG_PATH := /root/aosp-clang-r510928/bin all: PATH=$(CLANG_PATH):$$PATH make -C $(KDIR) O=$(OUTDIR) M=$(PWD) \ ARCH=arm64 LLVM=1 CC=clang LD=ld.lld \ CROSS_COMPILE=aarch64-linux-gnu- \ modules @mkdir -p build && mv -f *.o *.mod *.mod.c *.mod.o \ modules.order Module.symvers build/ 2>/dev/null; true

关键约束:

  • 必须用 AOSP Clang (/root/aosp-clang-r510928/). SM8750 A16 kernel 6.6.89 用 GCC 会碰 CFI 签名失配
  • KDIR 指向编译好的内核 common/ 树 (含 out/Module.symvers, 否则 call_module_alloc / patch_text_nosync 这类 EXPORT_SYMBOL 拿不到)
  • ARCH=arm64 LLVM=1 CC=clang LD=ld.lld 对齐 Android 内核的 toolchain
  • 中间产物全丢到 build/,保持源树干净

9.3 Module.symvers 的必要性

没有 symvers, ko 加载会报 “Unknown symbol in module” 或者 “symbol version mismatch”。流程:

  1. 先在 kernel_platform/common/out/ 里完整编译一遍内核
  2. out/Module.symvers 里包含所有 EXPORT_SYMBOL_GPL 的哈希
  3. 编译 bp_hook.ko 时 kbuild 自动 include,保证 call_module_alloc 等绑定正确版本

9.4 CFI_CLANG 兼容

SM8750 内核 CONFIG_CFI_CLANG=y。extreme 栈的 CFI 免疫路线:

  1. VBAR handler 是 raw asm bytes,不经任何 indirect call → CFI 校验表用不到
  2. call_module_alloc / call_set_memory_x / call_patch_text_nosync 这些 wrapper 通过 kln resolve 出函数指针,不是 C 层 direct call。shadow_ce 对这些 helper 用 __nocfi 属性跳过 hash check
  3. register_user_hw_breakpoint 是合法 EXPORT_SYMBOL_GPL,CFI 表里有对应签名,正常 call 就行

详见 project_cfi_hook_research.md —— 那里对比了 kprobes / ftrace / vendor hooks / __nocfi 四种 CFI 绕过方案。VBAR asm 属于第 0 种”根本不经 CFI”。

9.5 ko 入口 (bp_hook.c)

static int __init bp_hook_init(void) { /* 1. 随机 8 字符设备名 bpXXXXXX 避开 AC 签名 */ bp_dev_name[0]='b'; bp_dev_name[1]='p'; get_random_bytes(bp_dev_name+2, 6); for (i=2;i<8;i++) bp_dev_name[i]='a'+(bp_dev_name[i]%26); bp_dev_name[8]=0; /* 2. resolve kallsyms_lookup_name */ kpatch_init(); /* 3. 建 handler page + patch VBAR */ bph_init(); /* 4. miscdevice 注册 /dev/bpXXXXXX */ misc_register(&bp_dev); }

用户态 bp_ctl 通过扫 /dev/ 长度 8 名字 + 试探 BP_STATUS 找设备。

9.6 ioctl 协议 (bp_hook/bp_hook.h)

#define BP_HOOK _IOW (BP_IOC_MAGIC, 1, struct bp_hook_req) #define BP_UNHOOK _IOW (BP_IOC_MAGIC, 2, struct bp_unhook_req) #define BP_STATUS _IOR (BP_IOC_MAGIC, 3, struct bp_status_req) #define BP_READ _IOWR(BP_IOC_MAGIC, 4, struct bp_rw_req) #define BP_WRITE _IOWR(BP_IOC_MAGIC, 5, struct bp_rw_req) #define BP_BUF _IOWR(BP_IOC_MAGIC, 6, struct bp_buf_req) struct bp_hook_req { pid_t pid; unsigned long hook_va; unsigned long payload_va; };

BP_READ/WRITE 是给 bp_ctl 不依赖 shadow_ce.ko 时用的 fallback,走的是 mm_walk_pte + kmap —— 和 内存扫描 里的 PTE walk 是同一套 helper。

9.7 bp_ctl CLI

子命令作用
hook <pid> <hook_va> <payload_va>安装 ihook
unhook <pid> <hook_va>卸载单个
statusdump hits/steps/misses/total 计数
buf <pid> <size>VMA gap 分配,返回 RWX 地址
read <pid> <va> <len>读目标内存
write <pid> <va> <hex>写目标内存
sym <pid> <name>/proc/pid/exe ELF 解析符号 VA

典型一键安装 payload:

PID=$(pidof target) BUF=$(bp_ctl buf $PID 4096) bp_ctl write $PID $BUF $(xxd -p payload.raw | tr -d '\n') SYM=$(bp_ctl sym $PID target_func) bp_ctl hook $PID $SYM $BUF

10. CMD_EXTREME_HWBP_* 协议

shadow_ce 服务器端 (usr_extreme_hwbp_cmd.h) 通过 TCP 暴露给客户端,路由到 shadow_ce.ko ioctl。

10.1 命令表

CMDOpcode方向Payload
EHWBP_ACTIVATE0xE0C→S(无)
EHWBP_DEACTIVATE0xE1C→S(无)
EHWBP_SET0xE2C→Su32 pid; u64 addr; u32 bp_type; u32 bp_size
EHWBP_CLEAR0xE3C→Si32 slot_id
EHWBP_CLEAR_ALL0xE4C→S(无)
EHWBP_POLL0xE5C→Su32 max_events
EPTEBP_ACTIVATE0xE6C→S(无) (走 PTE 栈,本页不覆盖)
EPTEBP_DEACTIVATE0xE7C→S(无)

10.2 Response 格式

  • SETi32 result; i32 slot_id; i32 thread_count
  • CLEARi32 result
  • POLLu32 count; count × (u32 pid, u32 tid, u64 regs[33]) = 272 B/hit

10.3 ioctl 对应

#define SHADOW_CE_EHWBP_ACTIVATE _IO (CE_IOC_MAGIC, 30) #define SHADOW_CE_EHWBP_DEACTIVATE _IO (CE_IOC_MAGIC, 31) #define SHADOW_CE_EHWBP_SET _IOWR(CE_IOC_MAGIC, 32, ...) #define SHADOW_CE_EHWBP_CLEAR _IOWR(CE_IOC_MAGIC, 33, ...) #define SHADOW_CE_EHWBP_POLL _IOWR(CE_IOC_MAGIC, 34, ...)

10.4 完整触发链

ShadowCE client (Swift/macOS) │ TCP CMD_EHWBP_SET (0xE2) [pid][addr][type][size] usr_server.c → ehwbp_handle_set() │ ioctl(devfd, SHADOW_CE_EHWBP_SET, &req) shadow_ce.ko ioctl → ehwbp_set_wp(pid, addr, access, size, slot) │ 首次调用会触发 ehwbp_setup_handler() (patch VBAR) for_each_thread(leader, t): register_user_hw_breakpoint(attr, NULL, NULL, t); /* 借内核装 DBGWCR */ 返回 slot_id + thread_count

10.5 生命周期

install: ACTIVATE → (首次 SET 内部触发 setup_handler) arm: SET → module_alloc handler page + register_user_hw_breakpoint × N_threads hit: VBAR → ring buffer → ERET (~12 ticks) poll: EHWBP_POLL → ioctl 拉 ring → TCP 发回 disarm: CLEAR → unregister_hw_breakpoint per thread uninstall: DEACTIVATE → restore vectors+0x400 → synchronize_rcu → vfree

ring buffer 在 activate 期间保持,stop/start 循环不会丢失 handler page。只有 DEACTIVATE 才真正把 VBAR 改回原样。


11. 实测性能 (本条路径自身)

Changeling / gamma session 实测数据 (SM8750 @ 19.2 MHz cntvct_el0, 52.08 ns/tick):

场景handler 路径指令数ticksns
WP ULTRA-FAST (关 WCR → SS 延迟写 ring)wp_path 15 insn15~12~625
BP from EL0bp_path 关 6 个 BCR + reg save~45~18~940
ihook FAST HIT (slot 0)ia_path 单 cmp 命中~13~15~780
ihook slow scan (slot 15)循环 cmp 直到命中~30~28~1460
ihook sspath miss (同页其它指令)改 PTE + local TLBI + SS + 回来恢复~55 (两次穿越)~40~2080

ring buffer 配置:

  • 4096 entries × 288 B = 1.1 MB 预分配
  • 每 entry = 33 regs (264 B) + pid (4) + tid (4) + align (16) = 288 B
  • 单次 STP pair + MRS 读 SP_EL0/ELR_EL1 约 30 insn 完成 33 reg snapshot
  • atomic LDADD 递增 ring_idx, 无锁,SMP 下多核同时命中不丢

TLBI 粒度(和”为什么这么快”紧相关):

  • sspath 临时 exec 的 TLB 刷新走 local: tlbi vale1; dsb nsh (~12 cycles)
  • step 回来写回 no-exec 走 broadcast: tlbi vae1is; dsb ish (~50 cycles)
  • 不等待 non-IS broadcast,单 CPU 自己看到放行后立刻跨一条 → 避免 cross-CPU sync 阻塞 hot path

12. 限制与风险

12.1 Panic 恢复困难

SM8750 设备无 pstore / ramoops (见 feedback_no_pstore.md)。VBAR handler 或 asm emit 一旦 bug:

  • reboot 后 /sys/fs/pstore/
  • dmesg 随重启消失,丢失 crash 信息
  • 唯一办法: 增量改 + 每步插 pr_info 面包屑 + 断电重启后 dmesg | tail 反推

这是 extreme 方案的最大工程成本 —— 每次改 handler 汇编都得非常小心,不能”先试再看”。

12.2 vectors+0x400 改写的 SMP 窗口

call_patch_text_nosync 是 no-sync 的单指令 patch。中间有 µs 级窗口:某个 CPU 刚 fetch 老 B el0_sync、另一个 CPU 已经走新 B handler。流程保证:

  1. handler page 先 set_memory_x + icache flush
  2. 再改 vectors+0x400
  3. IPI 所有 CPU 重刷 icache

不要颠倒这个顺序 —— 否则有 CPU fetch 到新 B,但 handler page 还没标 X,EL1 IABORT 直接 oops。

12.3 两个 VBAR handler 互斥

kern_extreme_hwbp.hbp_hook/kern_bp_hook.h 都要 patch vectors+0x400。它们的 EC dispatch 有部分重叠 (EC=0x32 SS 双方都要接)。目前同一时刻只能 activate 一个

未来方向: 合并成统一 dispatcher(kern_vbar_engine.h 已是这个方向的雏形 —— 它把 DABORT 作为一个 slot 化的 handler),EC 按表派发到 HWBP / ihook / DABORT 各自的子 handler。

12.4 B 的 ±128 MB 限制 regression

当前 kernel 6.6.89 vectorsmodule_alloc 出的 vmalloc 区刚好在范围内。如果内核版本升级、或者 KASLR 偏移变大,make_b 会返回 0 → patch_text 失败。fallback 要改成三指令 trampoline,需测。

12.5 emit_mov_imm64 类型 bug regression

任何改动 arm64_reloc.h 都要跑字节级 diff。推荐 regression matrix:

  • target 低 16 位为 0 (h0=0)
  • target 低 32 位为 0 (h0=h1=0)
  • target 低 48 位为 0
  • target 高 16 位为 0
  • 混合: high 全 F, low 全 0 (kernel 地址常见)

任何一种失配就是 hook 装上就崩。

12.6 9+2 thread 调度的刷新延迟

ehwbp_refresh_threads 每 ~1 Hz 跑一次。新起的 pthread 最多 1 秒后才被 watch。如果目标进程在 reload 阶段 burst 创建 100+ 线程, 前 1 秒内命中会漏。对游戏 hot loop 这种稳态不是问题,但对启动期扫描场景要注意。


13. 和其它断点栈的关系

本页路径是”断点命中时不进内核 C 代码”的极致方案。适用于:

  • 需要最低延迟的高频 WP/BP (例如 UE4 game loop 每帧的坐标/弹药写入)
  • 需要在命中处跑任意自定义逻辑的 ihook (改游戏行为、注入反 AC 逻辑、trace EL1 kernel func)

其它路径请看:

  • 任意多个地址的读/写断点,每个断点代价略高但不受硬件槽数限制 → 内核 PTE 断点
  • 通用用户态 SIGTRAP 风格 HWBP,在客户端自己 perf_event_open 控制 → 用户态硬件断点

同一套作弊配置里三者可以共存,只是 extreme HWBP 和 ihook 不能同时 activate,要切换得先 deactivate 再 activate 另一个。


相关文档

  • 源码: /root/shadow/shadow_ce/server/kern_vbar_engine.h (719 行)
  • 源码: /root/shadow/shadow_ce/server/kern_extreme_hwbp.h (1174 行)
  • 源码: /root/shadow/shadow_ce/server/bp_hook/kern_bp_hook.h (836 行)
  • 源码: /root/shadow/shadow_ce/server/bp_hook/arm64_reloc.h (740 行)
  • 源码: /root/shadow/shadow_ce/server/bp_hook/bp_ctl.c (1325 行)
  • 协议: /root/shadow/shadow_ce/server/usr_extreme_hwbp_cmd.h
  • 开发历史: Changeling VBAR handler 通宵开发
  • 开发历史: PIC payload 架构
  • 经验教训: emit_mov_imm64 bug 完整复盘
  • 经验教训: 无 pstore,panic 调试方法
  • 研究: CFI_CLANG 下的 hook 方案对比
Last updated on