Shadow CE
ARM64 Android 内核级内存扫描器 + 调试器,类 Cheat Engine。
零 ptrace、零 /proc/pid/mem、零 process_vm_readv。全部通过内核模块页表 walk 实现。
技术栈总览
┌─ Python Client (PyQt6) ──────────────────────────────────┐
│ shadow_ce.py → main_window.py → memory_view.py │
│ ce_client.py (TCP) → bp_engine.py → hwbp_window.py │
│ ptrscan_dialog.py → mcp_server.py (18 AI tools) │
└──────────────┬────────────────────────────────────────────┘
│ TCP (CE 7.x 协议兼容)
┌──────────────▼────────────────────────────────────────────┐
│ ce_server (C, static linked, aarch64) │
│ CMD 0x00-0xD8: CE协议 + HW断点 + PTE断点 + 指针扫描 │
└──────────────┬────────────────────────────────────────────┘
│ ioctl(/dev/shadow_ce)
┌──────────────▼────────────────────────────────────────────┐
│ shadow_ce.ko (内核模块) │
│ 页表walk + kmap读写 + 内核态扫描 + PTE断点 + 单步模拟 │
│ + ldxr/stxr原子指令模拟 + 指针扫描 (radix sort + BFS) │
│ + 批量链走查 (chain_walk) │
└───────────────────────────────────────────────────────────┘内存读写
原理:不用 ptrace,直接在内核态做页表 walk。
pgd_offset → p4d → pud → pmd → pte_offset_map拿到目标进程的 PTEvm_normal_page验证 PTE 安全性(排除 VM_IO/VM_PFNMAP/special/devmap)kmap_local_page映射物理页到内核虚拟地址copy_to_user / copy_from_user和用户态 buffer 交换数据
支持 huge page (PMD 2MB / PUD 1GB)、每次最大 256KB、跨页自动分段。
写入可执行页时自动 flush_icache_range。
内存扫描
内核态扫描——不用逐字节从用户态读,直接在内核里 kmap 全页比较。
- 首次扫描:遍历 VMA (
VMA_ITERATOR),逐页 walk + kmap + 逐对齐地址比较 - 二次扫描:只检查上次结果地址列表
- 支持 exact / bigger / smaller / between / changed / unchanged / increased / decreased
- Float 近似比较用 ULP 容差(整数位操作,不用 FPU)
- Chunked mmap_read_lock:每 1024 页释放锁 + cond_resched,最大持锁 ~1ms,游戏渲染零感知
指针扫描
内核态 collect-then-BFS——一次 ioctl 完成全流程。
架构
Phase 1: 收集静态区域(file-backed RW VMA)
Phase 2: 收集所有指针对(遍历全 RW 页,提取 8B 对齐值)→ 30M pairs in ~100ms
Phase 3: LSD Radix Sort(8-pass 8-bit,O(n))→ 30M pairs in <1s
Phase 4: BFS 图遍历(binary search sorted pairs)→ 数千条链 in 2-13s对比 PC Cheat Engine
| PC CE (ReadProcessMemory) | Shadow CE (页表 walk + kmap) | |
|---|---|---|
| 收集 30M pairs | 分钟级(逐页 RPM) | ~100ms |
| 排序 | qsort 尚可 | radix sort <1s(原 heapsort 26s) |
| syscall 开销 | 每次 ~1-2µs | 零,全在 ioctl 内核态 |
Chunked Lock
收集阶段每 2048 页释放 mmap_read_lock + cond_resched,最大持锁 ~2ms。游戏渲染线程的 mmap/munmap/brk 操作永不被阻塞超过一帧。
批量链走查 (chain_walk)
SHADOW_CE_CHAIN_WALK ioctl:单次 syscall 走完所有链,返回 (addr, val) per chain。
- 一次
get_task_mm,chunked lock 每 256 条链释放 - 替代旧方案:25K 链 × 6 跳 = 150K 次 ioctl → 1 次 ioctl
Rescan 流程
- 首次扫描 → 数万条链
- 换局 / 改值 rescan → 过滤到数十条
- 重启游戏 rescan → 跨重启存活链(5-10 条)
- Save/Load
.ptr文件(CE 兼容格式)
客户端优化
- PtrScanWorker:独立 socket,不跟主窗口抢锁
- Rescan 后台线程:独立 socket + poll timer,主线程零阻塞
- Value 刷新:后台线程 + batch chain_rescan,只刷可见行
_populatesetUpdatesEnabled:批量插入零重绘
断点系统
Page Fault 断点(推荐,默认)
内核级页表异常断点——通过操控 PTE 权限制造用户态 page fault,在内核 fault handler 中截获。
类似 Windows CE 的 “Page Exceptions”,但 CE 的是用户态实现(VEH handler,极慢)。我们直接 hook do_page_fault,全程内核态,60Hz × 20 线程零压力。
对比 CE Page Exceptions
| CE Page Exceptions | Shadow CE Page Fault | |
|---|---|---|
| 实现层 | 用户态 VEH/SEH handler | 内核态 fault hook |
| 每次 fault 开销 | ~100μs(异常分发链) | ~2μs(直接 hook) |
| CE 官方评价 | ”Extremely slow, buggy” | 60Hz 满速 |
| 最大断点数 | 无限 | 64 slots / 32 pages |
| 原子指令 | 死循环 | 内核模拟 ✓ |
架构(v2 两层设计)
page_trap[32] ← 页级:每个唯一 (mm, page_va) 一个,存 saved_pte + 合并 trap_pte
↑
bp_slot[64] ← 断点级:每个用户断点一个,引用 page_trap
↑
bp_engine.py ← 客户端:单例 poll 线程 + 按 slot_id 分发到 HwbpWindow多个断点在同一页上共享一个 page_trap,compute_union_trap_pte 合并所有 slot 的权限需求。
S1PIE 权限控制
SM8750 支持 FEAT_S1PIE(Permission Indirection Extension),PIE index = [UXN:PXN:DBM:USER]:
| 需要 trap | PTE 操作 | EL0 权限 | 不受影响 |
|---|---|---|---|
| 执行 (X) | +PTE_UXN | R(只读) | 读写正常 |
| 写 (W) | pte_wrprotect | R(只读) | 读执行正常 |
| 读/读写 (R/RW) | 清PTE_USER | X_O 或 NONE | 执行可能正常 |
| 多类型混合 | PTE=0 | 全 trap | fault handler 分类 |
单步窗口 PTE (step_pte)
同页同时有 EXEC + 数据断点时,IABORT 恢复的不是 saved_pte(全权限),而是 compute_step_pte——允许执行但仍 trap 数据访问。防止单步窗口期间数据断点被绕过。
ldxr/stxr 原子指令模拟
ARM64 的 exclusive monitor 在任何异常时被硬件自动清除。单步 ldxr→stxr 序列会导致 stxr 永远失败 → cbnz 无限重试 → spinlock 饿死 → 系统卡死。
解决方案:在 fault handler 中检测 load-exclusive / store-exclusive 指令,直接在内核态模拟:
ldxr Rt, [Rn] → 内核读 [Rn] 写入 regs[Rt],PC += 4
stxr Rs, Rt, [Rn] → 内核写 regs[Rt] 到 [Rn],regs[Rs] = 0(成功),PC += 4零 exclusive monitor 参与,零无限重试。IABORT(PTE=0)和 DABORT(wrprotect)两条路径都覆盖。
Anti-starvation
高频 fault 会饿死 do_ptebp_set 的 ioctl(spinlock 竞争)。设新断点前临时恢复 saved_pte 暂停 fault,设完再 re-arm。
读穿透
PTE 被修改后 shadow_ce 的 read ioctl 可能读不到数据。ptebp_find_trapped_page 用 saved_pte 的物理页做 fallback。
硬件断点 (HWBP, legacy)
用户态 perf_event_open,不需要内核模块。
- 精确到字节,但 ARM64 最多 4 个
- 不支持 ldxr/stxr 模拟——原子操作会死循环
- 保留为 legacy 选项,默认不使用
UI 设计
参考 Cheat Engine 7.x 布局:
- 主窗口:上方扫描结果表(QTableView 虚拟模型,百万行零卡)+ 下方地址列表(batch read 刷新)
- Memory View:ARM64 反汇编(capstone)+ hex dump + 语法着色 + inspector
- 断点捕获窗口:独立 R/W/X/RW 窗口,按 PC 聚合 hit count + 反汇编 + probable pointer + 寄存器快照
- 断点管理器:Ctrl+B,显示所有 kernel slots + capture windows
- 指针扫描:Settings → Results(live Value 刷新)→ Rescan → Save/Load .ptr
- Workspace 面板:最近进程,双击快速 attach
性能优化
- scan results:QTableView + ResultsModel 虚拟模型,
data()O(1),预缓存字符串 - 值刷新(scan results):
CMD_READ_BATCH(一次 TCP 读 N 个地址),后台线程零主线程阻塞 - 地址列表:指针链用
chain_rescan一次 ioctl 批量走完(返回 addr+val),非指针用read_memory_batch,全在后台线程 - 指针扫描 Value 刷新:后台线程 + batch chain_rescan,只刷可见行,独立 socket
- ring buffer:lock-free SPSC(fault handler 写,poll 读),
atomic_fetch_add分配槽位
CE 协议 CMD 编号
| CMD | 编号 | 用途 |
|---|---|---|
| GET_VERSION | 0x00 | 握手 |
| OPEN_PROCESS | 0x03 | 打开进程 |
| READ_MEMORY | 0x09 | 读内存 |
| WRITE_MEMORY | 0x0A | 写内存 |
| VQE_FULL | 0x1F | 枚举 VMA |
| SCAN | 0xC8 | 首次扫描(内核态) |
| RESCAN | 0xC9 | 二次扫描 |
| MODULE_LIST | 0xCA | 批量模块列表 |
| READ_BATCH | 0xCB | 批量读(N 地址 1 次 TCP) |
| HWBP_SET | 0xCC | 设 HW 断点 |
| HWBP_CLEAR | 0xCD | 清断点 |
| HWBP_CLEAR_ALL | 0xCE | 清全部 |
| HWBP_POLL | 0xCF | 读断点事件 |
| PTEBP_SET | 0xD0 | 设 PTE 断点 |
| PTEBP_CLEAR | 0xD1 | 清 PTE 断点 |
| PTEBP_CLEAR_ALL | 0xD2 | 清全部 PTE 断点 |
| PTEBP_POLL | 0xD3 | 读 PTE 断点事件 |
| PTEBP_QUERY | 0xD4 | 查询 PTE 断点 slot |
| PTRSCAN | 0xD6 | 指针扫描(内核 BFS) |
| PTRSCAN_DUMP | 0xD7 | 导出原始指针对 |
| CHAIN_RESCAN | 0xD8 | 批量走指针链(返回 addr+val) |
内核 ioctl 编号
| ioctl | 编号 | 用途 |
|---|---|---|
| SHADOW_CE_READ | 1 | 页表 walk 读内存 |
| SHADOW_CE_WRITE | 2 | 页表 walk 写内存 |
| SHADOW_CE_LISTPROC | 3 | 枚举进程 |
| SHADOW_CE_LISTVMA | 4 | 枚举 VMA |
| SHADOW_CE_SCAN | 5 | 内核态扫描 |
| SHADOW_CE_RESCAN | 6 | 内核态二次扫描 |
| SHADOW_CE_PTEBP_SET | 10 | 设 PTE 断点 |
| SHADOW_CE_PTEBP_CLEAR | 11 | 清 PTE 断点 |
| SHADOW_CE_PTEBP_POLL | 12 | 读 PTE 断点事件 |
| SHADOW_CE_PTEBP_QUERY | 13 | 查询 slot 信息 |
| SHADOW_CE_PTRSCAN | 20 | 指针扫描 (collect + radix sort + BFS) |
| SHADOW_CE_CHAIN_WALK | 21 | 批量链走查 (返回 addr+val) |
文件结构
shadow_ce/
├── client/
│ ├── shadow_ce.py # 入口
│ ├── main_window.py # 主窗口(scan results + address list)
│ ├── connect.py # 连接向导(进程列表 + 实时刷新)
│ ├── ce_client.py # CE 协议 TCP 客户端
│ ├── scanner.py # 扫描引擎
│ ├── ptrscan_dialog.py # 指针扫描(settings + results + rescan + save/load)
│ ├── bp_engine.py # 断点引擎(singleton poll + slot 分发)
│ ├── bp_manager.py # 断点管理器(Ctrl+B)
│ ├── hwbp_window.py # 断点结果窗口(寄存器快照 + probable pointer)
│ ├── memory_view.py # Memory View(disasm + hex + inspector)
│ ├── disasm_view.py # ARM64 反汇编(capstone, QPixmap offscreen)
│ ├── hex_view.py # 十六进制视图(QPixmap offscreen + pre-computed hex)
│ ├── mem_cache.py # 页级内存缓存(4KB, LRU 128 页)
│ ├── add_address_dialog.py # 添加地址对话框(指针链支持)
│ ├── settings.py # 设置对话框
│ ├── style.py # 暗色主题 + CeButton + CeSelectionDelegate
│ ├── fonts.py # 全局字体管理(preset + NoAntialias)
│ ├── dropdown.py # CE-style 下拉按钮
│ ├── i18n.py # 中英文国际化
│ └── mcp_server.py # MCP server(18 个 AI 工具)
├── server/
│ ├── shadow_ce.c # 内核模块(页表walk + 扫描 + PTE断点 + 指针扫描 + 链走查)
│ ├── shadow_ce.h # ioctl 定义
│ ├── server.c # TCP server(CE协议 + 全部 CMD 分发)
│ ├── hwbp.h # 硬件断点(perf_event_open)
│ ├── chain_read.c # 指针链读取调试工具
│ └── Makefile
├── tests/
│ ├── ptebp_v2_samepage.c # 同页 3 类型断点测试
│ ├── ptebp_v2_test.c # 多断点基础测试
│ ├── ptebp_stress.c # 20 线程 EXEC 压力测试(2 页)
│ ├── ptebp_rw_stress.c # 20 线程 RW 压力测试
│ ├── ptebp_w_stress.c # 20 线程 WRITE 压力测试
│ ├── ptebp_r_stress.c # 20 线程 READ 压力测试
│ └── ptebp_mixed_stress.c # 5X+5R+5W+5RW 终极压力测试
├── markdown/ # 指针扫描设计文档 + session 记录
├── README.md # 本文件
└── CLAUDE.md # AI 操作手册当前能力
| 功能 | 状态 | 描述 |
|---|---|---|
| 内存读写 | ✅ 稳定 | 页表 walk + kmap,支持 huge page,256KB/次 |
| 内存扫描 | ✅ 稳定 | 内核态全页比较,exact/range/changed/float,chunked lock |
| 指针扫描 | ✅ 稳定 | 内核 collect + radix sort + BFS,rescan,save/load .ptr |
| 批量链走查 | ✅ 稳定 | 单次 ioctl 走 N 条链,返回 addr+val |
| Page Fault 断点 | ✅ 稳定 | 64 slots,同页多断点,ldxr 模拟,anti-starvation |
| 硬件断点 | ✅ legacy | perf_event_open,精确到字节,4 槽位 |
| Memory View | ✅ 可用 | ARM64 反汇编 + hex dump + 语法着色 |
| 模块解析 | ✅ 可用 | 文件偏移(匹配 IDA),模糊搜索 |
| 中英文 | ✅ 可用 | i18n 热切换 |
| MCP 工具 | ✅ 可用 | 18 个 AI 工具,Claude Code 直接调用 |
压力测试结果
| 测试 | 配置 | 结果 |
|---|---|---|
| ptebp_mixed_stress | 5X+5R+5W+5RW 同页 | 全部 60Hz ✓ |
| ptebp_stress | 20 线程 EXEC × 2 页 | 18 断点全命中 ✓ |
| ptebp_rw_stress | 20 线程 RW 同页 | 满速,原子模拟 ✓ |
| ptebp_w_stress | 20 线程 WRITE 同页 | 满速,stxr DABORT 模拟 ✓ |
| ptebp_r_stress | 20 线程 READ 同页 | 满速,ldxr 模拟 ✓ |
编译
# 内核模块
cd server
export PATH="/path/to/aosp-clang/bin:$PATH"
make
# server
aarch64-linux-gnu-gcc -O2 -static -o server server.c -lpthread
# 客户端
pip install PyQt6 capstone
python client/shadow_ce.py需要的内核符号(kallsyms)
shadow_ce.ko 通过 kprobe 引导拿到 kallsyms_lookup_name,然后解析:
| 符号 | 用途 |
|---|---|
do_page_fault | B-detour hook 目标 |
enable_debug_monitors | 单步 MDE 启用 |
disable_debug_monitors | 单步 MDE 关闭 |
register_user_step_hook | 注册单步回调 |
unregister_user_step_hook | 注销单步回调 |
module_alloc | trampoline 内存分配 |
set_memory_x | trampoline 可执行权限 |
aarch64_insn_patch_text_nosync | 内核 text 段安全写入 |
所有间接调用用 __nocfi wrapper(CONFIG_CFI_CLANG 兼容)。
踩过的坑
- exclusive monitor 死循环 → ldxr/stxr 单步时 monitor 被异常清除 → stxr 永远失败 → 内核模拟为普通 ldr/str
- spinlock 饿死 → 高频 fault 霸占 ptebp_lock → ptebp_set ioctl 超时 → 设断点前临时恢复 PTE 暂停 fault
- DABORT 路径遗漏 → wrprotect 页上 stxr 触发 DABORT 不走模拟 → 扩展到 is_write 路径
- 直接写内核 text → panic → 改用
aarch64_insn_patch_text_nosync - 直接写 MDSCR_EL1 → panic(per-CPU 引用计数不平衡)→ 改用
enable_debug_monitors+ cpuhp - contpte 合并 → PTE 修改不生效 →
contpte_try_unfold先解除再改 - MDSCR.SS per-CPU 泄漏 → context switch 后单步丢失 →
TIF_SINGLESTEP+ 自愈 fault handler - heapsort 26s → 30M pairs 内核 heapsort cache 效率极差 → LSD radix sort <1s
- mmap_read_lock 持锁过长 → 游戏渲染卡帧 → chunked lock 每 1024-2048 页释放
- chain rescan 150K ioctl → 逐跳读取 → 单次
SHADOW_CE_CHAIN_WALKioctl 批量走查 - ptrscan rescan 阻塞 UI → 主线程走 25K 链 → 后台线程 + 独立 socket + poll timer
- lambda 闭包陷阱 → 右键菜单变量被覆盖 → 用默认参数固定值
- ring buffer 竞态 → 多连接 poll 同一 buffer → bp_engine 单例 poll + slot_id 分发