指针扫描
Pointer Scan — 从一个运行时动态地址反向搜索出 “static base + N 层 offsets” 的指针链,使地址在游戏重启后依然可复用。Shadow CE 的实现跑在 kernel 里,single-ioctl 完成 collect → radix sort → BFS 全流程,160MB 内存预算,FPS2(4.6GB 游戏进程)实测 collect 82ms,整次扫描秒级返回。
1. 概念
1.1 为什么需要指针链
游戏里,随便一个 “值” 的地址每次启动几乎都不一样:
- ASLR:模块基址随机
- 堆分配顺序:对象地址每次不同
- session lifetime:死一次 / 换图 / 退到大厅,对象被销毁重建
- GC / UE4 tick:UObject 频繁重用 slot
扫出来的 0x70A1B2C3D0 再打开游戏就失效了。真正稳定的是 结构性路径:
从某个全局变量(.data / .bss,跟 .so 绑定)出发,一层层走指针到叶子字段。
libUE4.so + 0xB034CD8 (GEngine 全局指针)
→ +0x780 (GameViewportClient)
→ +0x78 (GameInstance)
→ +0x38 (LocalPlayers[0])
→ +0x0
→ +0x30 (PlayerController)
→ +0x260 (Character)
→ +0x750 (Actor Weapon)
→ +0x1440 (Ammo int32)“在 libUE4.so 加载点 + 0xB034CD8 处读 8 字节指针,加上 0x780 再读 8 字节,… 最后一次加上 0x1440 读 4 字节” — 这叫一条 pointer chain,只要游戏代码结构不变,跨重启复用。
1.2 Cheat Engine 类比
| 概念 | CE | Shadow CE |
|---|---|---|
| 反向搜索 | Pointer Scan for this address | 右键 → Pointer scan for this address |
| 结果文件 | .ptr + .ptr.results.N | 同,兼容 |
| 静态基址 | module name + file offset | 同 (libUE4.so+0xB034CD8) |
| 层数 | Max level | depth_max (1-16) |
| 每层步长 | Max offset | offset_max (256-65536 字节) |
| 过滤 | Pointer scan → rescan | Rescan 按钮(值过滤) |
1.3 术语
- Level / depth:链的跳数,0 是 target,N 是 static base。
- Offset:每一跳读完指针后加的偏移。
[0]最靠近 base,[depth-1]最靠近 target(CE 约定,写文件时再反转)。 - Base module:链的起点 .so / elf file。必须是 rw- 段(
.data/.bss),不能是 r-x(代码段里bl指令编码容易被误认作指针)。 - Anchor / static:就是 base 区间,扫描时判定 “已命中静态起点”。
2. 方案总览
2.1 演进史(重要设计决策都在这)
Shadow CE 的指针扫描被 重写了 4 次,每次失败的原因都塑造了当前方案。来源:ptrscan-final-plan.md, ptrscan-implementation-plan-v4.md, ptrscan-debug-lessons.md。
| 版本 | 方案 | 结果 | 失败 / 生效原因 |
|---|---|---|---|
| v1-v3 | Server 预收集 + 用户态 BFS | OOM | 4.6GB 游戏 → 48M 指针对 → 160MB+ buffer,pairs_tmp 还多分配 320MB |
| 3-28 Kernel DFS | 内核递归深搜,每层重扫全内存 | 226s/扫 | 每个候选点都触发一次全内存扫,路径上又经过堆临时对象,GC 后断裂 |
| v4 Kernel BFS per-level | 每层 kmap 扫全内存 + binary search sorted level | level-cap 截断深链 | UE4 堆指针密度 100-500/node,32K/level cap 必然截断 GEngine 深链;1M/level 又 OOM |
| 当前版(v4 collect-then-BFS) | 一次 collect 全部 pair → radix sort → BFS 图遍历 | 82ms collect + 秒级 BFS | 图表示一次建成,后续只做 O(log N) binary search,visited hash 防爆炸 |
关键教训(来自 ptrscan-debug-lessons.md):
“UE4 指针链深度 > 10 层,堆引用极度密集(每个 UObject 有 Outer/Class/Package 指针),反向扫描本质上被噪声淹没。但 ptrscan 仍有价值:非 UE4 游戏、初始探索、找静态 base 附近稳定链,CE 就是这么用的。“
2.2 当前 pipeline
总耗时(FPS2 4.6GB 进程):
- Collect: ~82ms – 30M pairs(
3-28-ptrscan-session-full.md) - Radix sort: ~100ms(8-pass LSD,全 integer,cache-friendly)
- BFS: 2-13s(depth、offset、max_results 决定)
- TCP 传链:<10ms (100K chains × ~80B)
2.3 为什么不是…
为什么不 DFS(3-28 方案的教训):DFS 对每个 candidate 都触发一次全内存扫描,UE4 堆上 226 秒。BFS 每层只 bsearch 一次,所有当前层 target 一次性匹配,总复杂度 O(depth × n_pairs × log n_pairs)。
为什么不 per-level scan(v4 per-level 教训):每一层都要 kmap 全部 RW 页(500MB+),depth × 2s 的顺序 IO 很难 < 10s;而且每层加个硬 cap 就会在 dense heap 上随机切断深链。
为什么 collect-then-BFS 可行:collect 是一次性的,radix sort 是 O(n) 常数快,整个图压在 160MB 里。BFS 每个节点一次 upper_bound + 邻居迭代,visited hash set 防止指数爆炸。
3. Collect 阶段(kern_ptrscan.h:269-340)
3.1 要扫哪些 VMA
| 类型 | 收不收? | 原因 |
|---|---|---|
VM_WRITE + VM_READ,非 VM_EXEC | 收 | 所有可能存指针的可写页 |
VM_EXEC(代码段) | 不收 | ARM64 bl 指令 0x94000071xxxxxxxx untag 后落在堆地址范围,会误认作指针 |
VM_IO / VM_PFNMAP | 不收 | 设备内存 / 共享内存,不能 kmap |
无 VM_WRITE | 不收 | 只读,跟扫描无关 |
3.2 什么是 static base(anchor)
Phase 1 单独 walk 所有 VMA 确定哪些是 “static 基址区”:
-
File-backed rw- VMA:
.data/.got等。 -
紧跟其后的 anon VMA:这是
.bss!很多游戏(尤其 UE4 GEngine)的全局对象指针存在.bss,而.bss在/proc/pid/maps里没有 file-backed 关联。源码判定逻辑:if (!vma->vm_file && last_file_rw_end && vma->vm_start == last_file_rw_end && n_statics < PTRSCAN_MAX_STATICS) // 加入 statics[] -
过滤的文件名前缀 / 后缀(避免 JIT 缓存当 anchor):
过滤 为什么 kgsl*,mali*GPU 驱动共享内存 ashmem*,dmabuf*匿名共享 .vdex,.odex,.art,.dex,.oatART / Dalvik 缓存,每次启动都变
最多 PTRSCAN_MAX_STATICS = 512 个区间。
3.3 Pair 收集
对每个 RW VMA,按页 walk:
几个关键点:
- chunked lock:每
PTRSCAN_COLLECT_CHUNK = 2048页放一次mmap_read_lock,避免长时间阻塞目标进程 page fault。 - mask:
req->mask默认0x0000_FFFF_FFFF_FFFF(48-bit VA,砍 MTE / PAC tag)。允许用户覆盖。 - 过滤
val < 0x1000:NULL-ish 和 PAGE 0 页不可能是指针。 signal_pending(current)每页检查:允许客户端 SIGKILL 中断。- collect_cursor:重新 lock 后从上次位置继续,避免 iterator use-after-free(v3 滑动窗口方案因此崩过)。
3.4 Buffer 预算 & 降级
for (max_pairs = 40000000; max_pairs >= 5000000; max_pairs >>= 1) {
pairs = vmalloc(max_pairs * sizeof(*pairs)); // 16B per pair
if (!pairs) continue;
sort_tmp = vmalloc(max_pairs * sizeof(*pairs));
...
}| max_pairs | 内存 | 游戏规模 |
|---|---|---|
| 40M | 640MB × 2 = 1.28GB | 太激进,一般失败 |
| 20M | 320MB × 2 = 640MB | 大多数设备 OK |
| 10M | 160MB × 2 = 320MB | 安全 |
| 5M | 80MB × 2 = 160MB | 最低 fallback |
注意:sort_tmp 不是浪费 —— radix sort 需要 ping-pong buffer,v1-v3 分配过但没用的 pairs_tmp 是真正的 bug。
3.5 DUMP 模式
PTRSCAN_F_DUMP flag:跳过 sort/BFS,把 raw pairs + statics 一次性 copy 出来。用途:
- Server 端或 PC 上做实验性算法(3-28 session 试过 pure-C BFS/DFS/Dijkstra/beam search,都没成功,但 dump 通道本身保留)。
- 诊断 / 导出供离线分析。
协议看 usr_server.c CMD 0xD7:318MB 回传用了 10.7s(3-28-ptrscan-session-full.md)。
4. Search 阶段
4.1 LSD Radix Sort(kern_ptrscan.h:96-133)
for (pass = 0; pass < 8; pass++) {
int shift = pass * 8;
// count[256]; histogram on src[i].target >> shift & 0xFF
// prefix sum; scatter to dst[]
swap(src, dst);
}- 8 passes × 8-bit digits = 处理 64-bit
target - 偶数次 pass → 结果正好落回
a(原 buffer) - 复杂度 O(8N),30M pair 约 100ms,比
sort泛型快数倍 sort_tmp就是这个算法的 pong buffer
4.2 Pair graph 的本质
排序完 pairs[] 是 按 target 升序的邻接表,一条 pair = 有向边:
source(pa+off) ---→ target(*(source)) = V“source 里存了一个指针,指向 target”
反向 BFS 从 target = req->target 出发,找所有入度:谁指向这附近([addr - off, addr + off])?
unsigned int idx = ptrscan_pair_upper(pairs, n_pairs, hi_key);
while (idx > 0 && pairs[idx - 1].target >= lo_key) {
idx--;
unsigned long src = pairs[idx].source;
int diff = (int)(addr - pairs[idx].target); // offset we need to hop
...
}ptrscan_pair_upper 是手写 binary search(returns first i where arr[i].target > key),然后从 idx 往左扫至 pairs[idx-1].target < lo_key 停止。
4.3 BFS 核心(kern_ptrscan.h:372-458)
关键数据结构:
struct ptrscan_bfs_node {
unsigned long addr;
int offset; // offset we added at this hop
unsigned int parent; // index of parent node in queue[]
};
struct ptrscan_visited_entry {
unsigned long addr;
unsigned int count;
};queue[]容量PTRSCAN_BFS_MAX = 4M,节点一经 enqueue 永不移动,parent索引稳定 → 回溯链只需顺着parent向队首走。visited[]是 open-addressing 哈希表,PTRSCAN_VISITED_SIZE = 4M slots,hash(addr >> 3) & mask,线性探测最多 32 位。每条 visited 记count,达到max_per_node (=3)就不再入队,但仍允许短链命中(所以不是硬 dedup)。
4.4 几个小但关键的行为
| 机制 | 代码 | 意图 |
|---|---|---|
| Depth-adaptive offset | eff_off = (cur_depth <= 1) ? offset_max : min(offset_max, 2048U) | 深层 offset 收窄防爆炸。浅层往往落在 TArray / TMap 数组尾端需要大 offset,深层都是 “字段 offset”。 |
src == addr 自环过滤 | if (src == addr) continue; | 同页同 offset 自指向,噪声。 |
allow_neg 开关 | PTRSCAN_F_NEGATIVE = 1 | 默认只允许 diff >= 0(source 在 target 之前)。UE4 里 TArray / TMap 经常跨 offset 负值,开了会找到更多但噪声也大。 |
| depth_marker | 每入队一层后记录当前 tail,head 到达此标记则 cur_depth++ | 纯队列模拟多层 BFS,不用分两个数组。 |
min_depth 过滤 | flags >> 8 编码在 flags 高位 | 深链才 emit chain,忽略短的。UE4 短链经常是堆临时对象。 |
4.5 Chain emit
当 src 落在 statics[] 里(phase 1 收集的区间)且 cur_depth >= min_depth:
hops[0] = diff; // 当前这一跳
qi = head - 1; // 当前节点在 queue 里的索引
while (hop_count < PTRSCAN_MAX_DEPTH && qi > 0) {
hops[hop_count++] = queue[qi].offset;
qi = queue[qi].parent;
}
if (qi == 0) hops[hop_count++] = 0;
c->depth = hop_count - 1;
c->base_addr = src;
for (i = 0; i < c->depth; i++)
c->offsets[i] = hops[i];hops[] 被反向组装 —— [0] 是离 target 最近的 offset,[depth-1] 是离 base 最近。这就是 CE walk order:addr = base; for off in offsets: addr = *addr + off。
4.6 常量参考
| 宏 | 值 | 作用 |
|---|---|---|
PTRSCAN_MAX_DEPTH | 16 | chain 最长 16 跳 |
PTRSCAN_MAX_RESULTS | 100000 | 最多回传 10 万条链 |
PTRSCAN_MAX_STATICS | 512 | anchor 区间数 |
PTRSCAN_BFS_MAX | 4M | BFS queue 节点上限 |
PTRSCAN_VISITED_BITS | 22 | visited hash: 4M slots |
PTRSCAN_COLLECT_CHUNK | 2048 页 | chunked mmap lock 粒度 |
PTRSCAN_CW_CHUNK | 256 | chain walk 每批次走 256 条再放锁 |
CHAIN_WALK_MAX | 200000 | 一次 rescan 最多 20 万链 |
CHAIN_WALK_DEAD | 0xDEADDEAD | 断裂链 sentinel |
默认 mask | 0x0000FFFFFFFFFFFF | 48-bit VA |
默认 offset_max | 8192 字节 | 超过后深层自动收紧到 2048 |
5. Validate 阶段
5.1 为什么要验证
第一次扫完经常有几千甚至几万条链,大量是堆上临时对象(UE4 GC 后断裂)或干扰指针(值恰好等于 target,但语义无关)。需要:
- 多次换局 / 死亡重生后,哪些链仍然指向正确地址?
- 哪些链值恒定为预期(例如 ammo 一定 > 0)?
- 哪些能跨游戏重启活下来?
这一步本质是 CE 里的 “pointer scan → rescan” 流程。
5.2 Chain Walk Protocol (CMD 0xD8)
内核侧实现在 ptrscan_chain_walk()(kern_ptrscan.h:487-582):
- 单次 ioctl 处理到 200K 条链。
- 每
PTRSCAN_CW_CHUNK = 256条放一次 lock,防 page fault blocked。 - 断链(某一跳
mem_read8失败)回传addr=0, val=0xDEADDEAD。 - 批量执行 = 零 TCP round-trip:首轮 2 条链 14ms(vs 每条 ~5ms round trip)。
5.3 Rescan 的 UI 工作流
客户端代码 ptrscan_dialog.py:519-634 _on_rescan:
- Probe 当前 read 连接能不能读
0x1000(活检) - 如果不能 →
_do_reconnect:用adb.exe shell pidof <package>找到新 PID,刷新 module cache,重开 read 连接 - 把所有 chain 的
base_addr先从base_str(libUE4.so+0x2ADCD8)解到当前 runtime(处理 ASLR) - 后台线程发 CMD
0xD8批量走链 - 过滤
val == expected的链(区分broken/wrong value/match计数) - 用结果替换 Results table
5.4 FPS2 实战验证结果(3-28 session)
首次扫描 (FPS2 ammo target):
25088 chains in 25s
换局 rescan (expected=40):
11 chains survive
重启游戏 rescan:
5 条链跨重启存活
都共享尾部 +0x260 +0x10 +0x298 +0x1440
来自未知 static global libUE4.so+0xAF0A7C8 —— ptrscan 自动发现的全新链已验证可复用的几条链(3-28-ptrscan-session-full.md):
| 名称 | depth | Base | Offsets |
|---|---|---|---|
| GameState | 5 | libUE4.so+0xB038CC0 | +0x120 +0x238 +0x0 +0x280 +0x750 +0x1440 |
| GWorld-GI | 6 | libUE4.so+0xB038CC0 | +0x180 +0x38 +0x0 +0x30 +0x260 +0x750 +0x1440 |
| GEngine | 8 | libUE4.so+0xB034CD8 | +0x780 +0x78 +0x38 +0x0 +0x30 +0x260 +0x750 +0x1440 |
| ptrscan 自动发现 | 6 | libUE4.so+0xAF0A7C8 | +0x8 +0x98 +0x260 +0x10 +0x298 +0x1440 |
6. 客户端实现(ptrscan_dialog.py)
6.1 Settings dialog (PtrScanSettingsDialog, 行 57-128)
Pointer Scan Settings
Address: [0x701169BF00 ]
Max depth: [10 ]
Max offset: [8192 ]
Max results: [100000 ]
Min depth: [0 ]
[ ] Allow negative offsets
[Scan] [Cancel]| 字段 | 范围 | 默认 | 说明 |
|---|---|---|---|
| Address | uint64 hex | 当前选中地址 | 要反向追的 target |
| Max depth | 1-16 | 10 | 链最长跳数 |
| Max offset | 256-65536 | 8192 | 单跳最大字节偏移(深层自动收紧到 2048) |
| Max results | 1-100000 | 100000 | 截断结果数 |
| Min depth | 0-16 | 0 | 只留 depth >= N 的链(跳过短浅链) |
| Allow negative offsets | bool | off | PTRSCAN_F_NEGATIVE |
按 Scan → flags 组装成 (min_depth << 8) | (neg & 1) 发出去。
6.2 Results window (PtrScanResultsWindow, 行 131-716)
4 列表格:
| Base Address | Offsets | Value | Depth |
|---|---|---|---|
libUE4.so+0x2ADCD8 | +0x780 +0x78 +0x38 +0x0 ... | 40 | 8 |
核心特性:
- Live Value 刷新:
QTimer 1.5s tick,每次批量chain_rescan只跑可见行(_table.rowAt计算 viewport),_val_refreshing互斥防 overlap。 - Background thread 读:所有 TCP 都在 daemon thread 里,主线程只做 pending-results apply。
- 双击 → 发
chains_added信号 → main_window 收到后AddrEntry(is_pointer=True, base_addr_str=..., pointer_offsets=...)加入 address list。 - 多选 Add to Address List:一次加多条。
- Save / Load .ptr:CE 兼容格式(magic
0xCE, version 2, uncompressed)。Offset 存储时反向(CE 约定),读回时再翻回来。 - Reconnect 按钮:手动触发
adb pidof找新 PID,重建连接(ptrscan 结果窗口要活过游戏重启)。 - Base 解析:
_resolve_base_runtime()把libUE4.so+0x2ADCD8在当前 module cache 里查 segment → 得到 runtime addr。段内foff <= offset < foff + size优先,fallbackfirst_base + offset(PIE 天然对)。
6.3 Worker thread (PtrScanWorker, 行 25-54)
class PtrScanWorker(QThread):
finished = pyqtSignal(object) # (chains, time_ms) or error string
def run(self):
own = CEClient()
own.connect(self._client._host, self._client._port)
own.open_process(self._pid)
chains, time_ms = own.ptrscan(
self._pid, self._target, self._depth,
self._offset_max, self._flags, self._max_chains,
self._min_depth)
own.close()
self.finished.emit((chains, time_ms))- 独立 socket:不抢主 scan 连接的锁。
- pyqtSignal(不是
QTimer.singleShot(0, lambda)):后者从子线程调用会静默失败(教训来自ptrscan-debug-lessons.md)。
6.4 菜单集成
main_window.py 里:
- 右键 address list item → Pointer scan for this address → 预填 address 打开 settings dialog
- Results window 关闭不阻塞 scan,可以同时开多个
7. 内核侧(kern_ptrscan.h)
7.1 ioctl 接口 (shadow_ce.h:172-224)
/* Scan */
#define SHADOW_CE_PTRSCAN _IOWR(CE_IOC_MAGIC, 20, struct ce_ptrscan_req)
#define PTRSCAN_F_NEGATIVE (1 << 0)
#define PTRSCAN_F_DUMP (1 << 1) /* 跳过 BFS,导出 raw pairs */
struct ce_ptrscan_chain {
unsigned int depth;
unsigned long base_addr;
int offsets[PTRSCAN_MAX_DEPTH]; /* CE order */
};
struct ce_ptrscan_req {
int pid;
unsigned long target;
unsigned long mask; /* 0 → 默认 48-bit */
unsigned int depth_max;
unsigned int offset_max;
unsigned int flags; /* PTRSCAN_F_* | (min_depth << 8) */
unsigned int max_results;
unsigned long result_buf; /* 用户态 ce_ptrscan_chain 数组 */
unsigned int result_count; /* out */
unsigned int scan_time_ms; /* out */
};
/* Chain walk / rescan */
#define SHADOW_CE_CHAIN_WALK _IOWR(CE_IOC_MAGIC, 21, struct ce_chain_walk_req)
#define CHAIN_WALK_MAX 200000
#define CHAIN_WALK_DEAD 0xDEADDEAD
struct ce_chain_walk_entry {
unsigned long base_addr;
unsigned int depth;
int offsets[PTRSCAN_MAX_DEPTH];
};
struct ce_chain_walk_result {
unsigned long addr; /* 0 = broken */
unsigned int val; /* val_size bytes at final addr */
};7.2 TCP CMD 对照
| CMD | 来源 | 用途 |
|---|---|---|
0xD6 CMD_PTRSCAN | usr_server.c:890 | ptrscan(正常 BFS 输出链) |
0xD7 CMD_PTRSCAN_DUMP | usr_server.c:938 | 导出 raw pairs(调试 / 离线算法实验) |
0xD8 CMD_CHAIN_RESCAN | usr_server.c:986 | 批量走链,value 刷新、rescan 过滤 |
7.3 内存 layout(执行期峰值)
| buffer | 大小(40M pairs 模式) |
|---|---|
pairs | 640MB |
sort_tmp | 640MB |
statics | 512 × 16B = 8KB |
chains | 100K × 72B = ~7MB |
queue | 4M × 16B = 64MB |
visited | 4M × 12B = 48MB |
| Peak | ~1.4GB(vmalloc) |
vmalloc 失败会自动降级到 20M / 10M / 5M pairs。12GB RAM 手机通常 40M 也能分配。
7.4 并发性 / 可取消
signal_pending(current)在 collect 内循环、BFS 主循环都会检查- 客户端取消 =
close socket→ server 端 send/recv 报错 → 进程退出 → kernel ioctl 被 signal 唤醒 → 干净退出,释放所有 vmalloc - 不支持中途暂停恢复(一次 ioctl 跑完,中断只能重来)
7.5 锁分析
| 阶段 | lock | 持续时间 |
|---|---|---|
| Phase 1 static | mmap_read_lock 一次 | <1ms |
| Phase 2 collect | mmap_read_lock chunked (2048 页一放) | 每 chunk ~几十 ms |
| Phase 3 sort | 无 | ~100ms |
| Phase 4 BFS | 无(纯算法,不访问进程 mm) | 几秒 |
| Chain walk | mmap_read_lock chunked (256 链一放) | 每 chunk 几 ms |
Phase 2 / chain walk 选择chunked read lock 不是独占,允许目标进程并行 page fault。
8. 坑 & 经验
精选来自 ptrscan-debug-lessons.md 和各历史 session 的教训,按杀伤力排序。
8.1 UE4 反向扫描本质困难
来源:
ptrscan-debug-lessons.md§1,ptrscan-final-plan.md§Avoid
libUE4.so 的 rw- 段只有 84KB,GEngine/GWorld 通过 .data → .bss → 堆对象链间接引用。每个 UObject 又都有 Outer/Class/Package 指针,堆里 fan-out 100-500 per node。depth > 10 才能碰到 .data,CE 默认 depth=7 对 UE4 不够。 实际可用链都需要 min_depth >= 5,offset 4-8K。
8.2 代码段指令被误认作指针
来源:
ptrscan-debug-lessons.md§2
ARM64 bl 指令 0x94000071xxxxxxxx untag 后地址范围 0x71xxxxxxxx 正好是堆区。修:static 判定只收 rw- 段(已在 Phase 1 实现)。
8.3 .bss 不是 file-backed
来源:
ptrscan-debug-lessons.md§3
GEngine/GWorld 就住 .bss。/proc/pid/maps 里 .bss 段显示 [anon] 或空。必须用 “anon VMA 紧跟在 file-backed rw- 后面” 的启发式(Phase 1 的 last_file_rw_end 逻辑),否则 static base 全漏。
8.4 untagged_addr 不能硬编码 39-bit
来源:
ptrscan-debug-lessons.md§8
SM8750 可能是 48-bit VA。用 req->mask(默认 0x0000FFFFFFFFFFFF)或直接 untagged_addr() 宏,别写 & 0x7FFFFFFFFF。
8.5 offset 不要 reverse
来源:
ptrscan-debug-lessons.md§9,ptrscan-final-plan.md§Avoid
BFS 输出的 offsets[] 已经是 CE walk order [closest_to_base, ..., closest_to_target](chain emit 的反向 hops 组装决定的)。
- pointer_offsets 不要 reverse
.ptr存档时才反转(CE 文件格式约定)
8.6 _resolve_pointer 必须 addr += offset
来源:
3-28-ptrscan-session-full.mdPhase 1
CE 标准语义:next = *cur + offset,先 deref 再加。老代码写 addr -= offset 跑了很久没人发现,修好后 ammo 链一次通过。
8.7 QTimer.singleShot(0, …) 从子线程会静默失败
来源:
ptrscan-debug-lessons.md§6
Worker thread 里回调主线程必须用 pyqtSignal.emit,QTimer.singleShot 从子线程调用不会触发。
8.8 pairs_tmp 不要预分配但不用
来源:
ptrscan-debug-lessons.md§4
v1-v3 分配了 320MB pairs_tmp 打算给 radix sort 用,但实际走的是 qsort,白占内存直接 OOM。如果你用 radix sort,它就是必须的(ping-pong);如果你用 qsort,别分配。
8.9 VMA iterator 跨 lock cycle use-after-free
来源:
ptrscan-debug-lessons.md§7
滑动窗口 drop mmap_read_lock → copy_to_user → reacquire 之后,VMA_ITERATOR 的 maple tree cursor 指向已释放内存 → kernel crash。修:
- 要么 vmalloc 一整块、一次 lock 全搞定
- 要么 chunked 时记录
collect_cursor地址(不是 iterator state),重新 lock 后用VMA_ITERATOR(vmi, mm, collect_cursor)从该地址开始(当前方案)
8.10 ART / Dalvik 缓存不能当 anchor
来源:
kern_ptrscan.h:238-249
.vdex / .odex / .art / .dex / .oat 每次启动都变,在里面找到的 “static” 链重启失效。kgsl / mali / ashmem / dmabuf 是设备 / 共享内存,也不算 static。Phase 1 硬编码过滤。
8.11 Rescan 前必须先 probe + 可能 reconnect
来源:
ptrscan_dialog.py:546-557
游戏重启 PID 变了,旧 socket / 旧 pid 的 chain_rescan 会返回全 DEAD。修:rescan 按钮先 read_memory(pid, 0x1000, 1) 活检,失败自动 adb pidof <package> 找新 pid 并重建 read 连接。
9. 未来 / 限制
9.1 当前限制
| 限制 | 原因 |
|---|---|
| 不支持中途暂停 | ioctl 是一次性调用,只能 SIGINT cancel 重来 |
PTRSCAN_MAX_DEPTH = 16 硬上限 | offsets[] 是固定长 int 数组;改大浪费内存(100K 条 × 32 offsets × 4B = 12MB) |
| BFS visited 是近似最短路径 | max_per_node=3,深链有时被短链截断(GEngine 链 3-28-ptrscan-session-full.md Phase 4 遇到过) |
| 无增量扫描 | 不能 “加一层再扫”,必须每次重跑 |
| 单 target 一次 | 不支持 batch target 扫描 |
mask 不能自适应 | 必须手动设,MTE 环境下 0 默认可能漏 |
9.2 失败的尝试(留作警示)
从 3-28-ptrscan-session-full.md Phase 5:
- PC 端 318MB raw pair dump + C BFS/DFS:44M 节点,29K 链,找不到 GEngine
- Dijkstra (offset-sum as cost):visited 依然挡路
- Beam search K=50000:UE4 dense heap 上杯水车薪
结论:纯图搜索无法在 UE4 dense heap 上找到特定深链。正确做法是 IDA/SDK dump 先找到候选全局,再 ptrscan 做 rescan filter。
9.3 可能的优化方向
| 方向 | 价值 | 难度 |
|---|---|---|
预过滤 target 邻域:Phase 2 只收 val ∈ [target - K, target + K] 的 pair | 10× 内存节省 | 中(要先跑第一层 BFS 确定范围,不适合当前架构) |
| Dijkstra weighted by segment type:倾向走 rw- 而非 heap | 更稳定跨重启链 | 中 |
| 并行 radix sort:8-thread per digit | 100ms → 20ms | 中,内核态 workqueue |
| Persistent pair buffer:同一进程多次 scan 复用 | 冷启动 100ms 省掉 | 高,要处理内存变动 |
| 正向追踪:从 .data 全局出发正向走对象树 | UE4 可用 | 需要对象布局知识(SDK dump) |
断点辅助:target 设 read bp,从寄存器反推(x20 = 对象基址) | 精确但人工 | 已有 PTE BP 基础设施 |
10. 一次典型扫描全景
把整个流程按时间轴串起来,参考 3-28-ptrscan-session-full.md Phase 4-9 的真实运行。
11. 快速参考
11.1 典型参数组合
| 场景 | depth | offset_max | min_depth | allow_neg | max_results | 说明 |
|---|---|---|---|---|---|---|
| 非 UE4 游戏 小范围探索 | 5-7 | 2048 | 0 | no | 1000 | CE 默认风格 |
| UE4 初次扫描 | 10 | 8192 | 5 | no | 100000 | 砍短链噪声 |
| 深层结构追踪 | 12-15 | 4096 | 7 | no | 100000 | TArray / TMap 嵌套 |
找 -N 偏移(反向链表节点) | 10 | 4096 | 0 | yes | 100000 | 噪声大 |
| Dump 离线分析 | - | - | - | - | - | flags = PTRSCAN_F_DUMP |
11.2 相关源码
| 文件 | 行 | 内容 |
|---|---|---|
shadow_ce/server/kern_ptrscan.h | 全部 584 行 | Kernel collect/sort/BFS/chain_walk |
shadow_ce/server/shadow_ce.h | 172-224 | ioctl 接口 |
shadow_ce/server/usr_server.c | 890-1053 | CMD 0xD6/0xD7/0xD8 wire 处理 |
shadow_ce/client/ptrscan_dialog.py | 716 行全部 | Settings + Results + Worker + Save/Load .ptr + Rescan |
shadow_ce/client/ce_client.py | 383-440 | ptrscan(), chain_rescan() |
11.3 依赖
kern_mem.h:mem_walk_pte(),struct mem_pte_result,mem_read8()(由 memscan 提供,见 内存扫描)kern_modules.h/enum_modules:把 runtime addr 映射回module+file_offset(用于 base_str 展示)
11.4 相关 feature
- 内存扫描 — 提供底层 page/PTE 读取
- UI 设计 — Pointer scan 入口与整体 UI
- 反汇编 / Hexview — 手动验证指针链最终地址