android-decompile-mcp 0.0.0-stage → 1.0.0

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/dist/smali.js ADDED
@@ -0,0 +1,128 @@
1
+ import { join, basename, resolve, dirname } from "node:path";
2
+ import { stat, readFile, readdir } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { exec, hasCommand, ensureDir } from "./util.js";
6
+ /** 项目根目录(dist/ 的上一级),不依赖启动时的 cwd。 */
7
+ const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ /**
9
+ * baksmali 调用方式解析:
10
+ * - 若设置 BAKSMALI_JAR 环境变量,则作为可执行 fat jar 调用(java -jar)。
11
+ * - 否则使用项目内 tools/ 目录下的 baksmali 及其依赖,走 classpath 调用。
12
+ */
13
+ function baksmaliInvocation() {
14
+ if (process.env.BAKSMALI_JAR) {
15
+ return { args: ["-jar", process.env.BAKSMALI_JAR], desc: process.env.BAKSMALI_JAR };
16
+ }
17
+ const toolsDir = join(PROJECT_ROOT, "tools");
18
+ const cp = join(toolsDir, "*");
19
+ return { args: ["-cp", cp, "org.jf.baksmali.Main"], desc: `${toolsDir}/*` };
20
+ }
21
+ async function ensureJava() {
22
+ if (!(await hasCommand("java"))) {
23
+ throw new Error("未找到 java。baksmali 依赖 JRE,请先安装 Java。");
24
+ }
25
+ }
26
+ /**
27
+ * 把一个或多个 dex 反编译为 smali。
28
+ * dexInput 可以是单个 .dex 文件、也可以是包含多个 dex 的目录。
29
+ */
30
+ export async function dexToSmali(dexInput, outDir) {
31
+ await ensureJava();
32
+ const bak = baksmaliInvocation();
33
+ const st = await stat(dexInput).catch(() => null);
34
+ if (!st)
35
+ throw new Error(`输入路径不存在:${dexInput}`);
36
+ // 收集待处理 dex
37
+ let dexFiles = [];
38
+ if (st.isDirectory()) {
39
+ const names = await readdir(dexInput);
40
+ dexFiles = names.filter((n) => n.toLowerCase().endsWith(".dex")).map((n) => join(dexInput, n));
41
+ }
42
+ else {
43
+ dexFiles = [dexInput];
44
+ }
45
+ if (dexFiles.length === 0)
46
+ throw new Error(`在 ${dexInput} 中未找到 .dex 文件。`);
47
+ await ensureDir(outDir);
48
+ const classDirs = [];
49
+ let log = "";
50
+ for (const dex of dexFiles) {
51
+ // 每个 dex 输出到独立子目录,避免互相覆盖
52
+ const sub = join(outDir, basename(dex).replace(/\.dex$/i, ""));
53
+ await ensureDir(sub);
54
+ const r = await exec("java", [...bak.args, "d", dex, "-o", sub], { timeoutMs: 300000 });
55
+ log += `# ${basename(dex)} (exit ${r.code})\n${r.stdout}${r.stderr}\n`;
56
+ if (r.code !== 0)
57
+ throw new Error(`baksmali 反编译失败:${dex}\n${r.stderr}`);
58
+ classDirs.push(sub);
59
+ }
60
+ return { dexFiles, outDir, classDirs, log };
61
+ }
62
+ /** 递归收集目录下所有 .smali 文件。 */
63
+ async function walkSmali(dir, acc = []) {
64
+ const entries = await readdir(dir, { withFileTypes: true });
65
+ for (const e of entries) {
66
+ const full = join(dir, e.name);
67
+ if (e.isDirectory())
68
+ await walkSmali(full, acc);
69
+ else if (e.name.toLowerCase().endsWith(".smali"))
70
+ acc.push(full);
71
+ }
72
+ return acc;
73
+ }
74
+ /** smali 类名(com.foo.Bar)转相对文件路径。 */
75
+ function classToPath(className) {
76
+ const norm = className.replace(/^L/, "").replace(/;$/, "").replace(/\./g, "/");
77
+ return norm.endsWith(".smali") ? norm : `${norm}.smali`;
78
+ }
79
+ /** 列出 smali 目录下所有类(以类名形式)。 */
80
+ export async function smaliListClasses(smaliDir, keyword) {
81
+ const files = await walkSmali(smaliDir);
82
+ const base = resolve(smaliDir);
83
+ let classes = files.map((f) => resolve(f).slice(base.length + 1).replace(/\\/g, "/").replace(/\.smali$/i, "").replace(/\//g, "."));
84
+ if (keyword)
85
+ classes = classes.filter((c) => c.includes(keyword));
86
+ return classes.sort();
87
+ }
88
+ /** 读取指定类的 smali 源码。支持 com.foo.Bar 或 com/foo/Bar 形式。 */
89
+ export async function smaliReadClass(smaliDir, className) {
90
+ const rel = classToPath(className);
91
+ const candidate = join(smaliDir, rel);
92
+ if (existsSync(candidate))
93
+ return readFile(candidate, "utf8");
94
+ // 回退:在整个目录中搜索同名文件
95
+ const files = await walkSmali(smaliDir);
96
+ const target = rel.split("/").pop();
97
+ const hit = files.find((f) => basename(f) === target);
98
+ if (!hit)
99
+ throw new Error(`未找到类:${className}`);
100
+ return readFile(hit, "utf8");
101
+ }
102
+ /**
103
+ * 在 smali 目录中按正则搜索(内容分析)。返回命中行及上下文位置。
104
+ * 适合查找 API 调用、字符串、权限、加解密逻辑等。
105
+ */
106
+ export async function smaliSearch(smaliDir, pattern, opts = {}) {
107
+ const files = await walkSmali(smaliDir);
108
+ const re = new RegExp(pattern, opts.flags ?? "i");
109
+ const max = opts.maxResults ?? 500;
110
+ const base = resolve(smaliDir);
111
+ const matches = [];
112
+ for (const f of files) {
113
+ const content = await readFile(f, "utf8");
114
+ const lines = content.split(/\r?\n/);
115
+ for (let i = 0; i < lines.length; i++) {
116
+ if (re.test(lines[i])) {
117
+ matches.push({
118
+ file: resolve(f).slice(base.length + 1).replace(/\\/g, "/"),
119
+ line: i + 1,
120
+ text: lines[i].trim(),
121
+ });
122
+ if (matches.length >= max)
123
+ return matches;
124
+ }
125
+ }
126
+ }
127
+ return matches;
128
+ }
@@ -0,0 +1,237 @@
1
+ import { readFile, writeFile, copyFile, readdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { findAppEntry } from "./entry.js";
5
+ const VERIFY_LOG_SNIPPET = ` # --- DexAndroid verify marker (learning rebuild check) ---
6
+ const-string v0, "DexAndroid"
7
+ const-string v1, "entry_ok"
8
+ invoke-static {v0, v1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
9
+ # --- end marker ---
10
+ `;
11
+ function splitLines(text) {
12
+ return text.split(/\r?\n/);
13
+ }
14
+ function joinLines(lines) {
15
+ return lines.join("\n");
16
+ }
17
+ function findOnCreateSuperIndex(lines) {
18
+ let inOnCreate = false;
19
+ for (let i = 0; i < lines.length; i++) {
20
+ if (/^\s*\.method\s+.*\bonCreate\s*\(/.test(lines[i])) {
21
+ inOnCreate = true;
22
+ continue;
23
+ }
24
+ if (inOnCreate && /^\s*\.end method\b/.test(lines[i]))
25
+ break;
26
+ if (inOnCreate &&
27
+ /invoke-super\s+\{[^}]*\},\s*L[^;]+;->onCreate\s*\(\s*Landroid\/os\/Bundle;\s*\)V/.test(lines[i])) {
28
+ return i;
29
+ }
30
+ }
31
+ // fallback: first .locals inside onCreate
32
+ inOnCreate = false;
33
+ for (let i = 0; i < lines.length; i++) {
34
+ if (/^\s*\.method\s+.*\bonCreate\s*\(/.test(lines[i])) {
35
+ inOnCreate = true;
36
+ continue;
37
+ }
38
+ if (inOnCreate && /^\s*\.locals\b/.test(lines[i]))
39
+ return i;
40
+ if (inOnCreate && /^\s*\.end method\b/.test(lines[i]))
41
+ break;
42
+ }
43
+ return -1;
44
+ }
45
+ function ensureLocalsForVerify(lines) {
46
+ // insert_verify_log uses v0/v1; bump .locals if needed inside onCreate
47
+ let inOnCreate = false;
48
+ for (let i = 0; i < lines.length; i++) {
49
+ if (/^\s*\.method\s+.*\bonCreate\s*\(/.test(lines[i])) {
50
+ inOnCreate = true;
51
+ continue;
52
+ }
53
+ if (!inOnCreate)
54
+ continue;
55
+ if (/^\s*\.end method\b/.test(lines[i]))
56
+ break;
57
+ const m = lines[i].match(/^\s*\.locals\s+(\d+)/);
58
+ if (m) {
59
+ const n = parseInt(m[1], 10);
60
+ if (n < 2)
61
+ lines[i] = lines[i].replace(/\.locals\s+\d+/, ".locals 2");
62
+ break;
63
+ }
64
+ }
65
+ return lines;
66
+ }
67
+ async function findClassSmali(roots, className) {
68
+ const rel = className.replace(/\./g, "/") + ".smali";
69
+ for (const root of roots) {
70
+ const candidates = [join(root, rel), join(root, "smali", rel)];
71
+ for (const p of candidates)
72
+ if (existsSync(p))
73
+ return p;
74
+ try {
75
+ for (const n of await readdir(root)) {
76
+ if (!n.startsWith("smali"))
77
+ continue;
78
+ const p = join(root, n, rel);
79
+ if (existsSync(p))
80
+ return p;
81
+ }
82
+ }
83
+ catch {
84
+ /* ignore */
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+ async function resolveTargetSmali(opts) {
90
+ if (opts.smaliPath) {
91
+ const p = resolve(opts.smaliPath);
92
+ if (!existsSync(p))
93
+ throw new Error(`smali 文件不存在:${p}`);
94
+ return p;
95
+ }
96
+ const roots = [];
97
+ if (opts.projectDir)
98
+ roots.push(resolve(opts.projectDir));
99
+ if (opts.smaliDir)
100
+ roots.push(resolve(opts.smaliDir));
101
+ if (opts.className && roots.length) {
102
+ const p = await findClassSmali(roots, opts.className);
103
+ if (p)
104
+ return p;
105
+ throw new Error(`未找到类 ${opts.className} 的 smali,请检查 className / projectDir。`);
106
+ }
107
+ if (opts.projectDir) {
108
+ const info = await findAppEntry({
109
+ projectDir: opts.projectDir,
110
+ smaliDir: opts.smaliDir,
111
+ });
112
+ const first = info.launchers.find((L) => L.smaliPath);
113
+ if (first?.smaliPath)
114
+ return first.smaliPath;
115
+ throw new Error("未能定位入口 Activity 的 smali,请显式传 smaliPath 或 className。");
116
+ }
117
+ throw new Error("请提供 smaliPath,或 projectDir(自动定位入口 Activity)。");
118
+ }
119
+ /**
120
+ * 通用 smali 补丁:内容由调用方提供(或使用内置 verify Log 标记)。
121
+ * 用于学习/验证自有工程重打包是否生效;不生成广告、外链跳转等业务模板。
122
+ */
123
+ export async function smaliPatch(opts) {
124
+ const smaliPath = await resolveTargetSmali(opts);
125
+ const dryRun = !!opts.dryRun;
126
+ const backup = opts.backup !== false;
127
+ const original = await readFile(smaliPath, "utf8");
128
+ let lines = splitLines(original);
129
+ let detail = "";
130
+ let changed = false;
131
+ let nextText = original;
132
+ switch (opts.mode) {
133
+ case "insert_verify_log": {
134
+ if (original.includes("DexAndroid") && original.includes("entry_ok")) {
135
+ return {
136
+ smaliPath,
137
+ mode: opts.mode,
138
+ dryRun,
139
+ changed: false,
140
+ detail: "已存在 verify 标记,跳过。",
141
+ };
142
+ }
143
+ lines = ensureLocalsForVerify(lines);
144
+ const idx = findOnCreateSuperIndex(lines);
145
+ if (idx < 0)
146
+ throw new Error("未找到 onCreate 内的 invoke-super/.locals,无法插入 verify Log。");
147
+ const snippet = splitLines(VERIFY_LOG_SNIPPET.replace(/\n$/, ""));
148
+ lines.splice(idx + 1, 0, ...snippet);
149
+ nextText = joinLines(lines);
150
+ changed = nextText !== original;
151
+ detail = `在 onCreate 第 ${idx + 1} 行后插入 Log.d("DexAndroid","entry_ok") 验证标记。`;
152
+ break;
153
+ }
154
+ case "insert_after_onCreate_super": {
155
+ if (!opts.content?.trim()) {
156
+ throw new Error("mode=insert_after_onCreate_super 需要提供 content(你自己的 smali 片段)。");
157
+ }
158
+ const idx = findOnCreateSuperIndex(lines);
159
+ if (idx < 0)
160
+ throw new Error("未找到 onCreate 内的 invoke-super/.locals。");
161
+ const snippet = splitLines(opts.content.replace(/\s+$/, ""));
162
+ lines.splice(idx + 1, 0, ...snippet);
163
+ nextText = joinLines(lines);
164
+ changed = nextText !== original;
165
+ detail = `在 onCreate 锚点第 ${idx + 1} 行后插入 ${snippet.length} 行(调用方 content)。`;
166
+ break;
167
+ }
168
+ case "insert_at_line": {
169
+ if (!opts.content?.trim())
170
+ throw new Error("mode=insert_at_line 需要 content。");
171
+ if (!opts.line || opts.line < 1)
172
+ throw new Error("mode=insert_at_line 需要 line(1-based)。");
173
+ const at = Math.min(opts.line, lines.length);
174
+ const snippet = splitLines(opts.content.replace(/\s+$/, ""));
175
+ lines.splice(at, 0, ...snippet);
176
+ nextText = joinLines(lines);
177
+ changed = nextText !== original;
178
+ detail = `在第 ${at} 行后插入 ${snippet.length} 行。`;
179
+ break;
180
+ }
181
+ case "replace": {
182
+ if (!opts.find)
183
+ throw new Error("mode=replace 需要 find。");
184
+ if (opts.content === undefined)
185
+ throw new Error("mode=replace 需要 content(替换后文本,可为空)。");
186
+ if (!original.includes(opts.find)) {
187
+ return {
188
+ smaliPath,
189
+ mode: opts.mode,
190
+ dryRun,
191
+ changed: false,
192
+ detail: "未找到 find 文本,未修改。",
193
+ };
194
+ }
195
+ nextText = original.replace(opts.find, opts.content);
196
+ changed = nextText !== original;
197
+ detail = `replace 完成(find 长度 ${opts.find.length} → content 长度 ${opts.content.length})。`;
198
+ break;
199
+ }
200
+ default:
201
+ throw new Error(`未知 mode: ${opts.mode}`);
202
+ }
203
+ const previewLines = splitLines(nextText);
204
+ const previewStart = Math.max(0, (opts.line ?? findOnCreateSuperIndex(splitLines(original))) - 3);
205
+ const preview = previewLines.slice(previewStart, previewStart + 40).join("\n");
206
+ let backupPath;
207
+ if (changed && !dryRun) {
208
+ if (backup) {
209
+ backupPath = smaliPath + ".bak";
210
+ await copyFile(smaliPath, backupPath);
211
+ }
212
+ await writeFile(smaliPath, nextText, "utf8");
213
+ }
214
+ return {
215
+ smaliPath,
216
+ mode: opts.mode,
217
+ dryRun,
218
+ backupPath,
219
+ changed,
220
+ detail,
221
+ preview,
222
+ };
223
+ }
224
+ export function formatSmaliPatch(r) {
225
+ return [
226
+ `file: ${r.smaliPath}`,
227
+ `mode: ${r.mode}`,
228
+ `dryRun: ${r.dryRun}`,
229
+ `changed: ${r.changed}`,
230
+ r.backupPath ? `backup: ${r.backupPath}` : "",
231
+ `detail: ${r.detail}`,
232
+ r.preview ? `--- preview ---\n${r.preview}` : "",
233
+ "说明: 内容由调用方提供;内置仅有 Log 验证标记。仅用于有权修改的自有/授权工程学习验证。",
234
+ ]
235
+ .filter(Boolean)
236
+ .join("\n");
237
+ }
@@ -0,0 +1,119 @@
1
+ import { join } from "node:path";
2
+ import { stat } from "node:fs/promises";
3
+ import { ensureDir } from "../util.js";
4
+ import { resolveSerial, isInstalled, install, launchApp, shell, pull, adb, } from "../adb.js";
5
+ /** 常见 BlackDex 包名(不同架构/版本不一致)。 */
6
+ export const BLACKDEX_PKGS = [
7
+ "top.niunaijun.blackdexa64",
8
+ "top.niunaijun.blackdexa32",
9
+ "com.wrlus.blackdex",
10
+ ];
11
+ /** 在设备上查找已安装的 BlackDex 包名。 */
12
+ export async function findBlackDexPkg(serial) {
13
+ for (const pkg of BLACKDEX_PKGS) {
14
+ if (await isInstalled(serial, pkg))
15
+ return pkg;
16
+ }
17
+ return null;
18
+ }
19
+ /** BlackDex dump 可能落在这些目录(v3.2 实际写在 dump/ 下)。 */
20
+ function candidateOutputDirs(pkg) {
21
+ return [
22
+ `/sdcard/Android/data/${pkg}/dump`,
23
+ `/storage/emulated/0/Android/data/${pkg}/dump`,
24
+ `/sdcard/Android/data/${pkg}/files`,
25
+ `/storage/emulated/0/Android/data/${pkg}/files`,
26
+ `/sdcard/BlackDex`,
27
+ `/storage/emulated/0/BlackDex`,
28
+ `/sdcard/Download/BlackDex`,
29
+ ];
30
+ }
31
+ /**
32
+ * 免 root 方案(BlackDex):安装并启动 BlackDex,用户在界面中选择目标 app 脱壳。
33
+ */
34
+ export async function blackdexPrepare(blackdexApkPath, serial) {
35
+ const s = await resolveSerial(serial);
36
+ let pkg = await findBlackDexPkg(s);
37
+ if (!pkg) {
38
+ if (!blackdexApkPath) {
39
+ return {
40
+ serial: s,
41
+ pkg: null,
42
+ installed: false,
43
+ launched: false,
44
+ message: `设备上未安装 BlackDex。请提供 BlackDex 的 APK 路径(参数 blackdexApkPath)以自动安装,\n` +
45
+ `或手动安装后重试。开源下载:https://github.com/CodingGay/BlackDex/releases`,
46
+ };
47
+ }
48
+ const st = await stat(blackdexApkPath).catch(() => null);
49
+ if (!st)
50
+ throw new Error(`BlackDex APK 不存在:${blackdexApkPath}`);
51
+ const r = await install(s, blackdexApkPath);
52
+ if (r.code !== 0)
53
+ throw new Error(`安装 BlackDex 失败:\n${r.stderr}`);
54
+ pkg = await findBlackDexPkg(s);
55
+ if (!pkg) {
56
+ throw new Error("BlackDex APK 已安装,但未识别到包名。请确认安装的是官方 BlackDex 发行版。");
57
+ }
58
+ }
59
+ // monkey 启动;失败时尝试显式 Activity
60
+ let launch = await launchApp(s, pkg);
61
+ let launched = launch.code === 0 && !/No activities found/i.test(launch.stdout + launch.stderr);
62
+ if (!launched) {
63
+ const act = await adb([
64
+ "shell",
65
+ "am",
66
+ "start",
67
+ "-n",
68
+ `${pkg}/top.niunaijun.blackdex.view.base.WelcomeActivity`,
69
+ ], { serial: s });
70
+ launched = act.code === 0 && !/Error/i.test(act.stdout + act.stderr);
71
+ launch = act;
72
+ }
73
+ const dirs = candidateOutputDirs(pkg).join("\n ");
74
+ return {
75
+ serial: s,
76
+ pkg,
77
+ installed: true,
78
+ launched,
79
+ message: `BlackDex 已就绪(包名: ${pkg})。\n` +
80
+ `请在设备 BlackDex 界面中选择目标应用并点击脱壳,\n` +
81
+ `完成后调用 blackdex_pull。dump 目录可能位于:\n ${dirs}`,
82
+ };
83
+ }
84
+ /** 在候选目录中找到含 dump 结果的远端路径。 */
85
+ async function resolveRemoteDump(serial, pkg, targetPkg) {
86
+ for (const base of candidateOutputDirs(pkg)) {
87
+ const remote = targetPkg ? `${base}/${targetPkg}` : base;
88
+ const ls = await shell(serial, `ls -R "${remote}" 2>/dev/null`);
89
+ if (ls.stdout.trim() && /\.dex/i.test(ls.stdout)) {
90
+ return { remote, listing: ls.stdout };
91
+ }
92
+ // 有内容但还没看到 dex 也先返回,便于排查
93
+ if (ls.stdout.trim() && targetPkg) {
94
+ return { remote, listing: ls.stdout };
95
+ }
96
+ }
97
+ // 再全局搜一遍 BlackDex / 目标包名目录
98
+ const find = await shell(serial, `ls -R /sdcard/BlackDex /sdcard/Android/data/${pkg}/files 2>/dev/null | head -n 200`);
99
+ throw new Error(`未找到 BlackDex dump 结果。请确认已在 BlackDex 中完成脱壳。\n` +
100
+ `当前可见内容:\n${find.stdout || "(空)"}`);
101
+ }
102
+ /** 把 BlackDex dump 出的 dex 从设备拉回本机。 */
103
+ export async function blackdexPull(localOutDir, serial, targetPkg) {
104
+ const s = await resolveSerial(serial);
105
+ await ensureDir(localOutDir);
106
+ const pkg = await findBlackDexPkg(s);
107
+ if (!pkg)
108
+ throw new Error("设备上未安装 BlackDex,无法拉取结果。");
109
+ const { remote, listing } = await resolveRemoteDump(s, pkg, targetPkg);
110
+ const r = await pull(s, remote, localOutDir);
111
+ if (r.code !== 0)
112
+ throw new Error(`拉取 BlackDex 结果失败:\n${r.stderr}`);
113
+ return {
114
+ serial: s,
115
+ remote,
116
+ pulledTo: join(localOutDir, targetPkg ?? "dump"),
117
+ listing,
118
+ };
119
+ }
@@ -0,0 +1,66 @@
1
+ import { join } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { exec, hasCommand, ensureDir } from "../util.js";
4
+ import { resolveSerial, shell, push } from "../adb.js";
5
+ /** 推送并启动 frida-server(需 root)。默认使用项目 tools/frida-server。 */
6
+ export async function fridaPrepare(serial, serverLocalPath) {
7
+ const s = await resolveSerial(serial);
8
+ const local = serverLocalPath || join(process.cwd(), "tools", "frida-server");
9
+ if (!existsSync(local)) {
10
+ throw new Error(`未找到 frida-server:${local}\n请下载与本机 frida 版本匹配的 android-arm64 文件到该路径。`);
11
+ }
12
+ await push(s, local, "/data/local/tmp/frida-server");
13
+ await shell(s, "su -c 'chmod 755 /data/local/tmp/frida-server'");
14
+ await shell(s, "su -c 'killall frida-server 2>/dev/null; true'");
15
+ await shell(s, "su -c 'setenforce 0'");
16
+ await shell(s, "su -c 'nohup /data/local/tmp/frida-server >/data/local/tmp/frida.log 2>&1 &'");
17
+ const ps = await shell(s, "su -c 'ps -A | grep frida-server'");
18
+ const running = /frida-server/.test(ps.stdout);
19
+ return {
20
+ serial: s,
21
+ running,
22
+ message: running
23
+ ? "frida-server 已在设备上运行(Magisk/root)。可调用 frida_unpack。"
24
+ : `frida-server 启动失败,请查看 /data/local/tmp/frida.log\n${ps.stdout}${ps.stderr}`,
25
+ };
26
+ }
27
+ /**
28
+ * root 方案:基于 frida-dexdump,从内存中搜索并 dump 已解密的 dex。
29
+ */
30
+ export async function fridaUnpack(pkg, outDir, opts = {}) {
31
+ const s = await resolveSerial(opts.serial);
32
+ await ensureDir(outDir);
33
+ if (!(await hasCommand("frida-dexdump"))) {
34
+ throw new Error("未找到 frida-dexdump。请先安装:pip install frida-tools frida-dexdump\n" +
35
+ "并确保设备已 root 且 frida-server 正在运行。");
36
+ }
37
+ const ps = await shell(s, "su -c 'ps -A | grep frida-server' || true");
38
+ const fridaRunning = /frida-server/.test(ps.stdout);
39
+ const args = ["-D", s];
40
+ if (opts.spawn !== false) {
41
+ args.push("-f", pkg);
42
+ }
43
+ else {
44
+ args.push("-n", pkg);
45
+ }
46
+ args.push("-o", outDir);
47
+ if (opts.deepSearch !== false)
48
+ args.push("-d");
49
+ if (opts.sleepSec != null)
50
+ args.push("--sleep", String(opts.sleepSec));
51
+ else if (opts.spawn !== false)
52
+ args.push("--sleep", "10");
53
+ const r = await exec("frida-dexdump", args, {
54
+ timeoutMs: opts.timeoutMs ?? 300000,
55
+ });
56
+ return {
57
+ pkg,
58
+ outDir,
59
+ code: r.code,
60
+ log: (fridaRunning ? "" : "[警告] 设备上未检测到 frida-server 进程。\n") +
61
+ r.stdout +
62
+ "\n" +
63
+ r.stderr,
64
+ dexHint: `dump 出的 dex 位于 ${outDir},可用 dex_to_smali 反编译。`,
65
+ };
66
+ }
@@ -0,0 +1,50 @@
1
+ import AdmZip from "adm-zip";
2
+ import { join, basename, isAbsolute } from "node:path";
3
+ import { stat } from "node:fs/promises";
4
+ import { ensureDir } from "../util.js";
5
+ import { resolveSerial, getApkPaths, pull } from "../adb.js";
6
+ /**
7
+ * 静态解包:解压 APK,提取所有 classes*.dex。
8
+ * 对加固 APK,这里拿到的只是壳 dex,需要配合 Frida/BlackDex 做运行时脱壳。
9
+ */
10
+ export async function staticUnpack(apkPath, outDir) {
11
+ const st = await stat(apkPath).catch(() => null);
12
+ if (!st || !st.isFile())
13
+ throw new Error(`APK 文件不存在:${apkPath}`);
14
+ await ensureDir(outDir);
15
+ const zip = new AdmZip(apkPath);
16
+ const entries = zip.getEntries();
17
+ const dexFiles = [];
18
+ for (const e of entries) {
19
+ if (/^classes\d*\.dex$/i.test(e.entryName)) {
20
+ zip.extractEntryTo(e, outDir, false, true);
21
+ dexFiles.push(join(outDir, basename(e.entryName)));
22
+ }
23
+ }
24
+ return {
25
+ apk: apkPath,
26
+ outDir,
27
+ dexFiles,
28
+ note: dexFiles.length > 0
29
+ ? `已提取 ${dexFiles.length} 个 dex。若该 APK 被加固,这些可能只是壳 dex。`
30
+ : "未发现 classes*.dex,可能是加固后 dex 被加密隐藏,需运行时脱壳。",
31
+ };
32
+ }
33
+ /** 从设备上拉取已安装应用的 APK,返回本地主 APK 路径。 */
34
+ export async function pullApkFromDevice(pkg, outDir, serial) {
35
+ const s = await resolveSerial(serial);
36
+ const remotePaths = await getApkPaths(s, pkg);
37
+ await ensureDir(outDir);
38
+ const localApks = [];
39
+ for (const remote of remotePaths) {
40
+ const name = basename(remote);
41
+ const local = join(outDir, name === "base.apk" ? `${pkg}.apk` : `${pkg}-${name}`);
42
+ const r = await pull(s, remote, local);
43
+ if (r.code !== 0)
44
+ throw new Error(`拉取 APK 失败:${remote}\n${r.stderr}`);
45
+ localApks.push(isAbsolute(local) ? local : join(process.cwd(), local));
46
+ }
47
+ // base.apk 作为主 APK
48
+ const baseApk = localApks.find((p) => p.endsWith(`${pkg}.apk`)) || localApks[0];
49
+ return { localApks, baseApk };
50
+ }
package/dist/util.js ADDED
@@ -0,0 +1,61 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir } from "node:fs/promises";
3
+ /**
4
+ * 执行一个外部命令并收集输出。不经过 shell,避免 Windows 下的转义问题。
5
+ */
6
+ export function exec(cmd, args, opts = {}) {
7
+ return new Promise((resolve) => {
8
+ const child = spawn(cmd, args, {
9
+ cwd: opts.cwd,
10
+ windowsHide: true,
11
+ shell: false,
12
+ });
13
+ let stdout = "";
14
+ let stderr = "";
15
+ let finished = false;
16
+ const timer = opts.timeoutMs
17
+ ? setTimeout(() => {
18
+ if (!finished) {
19
+ child.kill();
20
+ finished = true;
21
+ resolve({ code: -1, stdout, stderr: stderr + "\n[超时被终止]" });
22
+ }
23
+ }, opts.timeoutMs)
24
+ : null;
25
+ child.stdout.on("data", (d) => (stdout += d.toString()));
26
+ child.stderr.on("data", (d) => (stderr += d.toString()));
27
+ child.on("error", (err) => {
28
+ if (finished)
29
+ return;
30
+ finished = true;
31
+ if (timer)
32
+ clearTimeout(timer);
33
+ resolve({ code: -1, stdout, stderr: stderr + "\n" + err.message });
34
+ });
35
+ child.on("close", (code) => {
36
+ if (finished)
37
+ return;
38
+ finished = true;
39
+ if (timer)
40
+ clearTimeout(timer);
41
+ resolve({ code: code ?? -1, stdout, stderr });
42
+ });
43
+ if (opts.input) {
44
+ child.stdin.write(opts.input);
45
+ child.stdin.end();
46
+ }
47
+ });
48
+ }
49
+ /** 判断某个命令是否在 PATH 中可用。 */
50
+ export async function hasCommand(cmd) {
51
+ const probe = process.platform === "win32" ? "where" : "which";
52
+ const r = await exec(probe, [cmd], { timeoutMs: 5000 });
53
+ return r.code === 0;
54
+ }
55
+ export async function ensureDir(dir) {
56
+ await mkdir(dir, { recursive: true });
57
+ }
58
+ /** 组合文本工具结果,方便 MCP 返回。 */
59
+ export function textResult(text) {
60
+ return { content: [{ type: "text", text }] };
61
+ }