wowdump 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -19
- package/dist/cli.js +5 -3
- package/dist/core/profile-engine.js +112 -3
- package/dist/toolchain.js +72 -97
- package/package.json +2 -2
- package/skills/wowdump/SKILL.md +1 -0
- package/skills/wowdump/references/character-stats-case.md +130 -0
- package/skills/wowdump/references/commands.md +1 -1
- package/skills/wowdump/references/evidence-workflow.md +1 -1
- package/skills/wowdump/references/profiles.md +22 -3
package/README.md
CHANGED
|
@@ -1,41 +1,61 @@
|
|
|
1
1
|
# wowdump
|
|
2
2
|
|
|
3
|
-
WoW 原生内存分析 CLI。用
|
|
3
|
+
Windows 下的 WoW 原生内存分析 CLI。用 CDB 运行时证据和 IDA Pro MCP / iced-x86 分析字段,生成 Reader 可读取的 build profile。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 安装与更新
|
|
6
6
|
|
|
7
7
|
```powershell
|
|
8
8
|
npm install --global wowdump
|
|
9
9
|
wowdump init
|
|
10
|
+
wowdump --version
|
|
10
11
|
```
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
安装会初始化 `~/.wowdump`,把包内完整 Skill 安装到 `~/.agents/skills/wowdump`。升级或 init 会同步 Skill,差异文件先备份到 `~/.wowdump/skill-backups/`;个人新增文件和 config.json 保留。iced-x86 随 npm 安装,CDB 从本机探测,不自动下载或修改环境变量。
|
|
13
14
|
|
|
14
|
-
##
|
|
15
|
+
## 使用流程
|
|
15
16
|
|
|
16
17
|
```powershell
|
|
17
|
-
# 1.
|
|
18
|
+
# 1. 发现目标,取得真实 PID、buildKey、路径与哈希
|
|
18
19
|
wowdump targets
|
|
19
|
-
wowdump database status --build "
|
|
20
|
+
wowdump database status --build "<buildKey>"
|
|
20
21
|
|
|
21
|
-
# 2.
|
|
22
|
-
wowdump analyze runtime --pid
|
|
22
|
+
# 2. 仅在需要新的静态证据时导出相关段
|
|
23
|
+
wowdump analyze runtime --pid <pid> --build "<buildKey>" --kind dump --confirm
|
|
23
24
|
|
|
24
|
-
# 3.
|
|
25
|
-
# 4.
|
|
26
|
-
wowdump analyze
|
|
27
|
-
# 5. 将候选证据交给 verify,再用 Reader 验证
|
|
25
|
+
# 3. Agent 使用 IDA MCP 或 iced-x86 分析,生成候选 profile
|
|
26
|
+
# 4. 验证实际读取;不会修改候选状态
|
|
27
|
+
wowdump analyze runtime --pid <pid> --build "<buildKey>" --kind verify --profile "<candidate.profile.json>" --confirm
|
|
28
28
|
|
|
29
|
-
#
|
|
30
|
-
wowdump memory read --pid
|
|
29
|
+
# 5. 补齐字段和对象身份证据,用户确认后保存至当前 build 的 profile 目录
|
|
30
|
+
wowdump memory read --pid <pid> --build "<buildKey>" --profile "<character-stats.json>" --field critPercent
|
|
31
31
|
|
|
32
|
-
#
|
|
33
|
-
wowdump memory regions --pid
|
|
32
|
+
# 有界页面查询
|
|
33
|
+
wowdump memory regions --pid <pid> --start <startAddress> --end <endAddress> --max-regions 20000
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
尖括号是待替换参数。已有匹配且适用的 profile 时直接 Reader 读取,无需重新 dump。CDB 诊断与断点不是必经步骤,执行前先查 Skill 的退出和超时限制。
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
内存和 CDB 请求复用同一个管理员 broker:首次请求 UAC,空闲 20 分钟退出。所有调试请求需要 `--confirm`。
|
|
39
|
+
|
|
40
|
+
## 数据放在哪里
|
|
41
|
+
|
|
42
|
+
统一使用 `~/.wowdump/<buildKey>/`:
|
|
43
|
+
|
|
44
|
+
- `build.json`:目标身份。
|
|
45
|
+
- `database/`:静态分析数据库位置和状态,不因任务重复创建。
|
|
46
|
+
- `runtime/<session>/`:本轮证据、dump 和候选。
|
|
47
|
+
- `profile/`:用户确认的 Reader 接口。
|
|
48
|
+
|
|
49
|
+
Dump 默认导出 `.text/.rdata/.pdata`,`.data` 按需加入。Reader 默认选择当前 build 的 profile,也支持显式路径;它不读取 IDA 数据库或 dump 二进制。
|
|
50
|
+
|
|
51
|
+
## 0.3.9 的读取与验证
|
|
52
|
+
|
|
53
|
+
- verify 接受 candidate 或 reader_ready;普通 memory read 要求 reader_ready。
|
|
54
|
+
- 输出 `validation.read: "passed"`,替代旧版 `verified:true`;fieldSemantics、rootSemantics、crossProcess 为 not_checked,需要独立证据。命令不自动认证语义、提升状态或保存 profile。
|
|
55
|
+
- 新增 `root.lookup`:按动态键选择有界表项,核对对象键并检查读取期间变化,避免固化本轮哈希槽位。含 lookup 的 profile 需要 0.3.9 或更新版本;旧 Reader 可能忽略未知参数。
|
|
56
|
+
- 校验目标 build 时,缺少 executableSha256 会报错。模块外根 RVA、空根、短读和不一致查表结果均返回结构化错误。
|
|
57
|
+
|
|
58
|
+
详细用法由安装的 Skill 按需加载:[命令](skills/wowdump/references/commands.md)、[Profile 格式](skills/wowdump/references/profiles.md)、[字段挖掘方法与成功案例](skills/wowdump/references/character-stats-case.md)、[CDB 限制](skills/wowdump/references/windbg.md)。
|
|
39
59
|
|
|
40
60
|
## 开发验证
|
|
41
61
|
|
|
@@ -43,5 +63,6 @@ Windows reader 首次使用时由 Node 请求 UAC 启动 broker,后续内存
|
|
|
43
63
|
npm run typecheck
|
|
44
64
|
npm run build
|
|
45
65
|
npm test
|
|
46
|
-
npm
|
|
66
|
+
npm run package:verify
|
|
67
|
+
npm run release:verify
|
|
47
68
|
```
|
package/dist/cli.js
CHANGED
|
@@ -313,7 +313,9 @@ async function validateProfileIdentity(home, buildKey, profile, file) {
|
|
|
313
313
|
: profile.executable && typeof profile.executable === "object" && !Array.isArray(profile.executable)
|
|
314
314
|
? String(profile.executable.sha256 ?? "")
|
|
315
315
|
: "";
|
|
316
|
-
if (profileHash
|
|
316
|
+
if (!profileHash)
|
|
317
|
+
throw new CliError("PROFILE_IDENTITY_REQUIRED", "profile executableSha256 is required", { profileFile: file });
|
|
318
|
+
if (profileHash !== build.executableSha256) {
|
|
317
319
|
throw new CliError("EXECUTABLE_MISMATCH", "profile executable hash does not match the selected build", { expected: build.executableSha256, actual: profileHash, profileFile: file });
|
|
318
320
|
}
|
|
319
321
|
}
|
|
@@ -535,8 +537,8 @@ export function createWowdumpCli(dependencies = {}) {
|
|
|
535
537
|
if (normalized.kind === "verify") {
|
|
536
538
|
if (!profile)
|
|
537
539
|
throw new CliError("PROFILE_REQUIRED", "verify requires --profile <file>");
|
|
538
|
-
const result = await profileEngine.
|
|
539
|
-
writeJson(io, { ...result, command: "analyze.runtime.verify"
|
|
540
|
+
const result = await profileEngine.verify({ pid: normalized.pid, buildKey: normalized.build, executableSha256: buildRecord.executableSha256, profile, fields: undefined }, profileAdapter);
|
|
541
|
+
writeJson(io, { ...result, command: "analyze.runtime.verify" });
|
|
540
542
|
return;
|
|
541
543
|
}
|
|
542
544
|
const requestedSections = Array.isArray(options.sections) && options.sections.length > 0
|
|
@@ -132,10 +132,47 @@ function rootSpec(value, name) {
|
|
|
132
132
|
const root = record(value, `${name}.root`);
|
|
133
133
|
if (root.rva === undefined && root.address === undefined)
|
|
134
134
|
throw new ProfileEngineError("PROFILE_INVALID", `${name}.root requires rva or address`);
|
|
135
|
+
if (root.rva !== undefined && root.address !== undefined)
|
|
136
|
+
throw new ProfileEngineError("PROFILE_INVALID", `${name}.root must use rva or address, not both`);
|
|
135
137
|
return root;
|
|
136
138
|
}
|
|
139
|
+
function validateRootRange(value, module) {
|
|
140
|
+
const root = rootSpec(value, "field");
|
|
141
|
+
if (root.rva !== undefined && module.size !== undefined) {
|
|
142
|
+
const rva = hex(root.rva, "root.rva");
|
|
143
|
+
const span = root.dereference === true ? 8n : 1n;
|
|
144
|
+
if (!Number.isSafeInteger(module.size) || module.size < 1 || rva + span > BigInt(module.size)) {
|
|
145
|
+
throw new ProfileEngineError("RVA_OUT_OF_MODULE", "root RVA falls outside the selected module", { rva: String(root.rva), moduleSize: module.size });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (root.lookup !== undefined) {
|
|
149
|
+
const lookup = record(root.lookup, "root.lookup");
|
|
150
|
+
for (const key of ["countRoot", "keyRoot"]) {
|
|
151
|
+
const child = rootSpec(lookup[key], key);
|
|
152
|
+
if (child.lookup !== undefined)
|
|
153
|
+
throw new ProfileEngineError("PROFILE_INVALID", "nested lookups are not supported");
|
|
154
|
+
validateRootRange(child, module);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
137
158
|
export class ProfileEngine {
|
|
138
159
|
async read(request, adapter) {
|
|
160
|
+
return this.readInternal(request, adapter, false);
|
|
161
|
+
}
|
|
162
|
+
async verify(request, adapter) {
|
|
163
|
+
const result = await this.readInternal(request, adapter, true);
|
|
164
|
+
return {
|
|
165
|
+
...result,
|
|
166
|
+
command: "profile.verify",
|
|
167
|
+
validation: {
|
|
168
|
+
read: "passed",
|
|
169
|
+
fieldSemantics: "not_checked",
|
|
170
|
+
rootSemantics: "not_checked",
|
|
171
|
+
crossProcess: "not_checked"
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async readInternal(request, adapter, allowCandidate) {
|
|
139
176
|
const profile = record(request.profile, "profile");
|
|
140
177
|
const buildKey = String(profile.buildKey ?? "");
|
|
141
178
|
if (!buildKey)
|
|
@@ -148,13 +185,16 @@ export class ProfileEngine {
|
|
|
148
185
|
? profile.executable
|
|
149
186
|
: undefined;
|
|
150
187
|
const profileHash = typeof profile.executableSha256 === "string" ? profile.executableSha256 : String(executable?.sha256 ?? "");
|
|
188
|
+
if (!profileHash)
|
|
189
|
+
throw new ProfileEngineError("PROFILE_IDENTITY_REQUIRED", "profile executableSha256 is required when checking a target build");
|
|
151
190
|
if (profileHash && profileHash !== request.executableSha256) {
|
|
152
191
|
throw new ProfileEngineError("EXECUTABLE_MISMATCH", "profile executable hash does not match the target build", { expected: request.executableSha256, actual: profileHash });
|
|
153
192
|
}
|
|
154
193
|
}
|
|
155
194
|
const status = String(profile.readerStatus ?? profile.status ?? (profile.confidence === "reader_ready" ? "reader_ready" : ""));
|
|
156
|
-
if (status !== "reader_ready")
|
|
157
|
-
throw new ProfileEngineError("PROFILE_NOT_READY", "profile is not reader_ready", { status });
|
|
195
|
+
if (status !== "reader_ready" && !(allowCandidate && status === "candidate")) {
|
|
196
|
+
throw new ProfileEngineError("PROFILE_NOT_READY", allowCandidate ? "verify requires candidate or reader_ready" : "profile is not reader_ready", { status });
|
|
197
|
+
}
|
|
158
198
|
const modules = await adapter.modules(request.pid);
|
|
159
199
|
const moduleSpec = profile.module && typeof profile.module === "object" ? record(profile.module, "profile.module") : {};
|
|
160
200
|
const moduleName = String(moduleSpec.name ?? profile.moduleName ?? "Wow.exe");
|
|
@@ -163,10 +203,14 @@ export class ProfileEngine {
|
|
|
163
203
|
throw new ProfileEngineError("MODULE_NOT_FOUND", `${moduleName} was not found`, { modules: modules.map(item => item.name) });
|
|
164
204
|
const definitions = record(profile.fields ?? profile.readers, "profile.fields");
|
|
165
205
|
const names = request.fields?.length ? [...request.fields] : Object.keys(definitions);
|
|
206
|
+
if (names.length === 0)
|
|
207
|
+
throw new ProfileEngineError("PROFILE_INVALID", "profile must define at least one field");
|
|
166
208
|
const fields = {};
|
|
167
209
|
const reads = [];
|
|
168
210
|
for (const name of names) {
|
|
169
211
|
const definition = record(definitions[name], `field ${name}`);
|
|
212
|
+
if (definition.root !== undefined)
|
|
213
|
+
validateRootRange(definition.root, module);
|
|
170
214
|
fields[name] = await this.readField(request.pid, hex(module.base, "module.base"), name, definition, adapter, reads);
|
|
171
215
|
}
|
|
172
216
|
return { ok: true, command: "profile.read", pid: request.pid, buildKey, profileId: profileId(profile), module, fields, reads };
|
|
@@ -195,9 +239,74 @@ export class ProfileEngine {
|
|
|
195
239
|
if (root.dereference === true) {
|
|
196
240
|
const bytes = await this.readBytes(pid, address, 8, field, adapter, reads);
|
|
197
241
|
address = BigInt(`0x${bytes.toString("hex").match(/../g).reverse().join("")}`);
|
|
242
|
+
if (address === 0n)
|
|
243
|
+
throw new ProfileEngineError("NULL_POINTER", `${field} root is null`);
|
|
198
244
|
}
|
|
245
|
+
if (root.lookup !== undefined)
|
|
246
|
+
return this.resolveLookup(pid, moduleBase, address, root, adapter, field, reads);
|
|
199
247
|
return address;
|
|
200
248
|
}
|
|
249
|
+
async resolveLookup(pid, moduleBase, table, root, adapter, field, reads) {
|
|
250
|
+
const spec = record(root.lookup, `${field}.root.lookup`);
|
|
251
|
+
const countRoot = rootSpec(spec.countRoot, "lookup.countRoot");
|
|
252
|
+
const keyRoot = rootSpec(spec.keyRoot, "lookup.keyRoot");
|
|
253
|
+
if (countRoot.lookup !== undefined || keyRoot.lookup !== undefined)
|
|
254
|
+
throw new ProfileEngineError("PROFILE_INVALID", "nested lookups are not supported");
|
|
255
|
+
const stride = integer(spec.stride, "lookup.stride", 4096);
|
|
256
|
+
const maximum = integer(spec.maxItems, "lookup.maxItems", 65536);
|
|
257
|
+
const keySize = integer(spec.keySize, "lookup.keySize", 64);
|
|
258
|
+
const entryKeyOffset = integer(hex(spec.entryKeyOffset ?? "0x0", "lookup.entryKeyOffset"), "lookup.entryKeyOffset", 4096);
|
|
259
|
+
const valueOffset = integer(hex(spec.valueOffset, "lookup.valueOffset"), "lookup.valueOffset", 4096);
|
|
260
|
+
const keyOffset = hex(spec.keyOffset ?? "0x0", "lookup.keyOffset");
|
|
261
|
+
const objectKeyOffset = hex(spec.objectKeyOffset, "lookup.objectKeyOffset");
|
|
262
|
+
if (!stride || !maximum || !keySize || entryKeyOffset + keySize > stride || valueOffset + 8 > stride || stride * maximum > 1024 * 1024) {
|
|
263
|
+
throw new ProfileEngineError("PROFILE_INVALID", "lookup bounds must fit within the stride and a 1 MiB maximum table");
|
|
264
|
+
}
|
|
265
|
+
const countType = spec.countType ?? "u32";
|
|
266
|
+
if (countType !== "u32" && countType !== "u64")
|
|
267
|
+
throw new ProfileEngineError("PROFILE_INVALID", "lookup.countType must be u32 or u64");
|
|
268
|
+
const countAddress = await this.resolveRoot(pid, moduleBase, countRoot, adapter, field, reads);
|
|
269
|
+
const countBytes = await this.readBytes(pid, countAddress, countType === "u64" ? 8 : 4, field, adapter, reads);
|
|
270
|
+
const countBig = countType === "u64" ? countBytes.readBigUInt64LE() : BigInt(countBytes.readUInt32LE());
|
|
271
|
+
if (countBig > BigInt(maximum))
|
|
272
|
+
throw new ProfileEngineError("LOOKUP_LIMIT", "table count exceeds maxItems", { count: countBig.toString(), maximum });
|
|
273
|
+
const keyAddress = await this.resolveRoot(pid, moduleBase, keyRoot, adapter, field, reads) + keyOffset;
|
|
274
|
+
const key = await this.readBytes(pid, keyAddress, keySize, field, adapter, reads);
|
|
275
|
+
if (key.every(byte => byte === 0))
|
|
276
|
+
throw new ProfileEngineError("LOOKUP_KEY_EMPTY", "lookup key is empty");
|
|
277
|
+
const count = Number(countBig);
|
|
278
|
+
if (!count)
|
|
279
|
+
throw new ProfileEngineError("LOOKUP_NOT_FOUND", "lookup table is empty");
|
|
280
|
+
const bytes = await this.readBytes(pid, table, count * stride, field, adapter, reads);
|
|
281
|
+
let match = -1;
|
|
282
|
+
for (let index = 0; index < count; index++) {
|
|
283
|
+
const offset = index * stride;
|
|
284
|
+
if (!bytes.subarray(offset + entryKeyOffset, offset + entryKeyOffset + keySize).equals(key))
|
|
285
|
+
continue;
|
|
286
|
+
if (match !== -1)
|
|
287
|
+
throw new ProfileEngineError("LOOKUP_AMBIGUOUS", "multiple table entries match the key");
|
|
288
|
+
match = offset;
|
|
289
|
+
}
|
|
290
|
+
if (match === -1)
|
|
291
|
+
throw new ProfileEngineError("LOOKUP_NOT_FOUND", "no table entry matches the key");
|
|
292
|
+
const object = bytes.readBigUInt64LE(match + valueOffset);
|
|
293
|
+
if (object === 0n)
|
|
294
|
+
throw new ProfileEngineError("NULL_POINTER", "matching table entry contains a null pointer");
|
|
295
|
+
const objectKey = await this.readBytes(pid, object + objectKeyOffset, keySize, field, adapter, reads);
|
|
296
|
+
if (!objectKey.equals(key))
|
|
297
|
+
throw new ProfileEngineError("LOOKUP_OBJECT_MISMATCH", "object key does not match the selected table entry");
|
|
298
|
+
const { lookup: _lookup, ...tableRoot } = root;
|
|
299
|
+
const tableAfter = await this.resolveRoot(pid, moduleBase, tableRoot, adapter, field, reads);
|
|
300
|
+
const countAddressAfter = await this.resolveRoot(pid, moduleBase, countRoot, adapter, field, reads);
|
|
301
|
+
const keyAddressAfter = await this.resolveRoot(pid, moduleBase, keyRoot, adapter, field, reads) + keyOffset;
|
|
302
|
+
const countAfter = await this.readBytes(pid, countAddressAfter, countBytes.length, field, adapter, reads);
|
|
303
|
+
const keyAfter = await this.readBytes(pid, keyAddressAfter, keySize, field, adapter, reads);
|
|
304
|
+
const entryAfter = await this.readBytes(pid, table + BigInt(match), stride, field, adapter, reads);
|
|
305
|
+
if (tableAfter !== table || countAddressAfter !== countAddress || keyAddressAfter !== keyAddress || !countAfter.equals(countBytes) || !keyAfter.equals(key) || !entryAfter.subarray(entryKeyOffset, entryKeyOffset + keySize).equals(key) || entryAfter.readBigUInt64LE(valueOffset) !== object) {
|
|
306
|
+
throw new ProfileEngineError("LOOKUP_CHANGED", "lookup identity or table changed during the read; retry with a fresh snapshot");
|
|
307
|
+
}
|
|
308
|
+
return object;
|
|
309
|
+
}
|
|
201
310
|
async readLayout(pid, address, layout, field, adapter, reads) {
|
|
202
311
|
const kind = String(layout.kind ?? "scalar");
|
|
203
312
|
if (kind === "linked_list") {
|
|
@@ -240,7 +349,7 @@ export class ProfileEngine {
|
|
|
240
349
|
const bytesRead = Number(result.bytesRead ?? result.dataHex.length / 2);
|
|
241
350
|
const bytes = Buffer.from(result.dataHex, "hex");
|
|
242
351
|
reads.push({ field, address: hexText(address), size, bytesRead, dataHex: result.dataHex, complete: result.complete === true && bytesRead === size });
|
|
243
|
-
if (bytesRead !== size || bytes.length
|
|
352
|
+
if (result.complete !== true || bytesRead !== size || bytes.length !== size || !/^(?:[0-9a-f]{2})*$/i.test(result.dataHex))
|
|
244
353
|
throw new ProfileEngineError("PARTIAL_READ", `${field} read was incomplete`, { address: hexText(address), size, bytesRead });
|
|
245
354
|
return bytes.subarray(0, size);
|
|
246
355
|
}
|
package/dist/toolchain.js
CHANGED
|
@@ -1,112 +1,87 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname,
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { resolveWowdumpHome } from "./core/build-store.js";
|
|
7
|
-
export const DEFAULT_WOWDUMP_SKILL = `---
|
|
8
|
-
name: wowdump
|
|
9
|
-
description: 用 WinDbg CDB 运行时证据和 IDA Pro MCP 或 iced-x86 定位 WoW 字段,生成并验证 Reader profile。
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
# wowdump
|
|
13
|
-
|
|
14
|
-
先运行 \`wowdump targets\`,再复用 \`~/.wowdump/<buildKey>/database/\`。runtime dump 保存当前 session 的二进制证据,debug 命令保存 CDB 证据,Reader 只读取 build profile。详情见 references/workflow.md、references/commands.md、references/windbg.md、references/disassemble.md 和 references/profiles.md。
|
|
15
|
-
`;
|
|
16
|
-
export const DEFAULT_WOWDUMP_COMMANDS = `# wowdump 命令参考
|
|
17
|
-
|
|
18
|
-
1. \`wowdump targets\`
|
|
19
|
-
2. \`wowdump database status --build <buildKey>\`
|
|
20
|
-
3. \`wowdump analyze runtime --pid <pid> --build <buildKey> --kind dump --confirm\`
|
|
21
|
-
4. \`wowdump analyze debug --pid <pid> --build <buildKey> --rva <rva> --kind breakpoint --confirm\`
|
|
22
|
-
5. \`wowdump analyze runtime --pid <pid> --build <buildKey> --kind verify --profile <profile.json> --confirm\`
|
|
23
|
-
6. \`wowdump memory read --pid <pid> --build <buildKey> --profile <profile.json> --field <name>\`
|
|
24
|
-
|
|
25
|
-
\`dump\` 默认流式导出供 IDA/iced-x86 分析的 PE 段并生成 manifest;\`verify\` 只验证已有候选 RVA。未确认的候选 profile 放在 runtime session 目录;确认后才复制到当前 build 的 profile 目录。
|
|
26
|
-
`;
|
|
27
7
|
function normalizePath(value) { return resolve(value.trim().replace(/^"|"$/g, "")); }
|
|
28
|
-
function directoryExists(value) { try {
|
|
29
|
-
return statSync(value).isDirectory();
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return false;
|
|
33
|
-
} }
|
|
34
|
-
function fileExists(value) { try {
|
|
35
|
-
return statSync(value).isFile();
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return false;
|
|
39
|
-
} }
|
|
40
|
-
async function createFileIfMissing(file, contents, created, preserved) { try {
|
|
41
|
-
await writeFile(file, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
42
|
-
created.push(file);
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
if (error.code === "EEXIST")
|
|
46
|
-
preserved.push(file);
|
|
47
|
-
else
|
|
48
|
-
throw error;
|
|
49
|
-
} }
|
|
50
8
|
export { resolveWowdumpHome, sha256File } from "./core/build-store.js";
|
|
51
|
-
export function resolveWowdumpSkillHome(env = process.env, userHome = homedir()) {
|
|
9
|
+
export function resolveWowdumpSkillHome(env = process.env, userHome = homedir()) {
|
|
10
|
+
return normalizePath(env.WOWDUMP_SKILL_HOME || join(userHome, ".agents", "skills"));
|
|
11
|
+
}
|
|
52
12
|
export async function initializeWowdumpHome(options = {}) {
|
|
53
13
|
const env = options.env ?? process.env;
|
|
54
14
|
const home = normalizePath(options.home ?? resolveWowdumpHome(env, options.userHome));
|
|
55
|
-
const created = [];
|
|
56
|
-
const preserved = [];
|
|
57
|
-
await mkdir(home, { recursive: true });
|
|
58
|
-
const configFile = join(home, "config.json");
|
|
59
|
-
await createFileIfMissing(configFile, `${JSON.stringify({ schema: "wowdump.config.v1" }, null, 2)}\n`, created, preserved);
|
|
60
15
|
const skillDirectory = join(normalizePath(options.skillHome ?? resolveWowdumpSkillHome(env, options.userHome)), "wowdump");
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
16
|
+
const bundled = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", "wowdump");
|
|
17
|
+
const result = {
|
|
18
|
+
home, created: [], preserved: [], updated: [], backups: [],
|
|
19
|
+
configFile: join(home, "config.json"), skillFile: join(skillDirectory, "SKILL.md"),
|
|
20
|
+
commandsFile: join(skillDirectory, "references", "commands.md")
|
|
21
|
+
};
|
|
22
|
+
// Read the complete source before making changes; an incomplete package must not install a fallback skill.
|
|
23
|
+
const files = new Map();
|
|
24
|
+
async function collect(directory, prefix = "") {
|
|
25
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
26
|
+
const name = join(prefix, entry.name);
|
|
27
|
+
if (entry.isDirectory())
|
|
28
|
+
await collect(join(directory, entry.name), name);
|
|
29
|
+
else if (entry.isFile())
|
|
30
|
+
files.set(name, await readFile(join(directory, entry.name)));
|
|
31
|
+
}
|
|
66
32
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
33
|
+
await collect(bundled);
|
|
34
|
+
if (!files.has("SKILL.md") || !files.has(join("references", "commands.md")))
|
|
35
|
+
throw new Error("SKILL_BUNDLE_INVALID: required skill files are missing");
|
|
36
|
+
if (options.skillSource)
|
|
37
|
+
files.set("SKILL.md", await readFile(normalizePath(options.skillSource)));
|
|
38
|
+
if (options.commandsSource)
|
|
39
|
+
files.set(join("references", "commands.md"), await readFile(normalizePath(options.commandsSource)));
|
|
40
|
+
await mkdir(home, { recursive: true });
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(result.configFile, JSON.stringify({ schema: "wowdump.config.v1" }, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
43
|
+
result.created.push(result.configFile);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code !== "EEXIST")
|
|
47
|
+
throw error;
|
|
48
|
+
result.preserved.push(result.configFile);
|
|
77
49
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
50
|
+
const backupDirectory = join(home, "skill-backups", randomUUID());
|
|
51
|
+
for (const [name, contents] of files) {
|
|
52
|
+
const destination = join(skillDirectory, name);
|
|
53
|
+
let existing;
|
|
54
|
+
try {
|
|
55
|
+
existing = await readFile(destination);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code !== "ENOENT")
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
if (existing?.equals(contents)) {
|
|
62
|
+
result.preserved.push(destination);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (existing) {
|
|
66
|
+
const backup = join(backupDirectory, name);
|
|
67
|
+
await mkdir(dirname(backup), { recursive: true });
|
|
68
|
+
await writeFile(backup, existing, { flag: "wx", mode: 0o600 });
|
|
69
|
+
result.backups.push(backup);
|
|
70
|
+
}
|
|
71
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
72
|
+
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
73
|
+
await writeFile(temporary, contents, { flag: "wx", mode: 0o600 });
|
|
74
|
+
await rename(temporary, destination);
|
|
75
|
+
(existing ? result.updated : result.created).push(destination);
|
|
91
76
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
export async function bootstrapToolchain(options = {}) {
|
|
80
|
+
return { ...await initializeWowdumpHome(options), toolchain: { ready: true, disassembler: "iced-x86" }, installed: [] };
|
|
81
|
+
}
|
|
82
|
+
export function resolveToolchain(options = {}) {
|
|
83
|
+
return { home: normalizePath(options.home ?? resolveWowdumpHome(options.env ?? process.env, options.userHome)), ready: true, disassembler: "iced-x86" };
|
|
98
84
|
}
|
|
99
|
-
export async function bootstrapToolchain(options = {}) { const home = await initializeWowdumpHome(options); return { ...home, toolchain: { ready: true, disassembler: "iced-x86" }, installed: [] }; }
|
|
100
|
-
export function resolveToolchain(options = {}) { return { home: normalizePath(options.home ?? resolveWowdumpHome(options.env ?? process.env, options.userHome)), ready: true, disassembler: "iced-x86" }; }
|
|
101
|
-
export function inspectPeSections(buffer) { if (buffer.length < 0x40 || buffer.toString("ascii", 0, 2) !== "MZ")
|
|
102
|
-
return []; const pe = buffer.readUInt32LE(0x3c); if (pe + 24 > buffer.length || buffer.toString("ascii", pe, pe + 4) !== "PE\0\0")
|
|
103
|
-
return []; const count = buffer.readUInt16LE(pe + 6); const optional = buffer.readUInt16LE(pe + 20); const table = pe + 24 + optional; const out = []; for (let i = 0; i < count; i++) {
|
|
104
|
-
const off = table + i * 40;
|
|
105
|
-
if (off + 40 > buffer.length)
|
|
106
|
-
break;
|
|
107
|
-
out.push({ name: buffer.subarray(off, off + 8).toString("ascii").replace(/\0+$/, ""), virtualSize: `0x${buffer.readUInt32LE(off + 8).toString(16)}`, rva: `0x${buffer.readUInt32LE(off + 12).toString(16)}`, rawSize: `0x${buffer.readUInt32LE(off + 16).toString(16)}`, rawOffset: `0x${buffer.readUInt32LE(off + 20).toString(16)}` });
|
|
108
|
-
} return out; }
|
|
109
85
|
const invokedFile = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
|
|
110
|
-
if (invokedFile === import.meta.url && process.argv.includes("--postinstall"))
|
|
86
|
+
if (invokedFile === import.meta.url && process.argv.includes("--postinstall"))
|
|
111
87
|
await bootstrapToolchain();
|
|
112
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wowdump",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "WoW native memory analysis CLI with WinDbg CDB evidence, IDA/iced-x86 disassembly, and an elevated reader broker",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,6 +44,6 @@
|
|
|
44
44
|
},
|
|
45
45
|
"allowScripts": {
|
|
46
46
|
"koffi@2.16.3": true,
|
|
47
|
-
"wowdump@0.3.
|
|
47
|
+
"wowdump@0.3.9": true
|
|
48
48
|
}
|
|
49
49
|
}
|
package/skills/wowdump/SKILL.md
CHANGED
|
@@ -17,6 +17,7 @@ description: 使用 wowdump CLI 定位和读取 WoW 原生内存字段,结合
|
|
|
17
17
|
- [命令参数](references/commands.md):CLI 用法。
|
|
18
18
|
- [字段定义](references/request-schema.md):目标含义不明确时。
|
|
19
19
|
- [CDB 使用与退出限制](references/windbg.md):执行 dump 或 debug 前。
|
|
20
|
+
- [字段挖掘方法与角色属性案例](references/character-stats-case.md):未知字段、根指针不稳定或静态分析卡住时参考;学习证据链,不照抄地址。
|
|
20
21
|
|
|
21
22
|
目录统一为 `~/.wowdump/<buildKey>/`:`database/` 保存静态数据库,`runtime/<session>/` 保存本轮证据与候选,`profile/` 保存已确认的持久化接口。Reader 读取 profile。broker 首次 UAC 后复用,空闲 20 分钟退出。
|
|
22
23
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# 字段挖掘参考:从角色属性案例提炼方法
|
|
2
|
+
|
|
3
|
+
查询暴击、急速、精通等角色属性时参考。这里记录一次真实调查的方法与反例,不把旧 build 的偏移当成新任务的答案。用户要求全新分析时重新建立证据。
|
|
4
|
+
|
|
5
|
+
## 这次真正奏效的思路
|
|
6
|
+
|
|
7
|
+
**从取值代码确认字段,从对象身份确认根,最后用 Reader 验证整条路径。** 找到一个数值或一条当前能读的指针链,只完成了其中一部分。
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
目标字段、类型和单位
|
|
11
|
+
→ API 名称 / 注册关系 / getter 的真实取值指令
|
|
12
|
+
→ 对象相对字段偏移 + 转换公式
|
|
13
|
+
→ 可读页内的对象候选 + 多字段独立对照
|
|
14
|
+
→ 对象根的引用、初始化、更新与失效路径
|
|
15
|
+
→ 稳定身份来源 + 固定指针链或动态容器选择规则
|
|
16
|
+
→ candidate profile → 实时 verify → 用户已授权的正式 profile
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
这不是要求所有任务扫描整个流程。已有匹配 build/hash 且适用的正式 profile 时直接读取;已有哪一层证据,就从缺失的一层继续。具体命令见 [commands.md](commands.md),静态工具选择见 [disassemble.md](disassemble.md)。
|
|
20
|
+
|
|
21
|
+
### 给下一次调查的操作要点
|
|
22
|
+
|
|
23
|
+
1. **字段语义先收敛。** “暴击等级”和“暴击百分比”分开;记录 getter 是直接读取还是有公式转换。没有证明的职业修正系数不擅自补上。
|
|
24
|
+
2. **静态分析提供结构,不代替实时值。** 使用原始 PE 和同 build/hash 的运行时段;优先 IDA MCP,缺少时用 iced-x86 做局部分析。按原 RVA 映射,结合 `.rdata` 名称、类型和 `.pdata` 边界,而不是只看少量 `.text` 字节。函数边界是线索,重叠代码仍需按控制流确认。
|
|
25
|
+
3. **工具的空结果也要验证。** 用一条已知 RIP 相对指令检查解码器输出,再信任批量检索。先证明脚本能找到已知样本,别把接口用错造成的零结果解释成程序没有结构。
|
|
26
|
+
4. **按真实路径解码。** 遇到重叠字节、异常指令密集区,回到有证据的入口和跳转目标。只有证明条件互补且中间不改变相关标志,才把两条同目标条件跳转折叠。中间值不是返回值;指针计算失败时保留失败假设,不凭低位相似宣布成功。
|
|
27
|
+
5. **把值与对象身份绑在一起。** 页可读、数值合理、几个字段相等,支持对象候选;还要找 GUID 或其他稳定身份来源,区分自己、目标、附近同类型对象和旧缓存。CDB 与 Reader 一致证明读取实现一致,角色面板或独立状态变化才补充字段语义证据。
|
|
28
|
+
6. **查根的生命周期,而不是挑最短链。** 查看初始化、查找、插入、删除、扩容和清理路径。遇到容器时,确认容量、步长、键、值及空/删除槽含义;固定结构偏移可以存,哈希槽位、列表序号和本轮对象地址不应伪装成固定结构。
|
|
29
|
+
7. **优先找稳定身份路径。** getter 的快速缓存路径难还原时,继续看缓存未命中分支:它可能显式取得身份并查对象。本次正是从回退分支找到玩家 GUID 来源,避开了未完整还原的缓存指针算法;这是一种可选策略,不代表所有 getter 都有这样的分支。
|
|
30
|
+
8. **验证完整的读取规则。** 用实际 profile 读取,而不只运行临时脚本;保留原始字节、解析值、命令和时间。查表要有上限、唯一命中、对象身份复核和变化检测。普通 Reader 对落盘文件再读一次,确认正式接口确实可用。
|
|
31
|
+
|
|
32
|
+
### 卡住时如何选下一步
|
|
33
|
+
|
|
34
|
+
| 现象 | 下一项有效动作 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| API 字符串存在但没有直接 XREF | 检查注册表、间接引用、地址映射及检索器已知样本;名字地址不当函数入口。 |
|
|
37
|
+
| 线性反汇编出现大量异常指令 | 回到调用点/分支目标,分析相关标志位,再解码可达块;不扩大同一错误起点的输出。 |
|
|
38
|
+
| 当前读数正确,隔一会变成零或异常值 | 核对对象身份、根槽和容器变化;不把错误结果解释成属性真的为零。 |
|
|
39
|
+
| 多条链都能到同一个对象 | 查它们所属结构和生命周期;短链、重复命中次数都不等于当前玩家语义。 |
|
|
40
|
+
| 固定槽位失效 | 找键及键的动态来源,按容器规则查找;不只换成新的槽位偏移。 |
|
|
41
|
+
| getter 的缓存解码复杂 | 查缓存未命中/重建路径及其返回的标识,再寻找可读的对象索引。 |
|
|
42
|
+
| verify 成功但含义仍不明 | 分开报告读取、字段语义、根语义和跨进程验证;不自动提升状态。 |
|
|
43
|
+
| 容器中找不到玩家 | 返回未找到;检查索引是否覆盖该状态,必要时继续找更完整索引,不选第一项兜底。 |
|
|
44
|
+
|
|
45
|
+
每轮调查保留“当前假设 → 使用证据 → 结果 → 下一项能区分假设的检查”。失败后改假设或检查工具,不重复同一无效扫描;需要换装、换角色或重启才能区分时,具体说明要用户配合什么,不把缺少这项验证说成永远找不到。
|
|
46
|
+
|
|
47
|
+
### 什么进 CLI,什么留在任务里
|
|
48
|
+
|
|
49
|
+
- CLI 负责通用读取、页查询、有界遍历、类型解析及结构化错误;本次新增的是通用 `root.lookup`,不是写死某个游戏表。
|
|
50
|
+
- 本 build 的 RVA、字段偏移、键布局和证据进 profile;临时检索脚本、dump、失败假设进 runtime session。
|
|
51
|
+
- Skill 负责何时选哪条分析路径及如何判断证据。禁止 Hook/Stalker 的现有约束保持不变;本次成功不依赖它们。
|
|
52
|
+
- 只有发现通用工具缺陷或缺少通用读取表达能力才改框架,不为每次查询改源码。新增能力需测试失败路径,并确认实际使用的 CLI 支持它。
|
|
53
|
+
|
|
54
|
+
下面保留本次具体过程,便于对照上述判断方法。历史读数和地址仅适用于标注的 build 与证据范围。
|
|
55
|
+
|
|
56
|
+
## 先找字段含义,再找对象
|
|
57
|
+
|
|
58
|
+
API 名称所在的只读数据不是 getter 函数。需要沿注册关系、代码引用和真实控制流,确定取值指令、对象来源与字段类型。getter 的函数 RVA 与存放对象指针的全局槽位 RVA 也不是同一个地址;Reader 读取后者,不执行 getter。
|
|
59
|
+
|
|
60
|
+
线性反汇编遇到重叠指令或混淆分支时,按有证据的跳转目标重新解码。不要把错误路径的算式当成指针算法。本次一条重建算法生成了无效高位、看似正确的低 32 位;低位只能缩小候选范围,页检查和多字段匹配才支持进一步调查。
|
|
61
|
+
|
|
62
|
+
本次 build `retail@12.1.0.69587` 的静态/运行时证据支持以下对象相对位置:
|
|
63
|
+
|
|
64
|
+
| 字段 | 对象偏移 | 类型及解释 |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| 智力 | 0x1f70 | u32,属性点 |
|
|
67
|
+
| 耐力 | 0x1f6c | u32,属性点 |
|
|
68
|
+
| 生命值 | 0x19b8 | u64 |
|
|
69
|
+
| 暴击 | 0x723c | f32,百分比 |
|
|
70
|
+
| 急速倍率 | 0x1ccc | f32;getter 转换为 `100 × (1 / value − 1)` |
|
|
71
|
+
| 精通原始值 | 0x7254 | f32,不等于职业专属精通效果百分比 |
|
|
72
|
+
|
|
73
|
+
这些不是模块 RVA。全能候选尚未完成语义验证,所以没有混入已核对的字段。
|
|
74
|
+
|
|
75
|
+
## 有界读取与独立对照
|
|
76
|
+
|
|
77
|
+
用 VirtualQueryEx 结果限定可读区间,每次读取截断到当前 region 末尾;短读保留错误,不用零填补。多个候选要对照一组字段,而不是只匹配一个常见数值。角色切换可能改变数值,也可能更换对象,需记录观测时刻。
|
|
78
|
+
|
|
79
|
+
本次 Reader 与一次 CDB 非侵入读取分别得到:智力 2669、耐力 31204、生命值 624080、暴击约 17.391304%、急速倍率约 0.818332613(换算约 22.199700%)、精通原始值约 31.326086。这是历史观测,不是下一次查询的预期常量。
|
|
80
|
+
|
|
81
|
+
CDB 使用方式和退出限制见 [windbg.md](windbg.md)。本次 `-pd` 曾出现退出码为零但命令未执行;改用 `-pvr` 并以 `qd` 结束后才取得实际读数。必须核对输出标记、读数、超时和目标状态,单凭 exitCode:0 不足以认定成功。此案例没有 Hook,也没有调用未知原生函数。
|
|
82
|
+
|
|
83
|
+
## 从对象反查根:一次命中不代表稳定
|
|
84
|
+
|
|
85
|
+
在有界模块数据和候选指针范围内查找引用,保存每个候选槽位及间接偏移。读取较多小块时复用 broker 连接,完成后关闭;本次逐请求建连接曾耗尽临时端口,改为单连接解决。
|
|
86
|
+
|
|
87
|
+
本次发现的链采用记号 `*[地址]` 表示读取 8 字节指针:
|
|
88
|
+
|
|
89
|
+
| 候选对象链 | 后续实际复核 |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `*[*[base + 0x61f0110] + 0x28]` | 最初命中玩家,后续指向其他地址;智力、耐力、暴击变成零。排除为已验证玩家根。 |
|
|
92
|
+
| `*[*[base + 0x6288450] + 0x3b8]` | 后续读到零和异常浮点数,排除为已验证玩家根。 |
|
|
93
|
+
| `*[*[base + 0x59c5ec8] + 0x1c8]` | 当时仍匹配六项读数;后续证实是容器槽位,正式方案已改为按 GUID 查表。 |
|
|
94
|
+
| `*[*[base + 0x6202890] + 0x980]` | 后续仍匹配上述六项读数;根语义和跨重启表现待确认。 |
|
|
95
|
+
|
|
96
|
+
最短链失败时,CLI 仍返回 `verified:true`,因为内存读取完整。这是**读取成功不等于对象正确**的实测反例。不要为了落盘而挑一条偶然匹配的链。
|
|
97
|
+
|
|
98
|
+
## 如何收敛成 profile
|
|
99
|
+
|
|
100
|
+
继续分析候选根的读写引用、所属对象和列表选择条件,确认它为何指向当前玩家。把得到的模块 RVA、解引用步骤与字段偏移写入候选,使用同一份 profile 反复读取并对照;允许用户配合换装、换角色或重启验证,但分别记录覆盖范围。
|
|
101
|
+
|
|
102
|
+
按 [profiles.md](profiles.md) 保存:身份必须匹配,根语义成立,字段经过验证,用户已授权。跨重启尚未验证时明确注明;根语义未成立则保留候选,不放进持久化 profile 目录。固定槽位方案后来被排除,最终采用下节的按玩家 GUID 选对象方案。
|
|
103
|
+
|
|
104
|
+
## 最终确认:玩家 GUID + 动态查表
|
|
105
|
+
|
|
106
|
+
后续修正临时检索脚本:iced-x86 的接口是 isIpRelMemoryOperand / ipRelMemoryAddress,先前使用不存在的属性导致空结果;旧 root-static-xrefs.json 因此作废。正确检索找到根的读写引用。
|
|
107
|
+
|
|
108
|
+
初始化函数 RVA 0x258570、查找函数 0x25e57b0 与类型字符串证明 0x59c5ec8 是 GUID → AIObstacle 表,容量在 0x59c5ec0,步长 0x28,键位于条目 +0,关联对象指针位于 +0x10。旧 +0x1c8 是槽位 11 的值,不是结构常量;表扩容或条目位置变化后不应继续使用。
|
|
109
|
+
|
|
110
|
+
玩家 getter 的缓存回退路径:0x251dc76 调用 0x251e020,正常分支 0x251e373 跳到 0x1f9dfb0;后者正常分支 0x1f9e1a2 返回 `*[base + 0x7dec228] + 0x10`。此处是当前玩家 16 字节 GUID 的来源,而不是猜测选中目标的全局变量。当前进程四处关键代码字节与静态段相同;该 GUID 和已核对属性对象的 +0x10 GUID 完全一致。
|
|
111
|
+
|
|
112
|
+
正式 profile 使用 root.lookup:动态读取玩家 GUID,在容量上限内寻找唯一相同键,读取对象指针,再比较对象 GUID;最后复读表根、容量、键和命中条目。找不到玩家或发生变化时返回错误,不退回旧槽位。AIObstacle 表并非已证明覆盖所有游戏状态的完整对象索引,玩家条目缺失应明确报告 LOOKUP_NOT_FOUND。
|
|
113
|
+
|
|
114
|
+
三轮真实 verify 与落盘后的普通 memory read 均成功,且属性随游戏状态变化。最终文件位于 `~/.wowdump/retail@12.1.0.69587/profile/character-stats.json`,只存模块 RVA 和相对布局。未测试真实重启,因此 restartVerified 保持 false;不要把合成重定位测试描述为真实跨进程测试。
|
|
115
|
+
|
|
116
|
+
## 本次本机证据索引
|
|
117
|
+
|
|
118
|
+
以下文件位于 `~/.wowdump/retail@12.1.0.69587/runtime/character-stats-11628/`,不是 npm 包内容。其他机器缺少这些文件时,不将案例当作可执行 profile。
|
|
119
|
+
|
|
120
|
+
- `candidate-comparison.json`:多候选、面板字段对照。
|
|
121
|
+
- `cdb-current-stats-pvr.json`、`cdb-stats-summary.json`:独立读取结果及限制。
|
|
122
|
+
- `character-stats-profile-verification.json`:会话绝对地址 profile 的 Reader 验证。
|
|
123
|
+
- `root-indirect-scan.json`:模块根候选与间接引用。
|
|
124
|
+
- `root-static-xrefs.json`:已作废的错误检索结果;使用 root-xrefs-corrected.json 和 root-functions-corrected.txt。
|
|
125
|
+
- `candidate.profile.json`、`rva-candidate-verify.json`:最短 RVA 候选及其失败的语义复核。
|
|
126
|
+
- `other-roots-verify.json`:其余三条链的 CLI 参数、原始输出和退出码。
|
|
127
|
+
- `root-59c5ec8.candidate.profile.json`、`local-cli-candidate-verify.json`:修订后的本地 CLI 直接验证 candidate,仍得到六项匹配读数;validation 区分读取与未检查项目,候选状态不变。
|
|
128
|
+
- `player-guid-source-verify.json`:玩家 GUID 与对象 GUID 的实时对照。
|
|
129
|
+
- `current-code-check.json`:当前进程关键代码与静态段的字节比对。
|
|
130
|
+
- `guid-lookup-live-verify.json`、`formal-profile-read.json`:按键查表的三轮 verify,以及正式 profile 的普通 Reader 读取结果。
|
|
@@ -54,7 +54,7 @@ wowdump database status --build "<buildKey>"
|
|
|
54
54
|
wowdump analyze runtime --pid <pid> --build "<buildKey>" --kind verify --profile <profile.json> --confirm
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
`verify`
|
|
57
|
+
`verify` 接受 candidate 和 reader_ready,执行 profile 的 RVA、指针链、lookup 和类型读取,不读取 IDA 数据库或 dump 文件,也不修改候选状态。validation.read 为 passed 仅表示读取成功;字段语义、根语义和跨进程复用不由该命令认证。补齐相关证据并得到用户确认后,才保存到 `~/.wowdump/<buildKey>/profile/`。
|
|
58
58
|
|
|
59
59
|
## Reader
|
|
60
60
|
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
不要求用户每次都重新配合已有验证;新字段首次建立语义时才补相应证据。输出 evidence 文件包含字段名、来源文件/函数 RVA、假设、原始读数、观测时间、对照值、转换公式及验证状态。多个字段逐个记录,局部成功不代表全部成功。
|
|
10
10
|
|
|
11
|
-
`analyze runtime --kind verify`
|
|
11
|
+
`analyze runtime --kind verify` 返回分项 validation:read 为 passed 表示读取成功,fieldSemantics、rootSemantics、crossProcess 为 not_checked。它不做角色面板对照,不自动证明语义或重启复用,也不修改候选状态。旧版本的 verified:true 同样只代表读取调用成功。候选验证见 [profiles.md](profiles.md)。
|
|
12
12
|
|
|
13
13
|
dump 的日志、manifest 和段字节属于取证材料;检查每段大小、哈希和 shortRead 后再引用。分析输出中的 RVA、结构偏移、类型和调用关系必须能追溯到这些材料或原始 PE。
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
|
|
35
35
|
替换为本轮证实的身份、RVA、偏移和类型。直接模块标量无需解引用。数组使用 layout.kind:"array"、item、stride、count;动态计数格式先核对当前 Reader 并设置 maxItems。结构使用 layout.kind:"struct" 和 fields。复杂布局先核对当前实现或验证用例,不自创 schema。
|
|
36
36
|
|
|
37
|
-
##
|
|
37
|
+
## 验证候选
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
`analyze runtime --kind verify --profile <file> --confirm` 接受 readerStatus 为 candidate 或 reader_ready;普通 memory read 仍要求 reader_ready。
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
直接验证 candidate.profile.json,结果另存 verify.json。命令不修改候选、不提升状态、不自动持久化。validation.read 为 passed 只表示此次读取完成;fieldSemantics、rootSemantics、crossProcess 为 not_checked,需另外补证据。不要为执行验证而先将候选改成 reader_ready。
|
|
42
42
|
|
|
43
43
|
语义验证见 [evidence-workflow.md](evidence-workflow.md)。验证失败时保留错误和候选状态,不复制到持久化目录。
|
|
44
44
|
|
|
@@ -52,3 +52,22 @@ CLI 的 verify 调用同一个 ProfileEngine,要求 readerStatus:"reader_ready
|
|
|
52
52
|
buildKey 使用 targets 返回的安全单层目录名。profile 必须带真实 executableSha256;build/hash 不匹配重新分析,缺少哈希不视作匹配。
|
|
53
53
|
|
|
54
54
|
展示字段、单位、验证范围和绝对路径。用户确认后复制到 profile/<id>.json;已有保存授权直接沿用。覆盖现有文件前确认覆盖范围。未做重启测试时保留限制。profile 不保存 moduleBase、堆绝对地址或 IDA session ID。
|
|
55
|
+
|
|
56
|
+
### 持久化前检查根指针
|
|
57
|
+
|
|
58
|
+
- 持久化字段从当前 build 的模块 RVA 出发,随后使用有证据支持的相对偏移和解引用;根 RVA 必须落在对应模块范围内。堆地址减 moduleBase 不会变成模块 RVA。
|
|
59
|
+
- 指针扫描命中只说明某个槽位当时引用该对象。先确认槽位的含义、所属结构和生命周期,排除队列、缓存、目标单位或任意列表项。重复读取与当前对象对照;指针改变后仍可读不代表仍是玩家。
|
|
60
|
+
- 单独记录根语义、字段语义、同会话重读、换角色和跨进程验证。允许明确披露尚未进行的重启测试;根语义尚未确认的扫描命中继续留在 runtime,不凭 reader_ready 或 verified:true 提升为持久化接口。
|
|
61
|
+
- 绝对地址 profile 只适合本轮调查。证据里的历史地址可留在 runtime 文件,持久化 profile 引用该证据路径,不携带其本轮对象地址作为读取根。
|
|
62
|
+
|
|
63
|
+
具体反例见 [角色属性案例](character-stats-case.md)。这些是保存决策规则,不表示当前 CLI 已自动执行全部检查。
|
|
64
|
+
|
|
65
|
+
## 按键选择动态表项:root.lookup
|
|
66
|
+
|
|
67
|
+
根处是表而不是唯一对象时,使用有界键查找,不把本轮槽位编号写进 pointerChain。root.rva/dereference 先解析表地址,lookup 返回匹配表项中的对象指针,随后执行字段的 pointerChain。
|
|
68
|
+
|
|
69
|
+
lookup 参数:countRoot 和 keyRoot 是普通根定义(rva 或会话 address,可选 dereference),不嵌套 lookup;countType 为 u32(默认)或 u64;keyOffset 是键相对 keyRoot 的偏移;keySize 是比较的原始字节数;stride 是表项步长;entryKeyOffset 是表项内的键偏移(默认 0);valueOffset 是对象指针偏移;objectKeyOffset 用于核对对象本身的键;maxItems 必须显式提供。偏移使用 0x 字符串,大小和数量使用整数。
|
|
70
|
+
|
|
71
|
+
单次表读取上限 1 MiB,keySize 上限 64,stride 上限 4096,maxItems 上限 65536;实际容量超限返回 LOOKUP_LIMIT。键为空、没有命中、重复命中、对象键不符分别返回 LOOKUP_KEY_EMPTY、LOOKUP_NOT_FOUND、LOOKUP_AMBIGUOUS、LOOKUP_OBJECT_MISMATCH。选择后复读根、容量、键和命中条目;发现变化返回 LOOKUP_CHANGED,不沿用旧槽位。
|
|
72
|
+
|
|
73
|
+
这些复核不是进程冻结或原子快照保证;目标持续变化时保留失败证据,有限重试。lookup profile 必须使用 0.3.9 或更新版本 Reader;旧版可能忽略未知参数,不要交给旧 Reader 执行。
|