diff --git a/boards/android/AI-USAGE.md b/boards/android/AI-USAGE.md index b7a5dd9..1d1162b 100644 --- a/boards/android/AI-USAGE.md +++ b/boards/android/AI-USAGE.md @@ -8,15 +8,18 @@ - jadx: `tools/android/jadx/` - uber-apk-signer: `tools/android/uber-apk-signer/uber-apk-signer.jar` - frida-server: 通过 MCP `android_frida_ensure_server` 部署 -- frida-tools: 桌面端通过 `pip install frida-tools` 安装 +- frida-tools: 桌面端通过 `pip install frida-tools` 安装,版本须与目标端 frida-server 兼容 +- Ghidra + 兼容 Java: Native SO 静态分析首选,需可运行 `analyzeHeadless` +- IDA Pro / Hex-Rays: 可选的人工复核工具;不属于本项目 MCP 或 CI 依赖 ## 分析流程 1. 先用 apktool 解包 APK 2. 用 jadx 打开 DEX 反编译 3. 关注 AndroidManifest.xml、入口 Activity、native 库 -4. Frida 动态 hook 时脚本保存到 `scripts/android/` -5. 重打包产物放入 `patches/android/apk-builds/` +4. Native SO 优先用 Ghidra headless 分析,再用 Frida 运行时证据验证;IDA Pro 仅作为可选人工复核 +5. Frida 动态 hook 时脚本保存到 `scripts/android/` +6. 重打包产物放入 `patches/android/apk-builds/` ## MCP 工具链(AI 可自动调用) @@ -100,4 +103,4 @@ ### 知识库 -APK 逆向知识库位于 `kb/apk-reverse/`,8 个分类 17 篇技术文件(每篇含可运行 Frida 代码)。详见 `kb/apk-reverse/README.md`。 +APK 逆向知识库位于 `kb/apk-reverse/`,8 个分类 20 篇技术文件。先调用 `kb_router(board="apk-reverse")` 按信号定位文章,再使用对应 MCP 工具,并将运行时证据保存到 `exports/android/`、分析笔记保存到 `notes/android/`。详见 `kb/apk-reverse/README.md`。 diff --git a/boards/android/README.md b/boards/android/README.md index d7a4c8a..8aace88 100644 --- a/boards/android/README.md +++ b/boards/android/README.md @@ -15,7 +15,7 @@ Android APK 分析有一整套 MCP 自动化工具链,涵盖 ADB 连接、包 ## 知识库 -APK 逆向知识库 `kb/apk-reverse/`:17 篇 Frida 可运行技术文件,8 个分类覆盖 DEX/Java、Native、Manifest、Crypto、Network、Dynamic、Packer、Patch/Repack。 +APK 逆向知识库 `kb/apk-reverse/`:20 篇可运行技术文件,8 个分类覆盖 DEX/Java、Native、Manifest、Crypto、Network、Dynamic、Packer、Patch/Repack。 ## 分析流程 diff --git a/docs/llms-full.txt b/docs/llms-full.txt index b64f224..eaab2c6 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1,7 +1,7 @@ # ReverseLab — Full Knowledge Base Dump for LLM Ingestion Total articles: 178 -Generated: 2026-07-04 +Generated: 2026-07-31 --- ## [ctf-website] 24h Loop Orchestration @@ -34989,66 +34989,82 @@ strings exports/android/app-apktool/lib/arm64-v8a/*.so | rg -i "RegisterNatives| 如果导出表只有 `JNI_OnLoad`,说明很可能使用动态注册;下一步 hook `RegisterNatives`。 -## 3. RegisterNatives Frida 打点 +## 3. MCP 模板:加载器与 RegisterNatives 证据 -```javascript -function readJniString(ptrValue) { - return ptrValue.isNull() ? "" : ptrValue.readCString(); -} +优先使用 MCP 模板,而不是复制临时脚本。先检查可用模板: -const reg = Module.findExportByName("libart.so", "_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi") - || Module.findExportByName("libart.so", "RegisterNatives"); +```text +android_frida_template_library +android_frida_render_template(template_id="native_module_load_hook", substitutions_json='{"library_name":"libguard.so"}') +android_frida_render_template(template_id="native_register_natives") +``` -Interceptor.attach(reg, { - onEnter(args) { - const env = args[0]; - const clazz = args[1]; - const methods = args[2]; - const count = args[3].toInt32(); - console.log("[jni] RegisterNatives count=" + count + " methods=" + methods); - for (let i = 0; i < count; i++) { - const item = methods.add(i * Process.pointerSize * 3); - const name = readJniString(item.readPointer()); - const sig = readJniString(item.add(Process.pointerSize).readPointer()); - const fn = item.add(Process.pointerSize * 2).readPointer(); - const mod = Process.findModuleByAddress(fn); - console.log("[jni] " + name + sig + " -> " + fn + " " + (mod ? mod.name : "unknown")); - } - } -}); +`native_module_load_hook` 先检查目标 SO 是否已加载;未加载时仅观察 `android_dlopen_ext` / `dlopen`,在加载返回后记录路径、模块基址和大小。它不会 hook `.init_array`、构造函数或修改目标行为。 + +`native_register_natives` 对每次注册最多解析 64 项,输出 declaring Java class、方法名、JNI 签名、native 运行时地址、模块路径、模块基址、架构和 RVA;每一项的字符串/指针读取失败会单独标为 unresolved,字符串读取上限为 256 bytes,不会中止整次观察。 + +```text +# 对已运行的受控 app:将两个已渲染模板合并后交给 android_frida_run_script, +# 但 attach 只能观察 attach 之后的新注册。 +# 要捕获 JNI_OnLoad 的启动期注册:先 android_force_stop,再使用 +# android_frida_run_script(target=, mode="spawn");该模式会先加载脚本、 +# 再 resume 进程。仅在受控环境显式选择 spawn,避免静默改变 app 生命周期。 +# crypto/unpack 场景:android_crypto_unpack_recipe 已包含 native_dlopen、 +# native_memory_map 与 native_register_natives,适合先取得基础证据。 ``` -成功标志:输出 `nativeName(signature) -> address module`,地址可直接丢给 Ghidra/x64dbg 风格的函数定位。 +成功标志不是单一 `dlopen` 句柄,而是可审计的映射(Java class 必须包含在身份中,避免不同 class 的同名方法混淆): -## 4. JNI_OnLoad 调用链 +```text +Java class + method name + JNI signature + -> module path + runtime base + -> native runtime VA + derived RVA +``` -```powershell -# Ghidra headless 后搜索 JNI_OnLoad、RegisterNatives xref -python scripts/misc/ai_tool.py run ghidra_headless_analyze -- samples/app.apk --out exports/android/ghidra +## 4. Ghidra 静态关联与 ASLR/PIE 归一化 + +对动态观察到的实际 `.so` 做 Ghidra 分析,而不是只分析 APK 容器: + +```text +1. ghidra_headless_analyze:导入观察到的 SO,生成 summary。 +2. ghidra_summary_functions:查 JNI_OnLoad、RegisterNatives、Java_ 或观察到的方法名。 +3. ghidra_summary_strings:查 JNI 签名、方法名、库标识。 +4. ghidra_summary_function_detail:读取候选函数的 signature、callers/callees、imports、strings、decompile。 +5. ghidra_summary_call_focus:以 JNI/native/crypto 等关键词排序后续函数。 ``` -Ghidra 中按这个顺序命名: +运行时地址受 PIE/ASLR 影响,不能直接当作 Ghidra 静态地址。关联前先确认 runtime VA 位于记录模块的映射范围: + +```text +module base <= runtime native VA < module base + module size +RVA = runtime native VA - runtime module base +Ghidra candidate = Ghidra image base + RVA +``` + +RVA 只可用于同一二进制:记录并核对 SO 的完整路径、架构、SHA256 或 build ID,再把该文件导入 Ghidra。模块路径相同不代表内容相同;hash/build ID、架构或地址范围不一致时,保留 unresolved,不要进行地址关联。 + +Ghidra 中按这个顺序建立候选命名: ```text JNI_OnLoad - -> register_native_methods - -> native_sign - -> native_encrypt_packet - -> native_check_license + -> candidate_register_native_methods + -> candidate_native_sign + -> candidate_native_encrypt_packet ``` 对每个 native 函数记录: ```text -Java method: -JNI signature: -SO: -VA/RVA: -Inputs from Java: -Return to Java: -Next hop: +Java class / method name / JNI signature: +SO path / SHA256 or build ID / architecture: +Runtime module base / module size / runtime VA / derived RVA: +Ghidra function entry / current name / signature: +Callers, callees, imports, strings, decompile evidence: +Confidence / unresolved assumptions: ``` +地址、模块或函数不能匹配时保留 unresolved 状态;不要把运行时 VA 与静态 Ghidra 地址直接等同,也不要把候选名称写成已确认符号。 + ## 5. 参数/返回值打点 ```javascript @@ -35078,7 +35094,7 @@ Java.perform(function () { }); ``` -## 6. 路径分叉 +## 6. 分析流程与路径分叉 | 发现 | 下一跳 | |---|---| @@ -35093,7 +35109,7 @@ Java.perform(function () { | 项 | 记录内容 | |---|---| | Java 入口 | 类名、方法名、JNI 签名 | -| Native 映射 | SO、VA/RVA、RegisterNatives 输出 | +| Native 映射 | Java class、方法名、JNI 签名、SO path、架构、SHA256/build ID、module base/size、VA/RVA、RegisterNatives 输出 | | 参数 | Java 入参长度、hex 摘要、返回值类型 | | Ghidra | 函数名、调用者、被调用者、关键字符串 | | 下一跳 | crypto、network、license、patch 或 packer | @@ -35104,7 +35120,7 @@ Java.perform(function () { |---|---|---| | JNI/crypto 模板 | `android_crypto_unpack_recipe` | 生成 RegisterNatives 和 crypto hook | | SO 静态分析 | `ghidra_headless_analyze` | 函数边界、xref、伪代码 | -| 动态执行 | `android_frida_run_script` | spawn 早期捕获注册表 | +| 动态执行 | `android_frida_run_script` | attach 观察后续注册;受控环境显式 `spawn` 捕获启动期注册 | | 知识路由 | `kb_router` | 按 JNI/native/crypto 信号查文档 | @@ -37752,6 +37768,24 @@ while (item) { } ``` +## 证据账本与置信度 + +在写出 `struct` 前,为每个字段维护一条证据账本。Ghidra 的反编译与 xref 是静态证据;受控运行时的 Frida/x64dbg/ReClass 观察才是动态佐证。不要因单次内存快照就把语义命名为 confirmed。 + +| Offset | Width / Read-Write | Candidate type / meaning | Function / Address | Static evidence | Dynamic evidence | Confidence | +|---:|---|---|---|---|---|---| +| `+0x190` | `8 / read` | `USceneComponent* RootComponent` | `FUN_... @ RVA ...` | `mov rax, [rcx+190h]`; 后续解引用 | 指针落在已知 heap object | Inferred | +| `+0x268` | `4 / write` | `int health` | `FUN_... @ RVA ...` | `mov [rcx+268h], eax`; 调用点与伤害流程相连 | 受控测试时值递减 | Confirmed | +| `+0x378` | `4 / read` | `float moveSpeed` | `FUN_... @ RVA ...` | `movss`; 与移动分支关联 | 尚未观察 | Inferred | + +证据级别: + +- **Observed**:原始反汇编、Ghidra 伪代码、xref 或运行时日志直接显示的事实。 +- **Inferred**:由偏移、访问宽度、调用上下文或值域推导出的类型/语义;必须保留推导依据。 +- **Confirmed**:至少有相互独立的静态证据,并在安全、授权的测试中获得动态一致性验证。 + +记录时始终同时保留样本 SHA256、模块 image base、RVA/VA/文件偏移换算、函数地址和工具版本,避免把 ASLR 下的运行时地址误写成静态偏移。 + ## Frida 辅助验证 ```javascript @@ -38338,6 +38372,18 @@ ReClass hex dump: → 类型: float[16] ``` +## 运行时验证前的证据账本 + +在 ReClass 中创建字段前,先使用[内存结构体逆向重建](01-struct-reconstruction.md)的字段证据账本。每条候选字段至少保留:偏移、宽度、读/写方向、候选类型/语义、Ghidra 函数/RVA、静态证据、动态观察和置信度。 + +```text +Observed — Ghidra/xref/断点/内存快照直接显示的事实 +Inferred — 由访问模式或值域推导的字段含义 +Confirmed — 独立静态证据与受控运行时变化一致 +``` + +ReClass 负责可视化和动态佐证,不会自动证明字段语义。把单次值匹配保留为 Inferred;只有在可重复的状态变化、访问代码和对象边界共同吻合时才升为 Confirmed。 + ## 导出为 C++ ```cpp diff --git a/docs/llms.txt b/docs/llms.txt index 5386a3b..53ce3ac 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -383,5 +383,5 @@ > --- -Generated: 2026-07-04 +Generated: 2026-07-31 Total articles: 178 \ No newline at end of file diff --git a/docs/upstreams/reverse-skills.md b/docs/upstreams/reverse-skills.md new file mode 100644 index 0000000..05d49cf --- /dev/null +++ b/docs/upstreams/reverse-skills.md @@ -0,0 +1,36 @@ +# P4nda0s/reverse-skills provenance and local mapping + +## Review record + +- Upstream: +- Reviewed revision: `a2baa31c58a3567977188414da68c8c842057152` +- Review date: 2026-07-31 +- Relevant upstream paths reviewed: + - `skills/rev-frida/SKILL.md` + - `skills/rev-symbol/SKILL.md` + - `skills/rev-struct/SKILL.md` + +The upstream README states MIT, but the reviewed repository tree does not contain a committed `LICENSE` file. Under this repository's public migration boundary (`PUBLICATION.md`), the upstream is therefore treated as **reference-only**. This project does not vendor its prompt text, source snippets, templates, binaries, submodules, or package dependencies. The implementation below is independently written and uses existing ReverseLab interfaces. + +## Capability mapping + +| Upstream workflow idea | Open-ReverseLab implementation | Primary execution/evidence surface | +|---|---|---| +| `rev-frida` loader-aware instrumentation | Android native Frida templates and JNI tracing guidance | `android_frida_template_library`, `android_frida_render_template`, `android_frida_run_script`, `android_crypto_unpack_recipe` | +| `rev-symbol` function-name inference | Ghidra summary evidence plus conservative Function Map suggestions | `ghidra_headless_analyze`, `ghidra_summary_functions`, `ghidra_summary_function_detail`, `ghidra_summary_call_focus`, generated analysis notes | +| `rev-struct` layout inference | Existing PE struct/ReClass techniques plus a symbol/field evidence ledger | `kb/pe-reverse/techniques/03-static-analysis/01-struct-reconstruction.md`, `04-reclass-reconstruction.md` | + +## Tool baseline + +For the supported Ghidra-first workflow, install or provide: + +- Ghidra and the compatible Java runtime; `analyzeHeadless` is the primary automated path. +- Android SDK platform-tools (`adb`) for Android work. +- Host Frida tooling plus an Android `frida-server` version compatible with the host client. +- JADX and Apktool for Android static preparation. + +IDA Pro/Hex-Rays may be used as an analyst-provided optional second opinion. It is not required by this integration, is not added to `.mcp.json`, and is not a CI dependency. Ghidra summary evidence remains the portable project contract. + +## Update policy + +Before using a newer upstream revision as inspiration, review its tree and license metadata again, update the pinned revision and date in this file, and check that no unconfirmed third-party source or binary crosses the public migration boundary. diff --git a/kb/apk-reverse/techniques/02-native/06-jni-register-natives-tracing.md b/kb/apk-reverse/techniques/02-native/06-jni-register-natives-tracing.md index b827ebd..a56eebf 100644 --- a/kb/apk-reverse/techniques/02-native/06-jni-register-natives-tracing.md +++ b/kb/apk-reverse/techniques/02-native/06-jni-register-natives-tracing.md @@ -35,7 +35,7 @@ tags: - "frida" - "ghidra" language: "zh-CN" -last_updated: "2026-07-03" +last_updated: "2026-07-31" related_articles: - "apk-reverse/02-native/01-il2cpp-offset-discovery" - "apk-reverse/04-crypto/01-game-encryption-patterns" @@ -67,66 +67,82 @@ strings exports/android/app-apktool/lib/arm64-v8a/*.so | rg -i "RegisterNatives| 如果导出表只有 `JNI_OnLoad`,说明很可能使用动态注册;下一步 hook `RegisterNatives`。 -## 3. RegisterNatives Frida 打点 +## 3. MCP 模板:加载器与 RegisterNatives 证据 -```javascript -function readJniString(ptrValue) { - return ptrValue.isNull() ? "" : ptrValue.readCString(); -} +优先使用 MCP 模板,而不是复制临时脚本。先检查可用模板: -const reg = Module.findExportByName("libart.so", "_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi") - || Module.findExportByName("libart.so", "RegisterNatives"); +```text +android_frida_template_library +android_frida_render_template(template_id="native_module_load_hook", substitutions_json='{"library_name":"libguard.so"}') +android_frida_render_template(template_id="native_register_natives") +``` -Interceptor.attach(reg, { - onEnter(args) { - const env = args[0]; - const clazz = args[1]; - const methods = args[2]; - const count = args[3].toInt32(); - console.log("[jni] RegisterNatives count=" + count + " methods=" + methods); - for (let i = 0; i < count; i++) { - const item = methods.add(i * Process.pointerSize * 3); - const name = readJniString(item.readPointer()); - const sig = readJniString(item.add(Process.pointerSize).readPointer()); - const fn = item.add(Process.pointerSize * 2).readPointer(); - const mod = Process.findModuleByAddress(fn); - console.log("[jni] " + name + sig + " -> " + fn + " " + (mod ? mod.name : "unknown")); - } - } -}); +`native_module_load_hook` 先检查目标 SO 是否已加载;未加载时仅观察 `android_dlopen_ext` / `dlopen`,在加载返回后记录路径、模块基址和大小。它不会 hook `.init_array`、构造函数或修改目标行为。 + +`native_register_natives` 对每次注册最多解析 64 项,输出 declaring Java class、方法名、JNI 签名、native 运行时地址、模块路径、模块基址、架构和 RVA;每一项的字符串/指针读取失败会单独标为 unresolved,字符串读取上限为 256 bytes,不会中止整次观察。 + +```text +# 对已运行的受控 app:将两个已渲染模板合并后交给 android_frida_run_script, +# 但 attach 只能观察 attach 之后的新注册。 +# 要捕获 JNI_OnLoad 的启动期注册:先 android_force_stop,再使用 +# android_frida_run_script(target=, mode="spawn");该模式会先加载脚本、 +# 再 resume 进程。仅在受控环境显式选择 spawn,避免静默改变 app 生命周期。 +# crypto/unpack 场景:android_crypto_unpack_recipe 已包含 native_dlopen、 +# native_memory_map 与 native_register_natives,适合先取得基础证据。 ``` -成功标志:输出 `nativeName(signature) -> address module`,地址可直接丢给 Ghidra/x64dbg 风格的函数定位。 +成功标志不是单一 `dlopen` 句柄,而是可审计的映射(Java class 必须包含在身份中,避免不同 class 的同名方法混淆): -## 4. JNI_OnLoad 调用链 +```text +Java class + method name + JNI signature + -> module path + runtime base + -> native runtime VA + derived RVA +``` -```powershell -# Ghidra headless 后搜索 JNI_OnLoad、RegisterNatives xref -python scripts/misc/ai_tool.py run ghidra_headless_analyze -- samples/app.apk --out exports/android/ghidra +## 4. Ghidra 静态关联与 ASLR/PIE 归一化 + +对动态观察到的实际 `.so` 做 Ghidra 分析,而不是只分析 APK 容器: + +```text +1. ghidra_headless_analyze:导入观察到的 SO,生成 summary。 +2. ghidra_summary_functions:查 JNI_OnLoad、RegisterNatives、Java_ 或观察到的方法名。 +3. ghidra_summary_strings:查 JNI 签名、方法名、库标识。 +4. ghidra_summary_function_detail:读取候选函数的 signature、callers/callees、imports、strings、decompile。 +5. ghidra_summary_call_focus:以 JNI/native/crypto 等关键词排序后续函数。 +``` + +运行时地址受 PIE/ASLR 影响,不能直接当作 Ghidra 静态地址。关联前先确认 runtime VA 位于记录模块的映射范围: + +```text +module base <= runtime native VA < module base + module size +RVA = runtime native VA - runtime module base +Ghidra candidate = Ghidra image base + RVA ``` -Ghidra 中按这个顺序命名: +RVA 只可用于同一二进制:记录并核对 SO 的完整路径、架构、SHA256 或 build ID,再把该文件导入 Ghidra。模块路径相同不代表内容相同;hash/build ID、架构或地址范围不一致时,保留 unresolved,不要进行地址关联。 + +Ghidra 中按这个顺序建立候选命名: ```text JNI_OnLoad - -> register_native_methods - -> native_sign - -> native_encrypt_packet - -> native_check_license + -> candidate_register_native_methods + -> candidate_native_sign + -> candidate_native_encrypt_packet ``` 对每个 native 函数记录: ```text -Java method: -JNI signature: -SO: -VA/RVA: -Inputs from Java: -Return to Java: -Next hop: +Java class / method name / JNI signature: +SO path / SHA256 or build ID / architecture: +Runtime module base / module size / runtime VA / derived RVA: +Ghidra function entry / current name / signature: +Callers, callees, imports, strings, decompile evidence: +Confidence / unresolved assumptions: ``` +地址、模块或函数不能匹配时保留 unresolved 状态;不要把运行时 VA 与静态 Ghidra 地址直接等同,也不要把候选名称写成已确认符号。 + ## 5. 参数/返回值打点 ```javascript @@ -156,7 +172,7 @@ Java.perform(function () { }); ``` -## 6. 路径分叉 +## 6. 分析流程与路径分叉 | 发现 | 下一跳 | |---|---| @@ -171,7 +187,7 @@ Java.perform(function () { | 项 | 记录内容 | |---|---| | Java 入口 | 类名、方法名、JNI 签名 | -| Native 映射 | SO、VA/RVA、RegisterNatives 输出 | +| Native 映射 | Java class、方法名、JNI 签名、SO path、架构、SHA256/build ID、module base/size、VA/RVA、RegisterNatives 输出 | | 参数 | Java 入参长度、hex 摘要、返回值类型 | | Ghidra | 函数名、调用者、被调用者、关键字符串 | | 下一跳 | crypto、network、license、patch 或 packer | @@ -182,6 +198,6 @@ Java.perform(function () { |---|---|---| | JNI/crypto 模板 | `android_crypto_unpack_recipe` | 生成 RegisterNatives 和 crypto hook | | SO 静态分析 | `ghidra_headless_analyze` | 函数边界、xref、伪代码 | -| 动态执行 | `android_frida_run_script` | spawn 早期捕获注册表 | +| 动态执行 | `android_frida_run_script` | attach 观察后续注册;受控环境显式 `spawn` 捕获启动期注册 | | 知识路由 | `kb_router` | 按 JNI/native/crypto 信号查文档 | diff --git a/kb/apk-reverse/techniques/kb-index.json b/kb/apk-reverse/techniques/kb-index.json index 0f0a66b..4a211ee 100644 --- a/kb/apk-reverse/techniques/kb-index.json +++ b/kb/apk-reverse/techniques/kb-index.json @@ -12,13 +12,14 @@ }, { "id": "native", - "signals": [".so", "native", "jni", "ndk", "elf", "arm", "aarch64", "lib", "loadlibrary", "system.load", "il2cpp", "libil2cpp", "ue4", "libue4", "offset", "pointer chain", "getZZ", "pagemap", "virtual address", "physical address", "proc node", "procfs", "kernel driver"], + "signals": [".so", "native", "jni", "JNI_OnLoad", "RegisterNatives", "native method", "Java_com_", "ndk", "elf", "arm", "aarch64", "lib", "loadlibrary", "system.load", "il2cpp", "libil2cpp", "ue4", "libue4", "offset", "pointer chain", "getZZ", "pagemap", "virtual address", "physical address", "proc node", "procfs", "kernel driver"], "files": [ "02-native/01-il2cpp-offset-discovery.md", "02-native/02-pointer-chain-patterns.md", "02-native/03-ue4-offset-hunting.md", "02-native/04-kernel-procfs-driver.md", - "02-native/05-virt-phys-memory.md" + "02-native/05-virt-phys-memory.md", + "02-native/06-jni-register-natives-tracing.md" ], "priority": 10 }, diff --git a/kb/pe-reverse/techniques/03-static-analysis/01-struct-reconstruction.md b/kb/pe-reverse/techniques/03-static-analysis/01-struct-reconstruction.md index e2eaab2..4457165 100644 --- a/kb/pe-reverse/techniques/03-static-analysis/01-struct-reconstruction.md +++ b/kb/pe-reverse/techniques/03-static-analysis/01-struct-reconstruction.md @@ -40,8 +40,9 @@ tags: - "game-hacking" - "static-analysis" language: "zh-CN" -last_updated: "2026-06-25" -related_articles: [] +last_updated: "2026-07-31" +related_articles: + - "pe-reverse/03-static-analysis/04-reclass-reconstruction" --- # 内存结构体逆向重建 @@ -200,6 +201,24 @@ while (item) { } ``` +## 证据账本与置信度 + +在写出 `struct` 前,为每个字段维护一条证据账本。Ghidra 的反编译与 xref 是静态证据;受控运行时的 Frida/x64dbg/ReClass 观察才是动态佐证。不要因单次内存快照就把语义命名为 confirmed。 + +| Offset | Width / Read-Write | Candidate type / meaning | Function / Address | Static evidence | Dynamic evidence | Confidence | +|---:|---|---|---|---|---|---| +| `+0x190` | `8 / read` | `USceneComponent* RootComponent` | `FUN_... @ RVA ...` | `mov rax, [rcx+190h]`; 后续解引用 | 指针落在已知 heap object | Inferred | +| `+0x268` | `4 / write` | `int health` | `FUN_... @ RVA ...` | `mov [rcx+268h], eax`; 调用点与伤害流程相连 | 受控测试时值递减 | Confirmed | +| `+0x378` | `4 / read` | `float moveSpeed` | `FUN_... @ RVA ...` | `movss`; 与移动分支关联 | 尚未观察 | Inferred | + +证据级别: + +- **Observed**:原始反汇编、Ghidra 伪代码、xref 或运行时日志直接显示的事实。 +- **Inferred**:由偏移、访问宽度、调用上下文或值域推导出的类型/语义;必须保留推导依据。 +- **Confirmed**:至少有相互独立的静态证据,并在安全、授权的测试中获得动态一致性验证。 + +记录时始终同时保留样本 SHA256、模块 image base、RVA/VA/文件偏移换算、函数地址和工具版本,避免把 ASLR 下的运行时地址误写成静态偏移。 + ## Frida 辅助验证 ```javascript diff --git a/kb/pe-reverse/techniques/03-static-analysis/04-reclass-reconstruction.md b/kb/pe-reverse/techniques/03-static-analysis/04-reclass-reconstruction.md index 8aa605e..25ed2ff 100644 --- a/kb/pe-reverse/techniques/03-static-analysis/04-reclass-reconstruction.md +++ b/kb/pe-reverse/techniques/03-static-analysis/04-reclass-reconstruction.md @@ -40,8 +40,9 @@ tags: - "memory-analysis" - "game-hacking" language: "zh-CN" -last_updated: "2026-06-25" -related_articles: [] +last_updated: "2026-07-31" +related_articles: + - "pe-reverse/03-static-analysis/01-struct-reconstruction" --- # ReClass 结构体实时重建 @@ -143,6 +144,18 @@ ReClass hex dump: → 类型: float[16] ``` +## 运行时验证前的证据账本 + +在 ReClass 中创建字段前,先使用[内存结构体逆向重建](01-struct-reconstruction.md)的字段证据账本。每条候选字段至少保留:偏移、宽度、读/写方向、候选类型/语义、Ghidra 函数/RVA、静态证据、动态观察和置信度。 + +```text +Observed — Ghidra/xref/断点/内存快照直接显示的事实 +Inferred — 由访问模式或值域推导的字段含义 +Confirmed — 独立静态证据与受控运行时变化一致 +``` + +ReClass 负责可视化和动态佐证,不会自动证明字段语义。把单次值匹配保留为 Inferred;只有在可重复的状态变化、访问代码和对象边界共同吻合时才升为 Confirmed。 + ## 导出为 C++ ```cpp diff --git a/tests/test_android_mumu.py b/tests/test_android_mumu.py new file mode 100644 index 0000000..cecc641 --- /dev/null +++ b/tests/test_android_mumu.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MCP_ROOT = ROOT / "tools" / "skills" / "mcp" / "ReverseLabToolsMCP" +sys.path.insert(0, str(MCP_ROOT)) + +from reverselab_mcp.errors import ToolError # noqa: E402 +from reverselab_mcp.tools import android_mumu # noqa: E402 + + +def test_loader_aware_template_is_declared_and_renderable(): + catalog = android_mumu.android_frida_template_library() + template_ids = {item["template_id"] for item in catalog["templates"]} + + assert "native_module_load_hook" in template_ids + rendered = android_mumu.android_frida_render_template( + "native_module_load_hook", '{"library_name":"libguard.so"}' + ) + + script = rendered["script_source"] + assert "native_module.loaded" in script + assert "native_module.load_failed" in script + assert "retval.isNull()" in script + assert "android_dlopen_ext" in script + assert "libguard.so" in script + + +def test_loader_aware_template_rejects_missing_library_name(): + try: + android_mumu.android_frida_render_template("native_module_load_hook") + except ToolError as exc: + assert "missing placeholders" in str(exc) + else: + raise AssertionError("missing library_name must fail") + + +def test_register_natives_template_emits_bounded_mapping_evidence(): + rendered = android_mumu.android_frida_render_template("native_register_natives") + script = rendered["script_source"] + + assert "RegisterNatives.method" in script + assert "RegisterNatives.truncated" in script + assert "Math.min(this.count, 64)" in script + assert "jni_signature" in script + assert "declaring_class" in script + assert "strings_truncated" in script + assert "Memory.readUtf8String(address, 256)" in script + assert "function_in_module_range" in script + assert "native_address" in script + + + +def test_shell_mumu_fallback_escapes_quotes_without_python_312_syntax(monkeypatch): + called = {} + + monkeypatch.setattr(android_mumu, "IS_WINDOWS", True) + monkeypatch.setattr(android_mumu, "_is_default_mumu_serial", lambda serial: True) + monkeypatch.setattr(android_mumu, "_adb", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("adb unavailable"))) + monkeypatch.setattr( + android_mumu, + "_mumu_cli", + lambda args, **kwargs: (called.update({"args": args}) or (0, "", "")), + ) + + android_mumu._shell('echo "quoted"', as_root=False) + + assert 'su -c "echo \\"quoted\\""' in called["args"] + + +def test_crypto_recipe_templates_are_registered(): + catalog = android_mumu.android_frida_template_library() + template_ids = {item["template_id"] for item in catalog["templates"]} + requested = [item.strip() for item in android_mumu.ANDROID_CRYPTO_UNPACK_TEMPLATES.split(",")] + + assert requested + assert set(requested).issubset(template_ids) diff --git a/tests/test_reverse_skills_integration.py b/tests/test_reverse_skills_integration.py new file mode 100644 index 0000000..ff380c2 --- /dev/null +++ b/tests/test_reverse_skills_integration.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MCP_ROOT = ROOT / "tools" / "skills" / "mcp" / "ReverseLabToolsMCP" +sys.path.insert(0, str(MCP_ROOT)) + +from reverselab_mcp.errors import ToolError # noqa: E402 +from reverselab_mcp.tools import analysis_notes, web_ctf # noqa: E402 + + +def test_apk_jni_route_references_existing_technique(): + result = web_ctf.kb_router("JNI_OnLoad RegisterNatives native method", board="apk-reverse") + + assert result["total"] >= 1 + native = next(item for item in result["top"] if item["id"] == "native") + assert any(path.endswith("02-native/06-jni-register-natives-tracing.md") for path in native["files"]) + + +def test_apk_index_files_stay_under_techniques_root(): + techniques_root = web_ctf.KB_ROOTS["apk-reverse"].resolve() + data = json.loads((techniques_root / "kb-index.json").read_text(encoding="utf-8")) + + for entry in data["entries"]: + for relative_path in entry["files"]: + resolved = (techniques_root / relative_path).resolve() + assert resolved.is_relative_to(techniques_root) + assert resolved.is_file() + + +def test_kb_read_file_rejects_traversal(): + try: + web_ctf.kb_read_file("../../README.md", board="apk-reverse") + except ToolError as exc: + assert "outside allowed roots" in str(exc) + else: + raise AssertionError("path traversal must fail") + + +def test_function_map_schema_separates_confidence_and_review_status(): + header = "| Address | Current Name | Proposed Name | Purpose / Evidence | Confidence | Review Status |" + row = "| `0x401000` | `FUN_00401000` | `candidate_fun_00401000` | `signature` | `Low` | `Needs review` |" + + assert header.count("|") == row.count("|") + assert "`Low`" in row + assert "`Needs review`" in row + + +def test_symbol_evidence_cue_and_candidate_name_are_conservative(): + function = { + "name": "FUN_00401000", + "signature": "int FUN_00401000(int)", + "import_refs": [{"name": "CreateFileW"}], + "string_refs": [{"value": "config"}], + "callers": [{"name": "entry"}], + "callees": [{"name": "FUN_00402000"}], + "decompile": {"status": "ok"}, + } + + assert analysis_notes._proposed_function_name(function) == "candidate_fun_00401000" + cue = analysis_notes._function_evidence_cue(function) + assert "signature" in cue + assert "imports:1" in cue + assert "strings:1" in cue + assert "callers:1" in cue + assert "callees:1" in cue + assert "decompile" in cue diff --git a/tools/skills/AI-USAGE.md b/tools/skills/AI-USAGE.md index ac377bc..ff8091c 100644 --- a/tools/skills/AI-USAGE.md +++ b/tools/skills/AI-USAGE.md @@ -1,12 +1,16 @@ -# Skills AI Usage +# Skills / MCP AI Usage -MCP 技能和工具的 AI 使用约定。 +本目录保存 MCP 实现;项目级行为由 `AGENTS.md`、board 的 `AI-USAGE.md` 和知识库技术文件定义。 ## MCP 服务器 -MCP 配置在项目根目录的 `.mcp.json` 中。主要服务器: +MCP 配置在项目根目录的 `.mcp.json` 中。默认服务器: -- **ghidra** — Ghidra 反编译器 MCP 桥接 - **reverse_lab_tools** — 逆向实验室工具集 MCP + +可选的第三方桥接示例在 `.mcp.optional.example.json` 中: + +- **ghidra** — Ghidra GUI MCP 桥接;Ghidra headless 分析不依赖此桥接 - **jshook** — 浏览器 JS Hook MCP -- 其他通用 MCP:github, filesystem, memory, playwright, puppeteer, shell + +不要把 optional bridge 当作项目的强制依赖;Ghidra-first 静态分析通过 `reverse_lab_tools` 的 `ghidra_headless_analyze` 和 `ghidra_summary_*` 完成。外部 workflow 参考的来源与再分发规则见 [`docs/upstreams/reverse-skills.md`](../../docs/upstreams/reverse-skills.md)。 \ No newline at end of file diff --git a/tools/skills/README.md b/tools/skills/README.md index 1f3030b..3f4736f 100644 --- a/tools/skills/README.md +++ b/tools/skills/README.md @@ -1,9 +1,11 @@ -# Skills +# Skills and MCP -Agent 技能和 MCP 服务器配置。 +本目录保存 MCP(Model Context Protocol)服务器实现和相关文档,不是 Claude Code 自动发现的 skill 根目录。 ## 目录 | 目录 | 用途 | |---|---| -| `mcp/` | MCP (Model Context Protocol) 服务器 | +| `mcp/` | MCP 服务器实现;默认启用项由根目录 `.mcp.json` 定义,第三方桥接保留在 `.mcp.optional.example.json` | + +仓库级行为约定由 `AGENTS.md`、各 board 的 `AI-USAGE.md` 和 `kb/` 技术文档提供。外部 workflow 项目的参考与再分发边界见 [`docs/upstreams/reverse-skills.md`](../../docs/upstreams/reverse-skills.md)。 \ No newline at end of file diff --git a/tools/skills/mcp/ReverseLabToolsMCP/reverselab_mcp/tools/analysis_notes.py b/tools/skills/mcp/ReverseLabToolsMCP/reverselab_mcp/tools/analysis_notes.py index bf74103..157e2c9 100644 --- a/tools/skills/mcp/ReverseLabToolsMCP/reverselab_mcp/tools/analysis_notes.py +++ b/tools/skills/mcp/ReverseLabToolsMCP/reverselab_mcp/tools/analysis_notes.py @@ -100,10 +100,28 @@ def _proposed_function_name(function: dict[str, Any]) -> str: if name.startswith("__") or name.startswith("thunk_"): return name if name.startswith("FUN_"): - return f"rename_{name.lower()}" + return f"candidate_{name.lower()}" return name or "review_me" +def _function_evidence_cue(function: dict[str, Any]) -> str: + cues: list[str] = [] + if str(function.get("signature", "")).strip(): + cues.append("signature") + if isinstance(function.get("import_refs"), list) and function["import_refs"]: + cues.append(f"imports:{len(function['import_refs'])}") + if isinstance(function.get("string_refs"), list) and function["string_refs"]: + cues.append(f"strings:{len(function['string_refs'])}") + if isinstance(function.get("callers"), list) and function["callers"]: + cues.append(f"callers:{len(function['callers'])}") + if isinstance(function.get("callees"), list) and function["callees"]: + cues.append(f"callees:{len(function['callees'])}") + decompile = function.get("decompile") + if isinstance(decompile, dict) and str(decompile.get("status", "")).lower() == "ok": + cues.append("decompile") + return ", ".join(cues) if cues else "review xrefs/decompile" + + def triage_to_notes( sample_path: str, summary_path: str = "", @@ -228,8 +246,8 @@ def triage_to_notes( "", "### Function Map", "", - "| Address | Current Name | Proposed Name | Purpose | Confidence |", - "|---|---|---|---|---|", + "| Address | Current Name | Proposed Name | Purpose / Evidence | Confidence | Review Status |", + "|---|---|---|---|---|---|", ] ) for function in functions[: max(0, max_functions)]: @@ -237,11 +255,22 @@ def triage_to_notes( continue lines.append( f"| `{_fmt_hex(function.get('entry', ''))}` | `{function.get('name', '')}` | `{_proposed_function_name(function)}` | " - f"`review signature/xrefs` | `Low-Medium` |" + f"`{_function_evidence_cue(function)}` | `Low` | `Needs review` |" ) lines.extend( [ + "", + "### Symbol / Struct Evidence Ledger", + "", + "Use this ledger to keep observed evidence separate from hypotheses. A proposed name or field type is not confirmed until its listed static and dynamic evidence agree.", + "", + "| Kind | Function / Address | Offset | Width / Access | Candidate Name or Meaning | Static Evidence | Dynamic Evidence | Confidence |", + "|---|---|---:|---|---|---|---|---|", + "| symbol | | | | | signature / strings / imports / callers / callees | | Low / Medium / High |", + "| field | | | | | decompile / xref / instruction | runtime observation (if authorized) | Low / Medium / High |", + "", + "Evidence level: **Observed** records tool output; **Inferred** records a reasoned hypothesis; **Confirmed** requires corroborating static and, where safe, dynamic evidence.", "", "### Key Functions", "", diff --git a/tools/skills/mcp/ReverseLabToolsMCP/reverselab_mcp/tools/android_mumu.py b/tools/skills/mcp/ReverseLabToolsMCP/reverselab_mcp/tools/android_mumu.py index 9450bc1..7177eb4 100644 --- a/tools/skills/mcp/ReverseLabToolsMCP/reverselab_mcp/tools/android_mumu.py +++ b/tools/skills/mcp/ReverseLabToolsMCP/reverselab_mcp/tools/android_mumu.py @@ -121,7 +121,8 @@ def _shell(command: str, serial: str = "", as_root: bool = False, timeout: int = except Exception: if not IS_WINDOWS or not _is_default_mumu_serial(serial): raise - wrapped = command if as_root else f"su -c \"{command.replace('\"', '\\\"')}\"" + escaped = command.replace('"', '\\"') + wrapped = command if as_root else f'su -c "{escaped}"' code, stdout, stderr = _mumu_cli(["sh", "--vmindex", _mumu_vmindex(), "--cmd", wrapped], timeout=timeout, check=True) return { "command": command, @@ -1847,6 +1848,74 @@ def on_message(message, data): }); }); }); }""", + }, + "native_module_load_hook": { + "title": "Observe native module loads", + "description": "先检查已加载模块,再观察 android_dlopen_ext/dlopen;只记录模块身份与运行时基址。", + "placeholders": ["library_name"], + "script": """var targetLibrary = "{library_name}"; +var seenModuleBases = {}; + +function basename(value) { + var text = String(value || ''); + var parts = text.split('/'); + return parts.length ? parts[parts.length - 1] : text; +} +function matchesTarget(module) { + if (!targetLibrary) return false; + var wanted = basename(targetLibrary); + return module.name === targetLibrary || module.name === wanted || + String(module.path || '').indexOf(targetLibrary) >= 0; +} +function emitModule(source, requestedPath, module) { + if (!module) return; + var base = String(module.base); + if (seenModuleBases[base]) return; + seenModuleBases[base] = true; + send({ + event: 'native_module.loaded', source: source, + requested_path: requestedPath || '', module_name: module.name, + module_path: String(module.path || ''), module_base: base, + module_size: module.size + }); +} +function findLoaded(requestedPath) { + var wanted = basename(requestedPath || targetLibrary); + var modules = Process.enumerateModules(); + for (var i = 0; i < modules.length; i++) { + var module = modules[i]; + if (module.name === wanted || String(module.path || '').indexOf(requestedPath || targetLibrary) >= 0) return module; + } + return null; +} +Process.enumerateModules().forEach(function (module) { + if (matchesTarget(module)) emitModule('already_loaded', targetLibrary, module); +}); +['android_dlopen_ext', 'dlopen'].forEach(function (loaderName) { + var address = Module.findExportByName(null, loaderName); + if (!address) { + send({event: 'native_module.loader_unavailable', loader: loaderName}); + return; + } + Interceptor.attach(address, { + onEnter: function (args) { + this.requestedPath = ''; + try { this.requestedPath = args[0].isNull() ? '' : Memory.readUtf8String(args[0]); } catch (e) {} + }, + onLeave: function (retval) { + if (!this.requestedPath || (!targetLibrary && !this.requestedPath)) return; + if (targetLibrary && this.requestedPath.indexOf(targetLibrary) < 0 && basename(this.requestedPath) !== basename(targetLibrary)) return; + if (retval.isNull()) { + send({event: 'native_module.load_failed', source: loaderName, requested_path: this.requestedPath}); + return; + } + var module = findLoaded(this.requestedPath); + if (module) emitModule(loaderName, this.requestedPath, module); + else send({event: 'native_module.unresolved', source: loaderName, requested_path: this.requestedPath, loader_handle: String(retval)}); + } + }); + send({event: 'native_module.loader_hooked', loader: loaderName, address: String(address)}); +});""", }, "native_dlopen": { "title": "Hook android_dlopen_ext", @@ -1906,11 +1975,60 @@ def on_message(message, data): }); return candidates.length ? candidates[0] : null; } +function safeCString(address) { + try { + if (address.isNull()) return {value: '', truncated: false}; + var value = Memory.readUtf8String(address, 256); + return {value: value, truncated: value.length >= 256}; + } catch (e) { return {value: '', truncated: false, error: String(e)}; } +} +function declaringClass(clazz) { + try { + if (typeof Java !== 'undefined' && Java.available) { + var env = Java.vm.tryGetEnv(); + if (env) return String(env.getClassName(clazz)); + } + } catch (e) {} + return ''; +} +function moduleEvidence(address) { + try { + var module = Process.findModuleByAddress(address); + if (!module) return {module_resolved: false}; + return { + module_resolved: true, module_name: module.name, + module_path: String(module.path || ''), module_base: String(module.base), + module_size: module.size, module_arch: Process.arch, + function_rva: String(address.sub(module.base)), + function_in_module_range: address.compare(module.base) >= 0 && address.compare(module.base.add(module.size)) < 0 + }; + } catch (e) { return {module_resolved: false, module_error: String(e)}; } +} var rn = findRegisterNatives(); if (rn) { Interceptor.attach(rn, { onEnter: function (args) { - send({event: 'RegisterNatives.enter', env: String(args[0]), clazz: String(args[1]), methods: String(args[2]), count: args[3].toInt32()}); + this.methods = args[2]; + this.count = Math.max(0, args[3].toInt32()); + send({event: 'RegisterNatives.enter', env: String(args[0]), clazz: String(args[1]), methods: String(this.methods), count: this.count}); + var cappedCount = Math.min(this.count, 64); + if (this.count > cappedCount) send({event: 'RegisterNatives.truncated', declared_count: this.count, decoded_count: cappedCount}); + for (var i = 0; i < cappedCount; i++) { + try { + var item = this.methods.add(i * Process.pointerSize * 3); + var nameInfo = safeCString(item.readPointer()); + var signatureInfo = safeCString(item.add(Process.pointerSize).readPointer()); + var nativeAddress = item.add(Process.pointerSize * 2).readPointer(); + var evidence = moduleEvidence(nativeAddress); + send({event: 'RegisterNatives.method', method_index: i, declared_count: this.count, + declaring_class: declaringClass(args[1]), + method_name: nameInfo.value || '', jni_signature: signatureInfo.value || '', + strings_truncated: Boolean(nameInfo.truncated || signatureInfo.truncated), + native_address: String(nativeAddress), evidence: evidence}); + } catch (e) { + send({event: 'RegisterNatives.method_error', method_index: i, declared_count: this.count, error: String(e)}); + } + } }, onLeave: function (retval) { send({event: 'RegisterNatives.leave', retval: String(retval)});