极致优化硬件断点
Scope: 这一页只讲 Extreme 分支的 VBAR takeover + ihook 方案。
- 断点走
do_page_fault/ PTE AF / kpatch fault hook 的那一套 → 去 内核 PTE 断点- 通用用户态
perf_event_openHWBP → 去 用户态硬件断点本页核心: 自己接管 VBAR_EL1,自己写异常向量页,ihook 任意 EL1 kernel function,用 PIC payload 跑自定义逻辑。
1. 定位
极致 HWBP 的设计目标只有一条: 命中时不进内核 C 代码。
走的是硬件机制:
- 配好
DBGBCR/DBGWCR,让 EL0 执行时自然进异常 - 异常进来
VBAR_EL1 + 0x400→ 我们自己的向量页 → 保存寄存器 → ring buffer →ERET - 内核
entry.S的kernel_entry/el0_sync_handler/do_debug_exception/ signal delivery 全部跳过
源文件:
| 文件 | 职责 |
|---|---|
kern_vbar_engine.h | 通用 VBAR+0x400 handler 搭建框架, EC dispatch, 页表/模块化 slot |
kern_extreme_hwbp.h | EC=0x30/0x34/0x32 (BP/WP/SS) 自建 handler,ring buffer,9+2 thread 软件多路复用 |
bp_hook/kern_bp_hook.h | EC=0x20/0x21 (IABORT) → ihook → PIC payload |
bp_hook/arm64_reloc.h | Dobby 风格静态重定位 (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。布局相关偏移:
| 偏移 | 含义 |
|---|---|
+0x000 | Current EL with SP0 |
+0x200 | Current EL with SPx |
+0x400 | Lower EL (EL0) AArch64 Sync ← 我们改的就是这里 |
+0x480 | Lower EL IRQ |
| … | … |
正常内核里, vectors+0x400 的第一条指令是 B el0_sync。我们把它原子替换成 B our_handler_page,保存原字节以便卸载时还原。
2.2 patch 三件套
kern_vbar_engine.h 和 kern_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(通过 kln 在 kern_kpatch.h 里 resolve):
kallsyms_lookup_name→ 拿vectors符号 VAmodule_alloc(size)→ 分配内核模块空间(RWX 前身,vmalloc区域)set_memory_x(addr, npages)→ 把页标 Xpatch_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 ; 非我们的 SSwp_ss_path 里做完整 33-reg save,再恢复 dbgwcr0_el1、清 MDSCR.SS,ERET 回用户态(回到 STR 的下一条,但此时 AC 测延迟已经读完了)。
3.4 Ring buffer 结构 (kern_extreme_hwbp.h:51-113)
| 资源 | 值 |
|---|---|
EHWBP_TOTAL_PAGES | 290 (1 代码+数据, 289 ring) |
EHWBP_RING_SIZE | 4096 (power-of-2) |
EHWBP_RING_ENTRY_SIZE | 288 B (272 payload 对齐到 32) |
| 每 entry | u32 pid; u32 tid; u64 regs[33] (x0-x30, sp, pc) |
| Ring base | handler_page + 0x1000,缓存在 DATA_BASE+0x78 |
ring_idx | DATA_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:
- 临时把 PTE 改回 executable (local
tlbi vale1只本 CPU 看到放行) - 开 single-step 位让 CPU 跨一条
- ERET → CPU 跑一条 → step exception 回来
- Step handler 把 PTE 改回
UXN=1, broadcasttlbi 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_path 到 eret 约 13 条指令,~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 字节:
| 偏移 | 字段 |
|---|---|
| +0 | hook_va (0=空槽) |
| +8 | payload_va |
| +16 | page_va (hook_va & ~0xFFF,用于快速页匹配) |
| +24 | asid (进程匹配,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_page 用 tlbi 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_guardTLS)- 禁用:
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) == 0x14000000 | LDR 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) == 0x10000000 | MOVZ Xd,h3 LSL 48 + MOVK h2/h1/h0 |
LDR (literal) 32/64 | 0x18.../0x58... | LDR X16, [PC,#data]; LDR Rt, [X16] |
LDR (literal) SIMD | 0x1C/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 X16 到 code_off + 16。结果:
- buffer 里的字节排布被错位
- 后续
BR覆盖到本该是 MOVK 的位置 —— 但那条 MOVK 其实没被 emit - 或者更糟:
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 变化:
- reloc 单元测试必须覆盖 h0/h1/h2/h3 各自为 0 的排列组合
- 每次动
arm64_reloc.h后用hook_666最小 demo 跑一遍 - 字节级 diff: payload source → reloc output → target VMA bytes →
/proc/pid/maps读取的 bytes,四处对比
9. bp_hook.ko 独立模块
9.1 为什么独立成 ko
- VBAR 冲突隔离:
shadow_ce.ko的 VBAR 主要处 DABORT / WP,而bp_hook.ko处 IABORT。两个 handler 各自 patchvectors+0x400的 B 跳转 —— 不能同时在线,必须互斥 activate。独立成 ko 做到运行时按需加载。 - 可选性: 只用 HWBP 的用户不需要 ihook 的 code path,减少 attack surface。
- 调试周期: 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”。流程:
- 先在
kernel_platform/common/out/里完整编译一遍内核 out/Module.symvers里包含所有 EXPORT_SYMBOL_GPL 的哈希- 编译 bp_hook.ko 时 kbuild 自动 include,保证
call_module_alloc等绑定正确版本
9.4 CFI_CLANG 兼容
SM8750 内核 CONFIG_CFI_CLANG=y。extreme 栈的 CFI 免疫路线:
- VBAR handler 是 raw asm bytes,不经任何 indirect call → CFI 校验表用不到
call_module_alloc / call_set_memory_x / call_patch_text_nosync这些 wrapper 通过klnresolve 出函数指针,不是 C 层 direct call。shadow_ce 对这些 helper 用__nocfi属性跳过 hash checkregister_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> | 卸载单个 |
status | dump 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 $BUF10. CMD_EXTREME_HWBP_* 协议
shadow_ce 服务器端 (usr_extreme_hwbp_cmd.h) 通过 TCP 暴露给客户端,路由到 shadow_ce.ko ioctl。
10.1 命令表
| CMD | Opcode | 方向 | Payload |
|---|---|---|---|
EHWBP_ACTIVATE | 0xE0 | C→S | (无) |
EHWBP_DEACTIVATE | 0xE1 | C→S | (无) |
EHWBP_SET | 0xE2 | C→S | u32 pid; u64 addr; u32 bp_type; u32 bp_size |
EHWBP_CLEAR | 0xE3 | C→S | i32 slot_id |
EHWBP_CLEAR_ALL | 0xE4 | C→S | (无) |
EHWBP_POLL | 0xE5 | C→S | u32 max_events |
EPTEBP_ACTIVATE | 0xE6 | C→S | (无) (走 PTE 栈,本页不覆盖) |
EPTEBP_DEACTIVATE | 0xE7 | C→S | (无) |
10.2 Response 格式
SET→i32 result; i32 slot_id; i32 thread_countCLEAR→i32 resultPOLL→u32 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_count10.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 → vfreering buffer 在 activate 期间保持,stop/start 循环不会丢失 handler page。只有 DEACTIVATE 才真正把 VBAR 改回原样。
11. 实测性能 (本条路径自身)
Changeling / gamma session 实测数据 (SM8750 @ 19.2 MHz cntvct_el0, 52.08 ns/tick):
| 场景 | handler 路径 | 指令数 | ticks | ns |
|---|---|---|---|---|
| WP ULTRA-FAST (关 WCR → SS 延迟写 ring) | wp_path 15 insn | 15 | ~12 | ~625 |
| BP from EL0 | bp_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
- 单次
STPpair +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。流程保证:
- handler page 先
set_memory_x+ icache flush - 再改
vectors+0x400 - IPI 所有 CPU 重刷 icache
不要颠倒这个顺序 —— 否则有 CPU fetch 到新 B,但 handler page 还没标 X,EL1 IABORT 直接 oops。
12.3 两个 VBAR handler 互斥
kern_extreme_hwbp.h 和 bp_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 vectors 和 module_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 方案对比