@skrillex1224/android-toolkit 0.1.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/README.md +56 -0
- package/entrys/node.js +24 -0
- package/index.js +1 -0
- package/package.json +24 -0
- package/scripts/postinstall.js +72 -0
- package/src/apify-kit.js +177 -0
- package/src/constants.js +24 -0
- package/src/context.js +98 -0
- package/src/device.js +344 -0
- package/src/errors.js +37 -0
- package/src/frida-client.js +429 -0
- package/src/internals/frida/webview_event_agent.js +223 -0
- package/src/launch.js +52 -0
- package/src/logger.js +53 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
import {Code} from './constants.js';
|
|
5
|
+
import {adbShell} from './device.js';
|
|
6
|
+
import {CrawlerError} from './errors.js';
|
|
7
|
+
import {Logger} from './logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 启动一个持续运行的 Frida WebView 事件记录器。
|
|
11
|
+
*
|
|
12
|
+
* 业务方负责传入:
|
|
13
|
+
* - 要 hook 的 WebView class 列表;
|
|
14
|
+
* - 要关心的事件名或关键词;
|
|
15
|
+
* - 输出文件路径。
|
|
16
|
+
*
|
|
17
|
+
* toolkit 只做三件事:
|
|
18
|
+
* 1. 找到目标 App 当前真实 pid;
|
|
19
|
+
* 2. 把业务配置和通用 Frida agent 合并成临时脚本;
|
|
20
|
+
* 3. 收集 agent 打出的 marker 行并在 stop() 时返回。
|
|
21
|
+
*/
|
|
22
|
+
export async function startWebViewEventRecorder(ctx, config = {}, options = {}) {
|
|
23
|
+
assertFridaReady(ctx, ctx.fridaWebViewEventAgentScript, 'frida webview event agent script');
|
|
24
|
+
const marker = options.marker || 'ANDROID_WEBVIEW_EVENT_JSON ';
|
|
25
|
+
const scriptPath = writeConfiguredWebViewEventAgent(ctx, config);
|
|
26
|
+
const pid = await resolvePid(ctx, ctx.packageName);
|
|
27
|
+
Logger.info(`frida webview event recorder attach pid=${pid}`);
|
|
28
|
+
|
|
29
|
+
const recorder = startFridaLineRecorder(ctx.fridaPath, [
|
|
30
|
+
'-q',
|
|
31
|
+
'-t',
|
|
32
|
+
'inf',
|
|
33
|
+
'-D',
|
|
34
|
+
ctx.serial,
|
|
35
|
+
'-p',
|
|
36
|
+
pid,
|
|
37
|
+
'-l',
|
|
38
|
+
scriptPath
|
|
39
|
+
], {
|
|
40
|
+
marker,
|
|
41
|
+
label: options.label || 'webview-event-recorder',
|
|
42
|
+
maxLines: Number(options.maxLines || 6000),
|
|
43
|
+
outputPath: options.outputPath || ''
|
|
44
|
+
});
|
|
45
|
+
await recorder.waitForStartup(Number(options.startupTimeoutMs || 3000));
|
|
46
|
+
return recorder;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 在目标 App 进程内部执行一段短 Frida 脚本,并返回 marker 输出。
|
|
51
|
+
*
|
|
52
|
+
* 适合做“从 App 内部启动非 exported Activity”“读取当前 Activity View 树”等
|
|
53
|
+
* 小动作。业务脚本必须自己 console.log marker JSON;toolkit 只负责 attach、
|
|
54
|
+
* 收集输出和错误显式化。
|
|
55
|
+
*/
|
|
56
|
+
export async function runScript(ctx, source, options = {}) {
|
|
57
|
+
assertFridaReady(ctx, ctx.fridaWebViewEventAgentScript, 'frida webview event agent script');
|
|
58
|
+
const marker = options.marker || 'ANDROID_TOOLKIT_SCRIPT_JSON ';
|
|
59
|
+
const scriptPath = writeTempScript(ctx, source, options.label || 'script');
|
|
60
|
+
const pid = await resolvePid(ctx, options.packageName || ctx.packageName);
|
|
61
|
+
const recorder = startFridaLineRecorder(ctx.fridaPath, [
|
|
62
|
+
'-q',
|
|
63
|
+
'-t',
|
|
64
|
+
String(Number(options.fridaTimeoutSeconds || 5)),
|
|
65
|
+
'-D',
|
|
66
|
+
ctx.serial,
|
|
67
|
+
'-p',
|
|
68
|
+
pid,
|
|
69
|
+
'-l',
|
|
70
|
+
scriptPath
|
|
71
|
+
], {
|
|
72
|
+
marker,
|
|
73
|
+
label: options.label || 'script',
|
|
74
|
+
maxLines: Number(options.maxLines || 1200),
|
|
75
|
+
outputPath: options.outputPath || ''
|
|
76
|
+
});
|
|
77
|
+
await recorder.waitForExit(Number(options.timeoutMs || 7000));
|
|
78
|
+
const result = await recorder.stop();
|
|
79
|
+
const scriptEvents = result.events.filter((event) => event?.type !== 'parse_error');
|
|
80
|
+
if (scriptEvents.length === 0) {
|
|
81
|
+
throw new CrawlerError({
|
|
82
|
+
message: `frida_script_failed: ${options.label || 'script'} did not emit ${marker.trim()}`,
|
|
83
|
+
code: Code.FridaUnavailable,
|
|
84
|
+
context: {lines: result.lines.slice(-40)}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return scriptEvents.at(-1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 从 App 内部启动 Activity。
|
|
92
|
+
*
|
|
93
|
+
* shell 的 `am start` 只能打开 exported Activity;很多 App 内部页面
|
|
94
|
+
* 不能被外部直接启动。这里 attach 到 App 自己的进程,用当前 Application
|
|
95
|
+
* 发起 startActivity,等价于 App 内部跳转。
|
|
96
|
+
*/
|
|
97
|
+
export async function startActivityInsideApp(ctx, className, options = {}) {
|
|
98
|
+
const event = await runScript(ctx, `
|
|
99
|
+
Java.perform(function () {
|
|
100
|
+
function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
|
|
101
|
+
Java.scheduleOnMainThread(function () {
|
|
102
|
+
try {
|
|
103
|
+
var ActivityThread = Java.use('android.app.ActivityThread');
|
|
104
|
+
var Intent = Java.use('android.content.Intent');
|
|
105
|
+
var app = ActivityThread.currentApplication();
|
|
106
|
+
var intent = Intent.$new();
|
|
107
|
+
intent.setClassName(${JSON.stringify(options.packageName || ctx.packageName)}, ${JSON.stringify(className)});
|
|
108
|
+
intent.addFlags(0x10000000);
|
|
109
|
+
app.startActivity(intent);
|
|
110
|
+
emit({ ok: true, className: ${JSON.stringify(className)} });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
emit({ ok: false, className: ${JSON.stringify(className)}, error: String(error), stack: String(error.stack || '') });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
`, {
|
|
117
|
+
label: options.label || 'start-activity-inside-app',
|
|
118
|
+
packageName: options.packageName || ctx.packageName,
|
|
119
|
+
timeoutMs: options.timeoutMs || 7000
|
|
120
|
+
});
|
|
121
|
+
if (!event.ok) {
|
|
122
|
+
throw new CrawlerError({
|
|
123
|
+
message: `automation_failed: startActivityInsideApp failed ${event.error || className}`,
|
|
124
|
+
code: Code.AutomationFailed,
|
|
125
|
+
context: event
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return event;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 在当前 App 内部 View 树里查找可见文本节点,返回真实屏幕 bounds。
|
|
133
|
+
*
|
|
134
|
+
* 这不是 OCR,也不依赖系统 uiautomator 无障碍树;它在 App 进程内遍历
|
|
135
|
+
* Activity DecorView。微信这类 App 经常不给 uiautomator 暴露节点,但
|
|
136
|
+
* App 内部 View 树仍然能拿到 TextView / EditText / List item 的位置。
|
|
137
|
+
*/
|
|
138
|
+
export async function findVisibleTextBounds(ctx, text, options = {}) {
|
|
139
|
+
const event = await runScript(ctx, `
|
|
140
|
+
Java.perform(function () {
|
|
141
|
+
function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
|
|
142
|
+
function rectOf(view) {
|
|
143
|
+
var Rect = Java.use('android.graphics.Rect');
|
|
144
|
+
var rect = Rect.$new();
|
|
145
|
+
try { view.getGlobalVisibleRect(rect); } catch (_) { }
|
|
146
|
+
return { left: rect.left.value, top: rect.top.value, right: rect.right.value, bottom: rect.bottom.value };
|
|
147
|
+
}
|
|
148
|
+
function walk(view, out, depth) {
|
|
149
|
+
if (!view || depth > ${Number(options.maxDepth || 20)} || out.length > ${Number(options.maxNodes || 3000)}) return;
|
|
150
|
+
out.push(view);
|
|
151
|
+
try {
|
|
152
|
+
var ViewGroup = Java.use('android.view.ViewGroup');
|
|
153
|
+
var group = Java.cast(view, ViewGroup);
|
|
154
|
+
for (var i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), out, depth + 1);
|
|
155
|
+
} catch (_) { }
|
|
156
|
+
}
|
|
157
|
+
function activeActivities() {
|
|
158
|
+
var ActivityThread = Java.use('android.app.ActivityThread');
|
|
159
|
+
var Activity = Java.use('android.app.Activity');
|
|
160
|
+
var ArrayMap = Java.use('android.util.ArrayMap');
|
|
161
|
+
var thread = ActivityThread.currentActivityThread();
|
|
162
|
+
var field = thread.getClass().getDeclaredField('mActivities');
|
|
163
|
+
field.setAccessible(true);
|
|
164
|
+
var map = Java.cast(field.get(thread), ArrayMap);
|
|
165
|
+
var out = [];
|
|
166
|
+
for (var i = 0; i < map.size(); i++) {
|
|
167
|
+
var record = map.valueAt(i);
|
|
168
|
+
var recordClass = record.getClass();
|
|
169
|
+
var activityField = recordClass.getDeclaredField('activity');
|
|
170
|
+
var pausedField = recordClass.getDeclaredField('paused');
|
|
171
|
+
activityField.setAccessible(true);
|
|
172
|
+
pausedField.setAccessible(true);
|
|
173
|
+
if (!pausedField.getBoolean(record)) out.push(Java.cast(activityField.get(record), Activity));
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
var target = ${JSON.stringify(String(text || ''))};
|
|
179
|
+
var exact = ${options.exact === false ? 'false' : 'true'};
|
|
180
|
+
var TextView = Java.use('android.widget.TextView');
|
|
181
|
+
var matches = [];
|
|
182
|
+
var activities = activeActivities();
|
|
183
|
+
for (var a = 0; a < activities.length; a++) {
|
|
184
|
+
var views = [];
|
|
185
|
+
walk(activities[a].getWindow().getDecorView(), views, 0);
|
|
186
|
+
for (var i = 0; i < views.length; i++) {
|
|
187
|
+
var view = views[i];
|
|
188
|
+
if (!view.isShown()) continue;
|
|
189
|
+
var value = '';
|
|
190
|
+
try { value = String(Java.cast(view, TextView).getText()); } catch (_) { continue; }
|
|
191
|
+
if (exact ? value === target : value.indexOf(target) >= 0) {
|
|
192
|
+
var rect = rectOf(view);
|
|
193
|
+
if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
|
|
194
|
+
matches.push({
|
|
195
|
+
text: value,
|
|
196
|
+
className: String(view.getClass().getName()),
|
|
197
|
+
idName: (function () { try { return String(view.getResources().getResourceName(view.getId())); } catch (_) { return ''; } })(),
|
|
198
|
+
bounds: rect
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
emit({ ok: true, target: target, matches: matches });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
emit({ ok: false, target: ${JSON.stringify(String(text || ''))}, error: String(error), stack: String(error.stack || '') });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
`, {
|
|
209
|
+
label: options.label || 'find-visible-text-bounds',
|
|
210
|
+
packageName: options.packageName || ctx.packageName,
|
|
211
|
+
timeoutMs: options.timeoutMs || 7000
|
|
212
|
+
});
|
|
213
|
+
if (!event.ok) {
|
|
214
|
+
throw new CrawlerError({
|
|
215
|
+
message: `automation_failed: findVisibleTextBounds failed ${event.error || text}`,
|
|
216
|
+
code: Code.AutomationFailed,
|
|
217
|
+
context: event
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return event.matches || [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 找到目标包名当前可 attach 的 pid。
|
|
225
|
+
*
|
|
226
|
+
* 微信会出现历史 zombie pid;这里优先使用 pidof 返回的最后一个非 Z 状态 pid,
|
|
227
|
+
* 再从 `ps -A` 兜底扫描,避免 Frida attach 到已经退出的进程。
|
|
228
|
+
*/
|
|
229
|
+
export async function resolvePid(ctx, packageName = ctx.packageName) {
|
|
230
|
+
const pidof = await adbShell(ctx, ['pidof', packageName]).catch(() => '');
|
|
231
|
+
const pidCandidates = String(pidof || '').trim().split(/\s+/).filter(Boolean);
|
|
232
|
+
const ps = await adbShell(ctx, ['ps', '-A', '-o', 'USER,PID,PPID,STAT,NAME']).catch(() => '');
|
|
233
|
+
const psRows = parsePsRows(ps);
|
|
234
|
+
|
|
235
|
+
for (let i = pidCandidates.length - 1; i >= 0; i -= 1) {
|
|
236
|
+
const pid = pidCandidates[i];
|
|
237
|
+
const row = psRows.find((item) => item.pid === pid);
|
|
238
|
+
if (row && row.name === packageName && !row.stat.includes('Z')) return pid;
|
|
239
|
+
}
|
|
240
|
+
for (const row of psRows) {
|
|
241
|
+
if (row.name === packageName && !row.stat.includes('Z')) return row.pid;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const fallbackPs = ps || await adbShell(ctx, ['ps', '-A']);
|
|
245
|
+
for (const line of String(fallbackPs || '').split('\n')) {
|
|
246
|
+
const parts = line.trim().split(/\s+/);
|
|
247
|
+
if (parts.length >= 2 && parts.at(-1) === packageName && !parts.some((part) => /^Z/.test(part))) return parts[1];
|
|
248
|
+
}
|
|
249
|
+
throw new Error(`process not found: ${packageName}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function startFridaLineRecorder(command, args, options = {}) {
|
|
253
|
+
const marker = options.marker || 'ANDROID_WEBVIEW_EVENT_JSON ';
|
|
254
|
+
const label = options.label || 'frida-recorder';
|
|
255
|
+
const maxLines = Math.max(50, Number(options.maxLines || 6000));
|
|
256
|
+
const lines = [];
|
|
257
|
+
const events = [];
|
|
258
|
+
let rawBuffer = '';
|
|
259
|
+
let stopped = false;
|
|
260
|
+
let exited = false;
|
|
261
|
+
let exitCode = null;
|
|
262
|
+
let exitSignal = null;
|
|
263
|
+
let fatalError = null;
|
|
264
|
+
|
|
265
|
+
const child = spawn(command, args, {stdio: ['ignore', 'pipe', 'pipe']});
|
|
266
|
+
|
|
267
|
+
const append = (chunk) => {
|
|
268
|
+
rawBuffer += String(chunk || '');
|
|
269
|
+
const parts = rawBuffer.split(/\r?\n/);
|
|
270
|
+
rawBuffer = parts.pop() || '';
|
|
271
|
+
for (const line of parts) {
|
|
272
|
+
lines.push(line);
|
|
273
|
+
if (lines.length > maxLines) lines.shift();
|
|
274
|
+
const payload = extractMarkerLinePayload(line, marker);
|
|
275
|
+
if (!payload) continue;
|
|
276
|
+
try {
|
|
277
|
+
events.push(JSON.parse(payload));
|
|
278
|
+
} catch (error) {
|
|
279
|
+
events.push({type: 'parse_error', raw: payload, error: String(error)});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
child.stdout.on('data', append);
|
|
285
|
+
child.stderr.on('data', append);
|
|
286
|
+
child.on('error', (error) => {
|
|
287
|
+
lines.push(`${label} error: ${error.message || String(error)}`);
|
|
288
|
+
fatalError = makeFridaUnavailableError(label, error.message || String(error), lines);
|
|
289
|
+
});
|
|
290
|
+
child.on('exit', (code, signal) => {
|
|
291
|
+
exited = true;
|
|
292
|
+
exitCode = code;
|
|
293
|
+
exitSignal = signal;
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const snapshot = () => ({
|
|
297
|
+
pid: child.pid,
|
|
298
|
+
marker,
|
|
299
|
+
events: [...events],
|
|
300
|
+
lines: [...lines],
|
|
301
|
+
exitCode,
|
|
302
|
+
exitSignal,
|
|
303
|
+
error: fatalError ? {message: fatalError.message, code: fatalError.code} : null
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const waitForStartup = async (timeoutMs) => {
|
|
307
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
308
|
+
while (Date.now() <= deadline) {
|
|
309
|
+
if (fatalError) throw fatalError;
|
|
310
|
+
if (events.length > 0) return snapshot();
|
|
311
|
+
if (exited) {
|
|
312
|
+
throw makeFridaUnavailableError(label, `Frida exited before startup code=${exitCode} signal=${exitSignal || ''}`.trim(), lines);
|
|
313
|
+
}
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
315
|
+
}
|
|
316
|
+
return snapshot();
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const waitForExit = async (timeoutMs) => {
|
|
320
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
321
|
+
while (Date.now() <= deadline) {
|
|
322
|
+
if (fatalError) throw fatalError;
|
|
323
|
+
if (exited) return snapshot();
|
|
324
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
325
|
+
}
|
|
326
|
+
return snapshot();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const stop = async () => {
|
|
330
|
+
if (stopped) return snapshot();
|
|
331
|
+
stopped = true;
|
|
332
|
+
if (rawBuffer) append('\n');
|
|
333
|
+
if (!child.killed) child.kill('SIGTERM');
|
|
334
|
+
await new Promise((resolve) => {
|
|
335
|
+
const timer = setTimeout(resolve, 1500);
|
|
336
|
+
child.once('exit', () => {
|
|
337
|
+
clearTimeout(timer);
|
|
338
|
+
resolve();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
const result = snapshot();
|
|
342
|
+
if (options.outputPath) {
|
|
343
|
+
fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
344
|
+
}
|
|
345
|
+
Logger.info(`frida ${label} stopped events=${events.length}`);
|
|
346
|
+
return result;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
pid: child.pid,
|
|
351
|
+
waitForStartup,
|
|
352
|
+
waitForExit,
|
|
353
|
+
stop,
|
|
354
|
+
snapshot
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function makeFridaUnavailableError(label, message, lines) {
|
|
359
|
+
return new CrawlerError({
|
|
360
|
+
message: `frida_unavailable: ${message}`,
|
|
361
|
+
code: Code.FridaUnavailable,
|
|
362
|
+
context: {
|
|
363
|
+
label,
|
|
364
|
+
recentLines: [...lines].slice(-20)
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function writeConfiguredWebViewEventAgent(ctx, config) {
|
|
370
|
+
const base = fs.readFileSync(ctx.fridaWebViewEventAgentScript, 'utf8');
|
|
371
|
+
const out = `/tmp/android-toolkit-webview-event-${safeFilePart(ctx.runId || 'manual')}-${Date.now()}.js`;
|
|
372
|
+
const source = [
|
|
373
|
+
`globalThis.__ANDROID_TOOLKIT_WEBVIEW_EVENT_CONFIG__ = ${JSON.stringify(config || {})};`,
|
|
374
|
+
base
|
|
375
|
+
].join('\n');
|
|
376
|
+
fs.writeFileSync(out, source, 'utf8');
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function writeTempScript(ctx, source, label) {
|
|
381
|
+
const out = `/tmp/android-toolkit-${safeFilePart(label || 'script')}-${safeFilePart(ctx.runId || 'manual')}-${Date.now()}.js`;
|
|
382
|
+
fs.writeFileSync(out, String(source || ''), 'utf8');
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function extractMarkerLinePayload(raw, marker) {
|
|
387
|
+
const index = String(raw || '').indexOf(marker);
|
|
388
|
+
if (index < 0) return '';
|
|
389
|
+
const start = index + marker.length;
|
|
390
|
+
const newline = String(raw || '').indexOf('\n', start);
|
|
391
|
+
const end = newline < 0 ? String(raw || '').length : newline;
|
|
392
|
+
return String(raw || '').slice(start, end).trim();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function parsePsRows(rawPs) {
|
|
396
|
+
const rows = [];
|
|
397
|
+
for (const line of String(rawPs || '').split('\n')) {
|
|
398
|
+
const parts = line.trim().split(/\s+/);
|
|
399
|
+
if (parts.length < 5 || parts[0] === 'USER') continue;
|
|
400
|
+
const [user, pid, ppid, stat, ...nameParts] = parts;
|
|
401
|
+
rows.push({user, pid, ppid, stat, name: nameParts.join(' ')});
|
|
402
|
+
}
|
|
403
|
+
return rows;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function assertFridaReady(ctx, scriptPath, scriptLabel) {
|
|
407
|
+
if (!ctx.serial) throw new Error('device.serial is empty');
|
|
408
|
+
assertCommandOrFile(ctx.fridaPath, 'frida cli');
|
|
409
|
+
if (!fs.existsSync(scriptPath)) throw new Error(`${scriptLabel} not found: ${scriptPath}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function assertCommandOrFile(value, label) {
|
|
413
|
+
const command = String(value || '').trim();
|
|
414
|
+
if (!command) throw new Error(`${label} is empty`);
|
|
415
|
+
const looksLikePath = command.includes('/') || command.startsWith('.');
|
|
416
|
+
if (looksLikePath && !fs.existsSync(command)) throw new Error(`${label} not found: ${command}`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function safeFilePart(value) {
|
|
420
|
+
return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '-');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export const Frida = {
|
|
424
|
+
startWebViewEventRecorder,
|
|
425
|
+
runScript,
|
|
426
|
+
startActivityInsideApp,
|
|
427
|
+
findVisibleTextBounds,
|
|
428
|
+
resolvePid
|
|
429
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const CONFIG = globalThis.__ANDROID_TOOLKIT_WEBVIEW_EVENT_CONFIG__ || {};
|
|
2
|
+
|
|
3
|
+
function getConfig() {
|
|
4
|
+
const webViewClasses = Array.isArray(CONFIG.webViewClasses)
|
|
5
|
+
? CONFIG.webViewClasses.map((item) => String(item || '').trim()).filter(Boolean)
|
|
6
|
+
: [];
|
|
7
|
+
if (webViewClasses.length === 0) {
|
|
8
|
+
throw new Error('android-toolkit webview_event_agent requires config.webViewClasses');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
webViewClasses,
|
|
13
|
+
eventNames: normalizeSet(CONFIG.eventNames),
|
|
14
|
+
keywords: Array.isArray(CONFIG.keywords)
|
|
15
|
+
? CONFIG.keywords.map((item) => String(item || '')).filter(Boolean)
|
|
16
|
+
: [],
|
|
17
|
+
includeRawJs: CONFIG.includeRawJs === true,
|
|
18
|
+
maxRawJsLength: Number(CONFIG.maxRawJsLength || 1200),
|
|
19
|
+
maxPayloadLength: Number(CONFIG.maxPayloadLength || 4 * 1024 * 1024)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeSet(values) {
|
|
24
|
+
return new Set(Array.isArray(values)
|
|
25
|
+
? values.map((item) => String(item || '').trim()).filter(Boolean)
|
|
26
|
+
: []);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emit(type, payload) {
|
|
30
|
+
const safe = payload || {};
|
|
31
|
+
safe.type = type;
|
|
32
|
+
safe.pid = Process.id;
|
|
33
|
+
safe.process = Process.name;
|
|
34
|
+
console.log(`ANDROID_WEBVIEW_EVENT_JSON ${JSON.stringify(safe)}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clip(value, limit) {
|
|
38
|
+
const raw = String(value || '');
|
|
39
|
+
if (limit <= 0 || raw.length <= limit) return raw;
|
|
40
|
+
return `${raw.slice(0, limit)}...<${raw.length}>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseJson(value) {
|
|
44
|
+
const raw = String(value || '').trim();
|
|
45
|
+
if (!raw) return null;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
} catch (_) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractCallPayload(rawSource) {
|
|
54
|
+
const source = String(rawSource || '').trim().replace(/^javascript:\s*/i, '').trim();
|
|
55
|
+
const patterns = [
|
|
56
|
+
{
|
|
57
|
+
kind: 'weixin_bridge',
|
|
58
|
+
re: /^(?:typeof\s+WeixinJSBridge\s*!==\s*['"]undefined['"]\s*&&\s*)?WeixinJSBridge\._handleMessageFromWeixin\(([\s\S]*)\)\s*;?\s*$/
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
kind: 'window_callback',
|
|
62
|
+
re: /^window\[['"]([^'"]+)['"]\]\s*&&\s*\1\(([\s\S]*)\)\s*;?\s*$/
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
kind: 'function_callback',
|
|
66
|
+
re: /^([A-Za-z_$][\w$]*)\(([\s\S]*)\)\s*;?\s*$/
|
|
67
|
+
}
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const pattern of patterns) {
|
|
71
|
+
const match = source.match(pattern.re);
|
|
72
|
+
if (!match) continue;
|
|
73
|
+
if (pattern.kind === 'weixin_bridge') {
|
|
74
|
+
const payload = parseJson(match[1]);
|
|
75
|
+
const eventName = String(payload?.__event_id || '').trim();
|
|
76
|
+
return {kind: pattern.kind, eventName, payload};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const eventName = String(match[1] || '').trim();
|
|
80
|
+
const payload = parseJson(match[2]);
|
|
81
|
+
return {kind: pattern.kind, eventName, payload};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {kind: 'raw', eventName: '', payload: null};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shouldCapture(config, rawJs, parsed) {
|
|
88
|
+
const eventName = String(parsed?.eventName || '').trim();
|
|
89
|
+
if (eventName && config.eventNames.has(eventName)) return true;
|
|
90
|
+
if (config.keywords.length === 0) return false;
|
|
91
|
+
const raw = String(rawJs || '');
|
|
92
|
+
const payloadText = parsed?.payload ? JSON.stringify(parsed.payload) : '';
|
|
93
|
+
return config.keywords.some((keyword) => raw.includes(keyword) || payloadText.includes(keyword));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function safePayload(config, payload) {
|
|
97
|
+
if (payload == null) return null;
|
|
98
|
+
const raw = JSON.stringify(payload);
|
|
99
|
+
if (raw.length <= config.maxPayloadLength) return payload;
|
|
100
|
+
return {
|
|
101
|
+
__androidToolkitTruncated: true,
|
|
102
|
+
raw: clip(raw, config.maxPayloadLength)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function callString(instance, methodName) {
|
|
107
|
+
try {
|
|
108
|
+
if (typeof instance[methodName] !== 'function') return '';
|
|
109
|
+
return String(instance[methodName]() || '');
|
|
110
|
+
} catch (_) {
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findLoaders(className) {
|
|
116
|
+
const loaders = [];
|
|
117
|
+
Java.enumerateClassLoaders({
|
|
118
|
+
onMatch(loader) {
|
|
119
|
+
try {
|
|
120
|
+
loader.findClass(className);
|
|
121
|
+
loaders.push(loader);
|
|
122
|
+
} catch (_) { }
|
|
123
|
+
},
|
|
124
|
+
onComplete() { }
|
|
125
|
+
});
|
|
126
|
+
return loaders;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hookWithFactory(loader, className, hooker) {
|
|
130
|
+
const oldLoader = Java.classFactory.loader;
|
|
131
|
+
try {
|
|
132
|
+
Java.classFactory.loader = loader;
|
|
133
|
+
const factory = Java.ClassFactory.get(loader);
|
|
134
|
+
const klass = factory.use(className);
|
|
135
|
+
hooker(klass);
|
|
136
|
+
emit('hook_installed', {className, loader: String(loader)});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
emit('hook_error', {className, loader: String(loader), error: String(error)});
|
|
139
|
+
} finally {
|
|
140
|
+
Java.classFactory.loader = oldLoader;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const INSTALLED_HOOKS = new Set();
|
|
145
|
+
|
|
146
|
+
function installAllHooks(config) {
|
|
147
|
+
let installed = 0;
|
|
148
|
+
for (const className of config.webViewClasses) {
|
|
149
|
+
const loaders = findLoaders(className);
|
|
150
|
+
if (loaders.length > 0) {
|
|
151
|
+
emit('loaders', {
|
|
152
|
+
className,
|
|
153
|
+
loaderCount: loaders.length,
|
|
154
|
+
loaders: loaders.map((loader) => String(loader)).slice(0, 5)
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
for (const loader of loaders) {
|
|
158
|
+
const key = `${className}::${String(loader)}`;
|
|
159
|
+
if (INSTALLED_HOOKS.has(key)) continue;
|
|
160
|
+
INSTALLED_HOOKS.add(key);
|
|
161
|
+
hookWithFactory(loader, className, (klass) => {
|
|
162
|
+
// 通用层只 hook WebView 的“执行 JS 字符串”入口;业务事件名和 payload 解析规则由 config 决定。
|
|
163
|
+
hookStringMethod(config, klass, className, 'evaluateJavascript');
|
|
164
|
+
hookStringMethod(config, klass, className, 'evaluateJavaScript');
|
|
165
|
+
hookStringMethod(config, klass, className, 'loadUrl');
|
|
166
|
+
});
|
|
167
|
+
installed += 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return installed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function hookStringMethod(config, klass, className, methodName) {
|
|
174
|
+
const method = klass[methodName];
|
|
175
|
+
if (!method || !method.overloads) return;
|
|
176
|
+
|
|
177
|
+
for (const overload of method.overloads) {
|
|
178
|
+
const signature = overload.argumentTypes.map((item) => item.className).join(',');
|
|
179
|
+
if (!signature.includes('java.lang.String')) continue;
|
|
180
|
+
|
|
181
|
+
overload.implementation = function (...args) {
|
|
182
|
+
const js = String(args[0] || '');
|
|
183
|
+
const parsed = extractCallPayload(js);
|
|
184
|
+
if (shouldCapture(config, js, parsed)) {
|
|
185
|
+
emit('webview_event', {
|
|
186
|
+
className,
|
|
187
|
+
methodName,
|
|
188
|
+
signature,
|
|
189
|
+
url: callString(this, 'getUrl'),
|
|
190
|
+
title: callString(this, 'getTitle'),
|
|
191
|
+
eventName: parsed.eventName,
|
|
192
|
+
eventKind: parsed.kind,
|
|
193
|
+
payload: safePayload(config, parsed.payload),
|
|
194
|
+
rawJs: config.includeRawJs ? clip(js, config.maxRawJsLength) : ''
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return overload.apply(this, args);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (typeof Java !== 'undefined' && Java.available) {
|
|
203
|
+
Java.perform(() => {
|
|
204
|
+
const config = getConfig();
|
|
205
|
+
emit('agent_ready', {
|
|
206
|
+
webViewClassCount: config.webViewClasses.length,
|
|
207
|
+
eventNames: Array.from(config.eventNames),
|
|
208
|
+
keywordCount: config.keywords.length
|
|
209
|
+
});
|
|
210
|
+
installAllHooks(config);
|
|
211
|
+
|
|
212
|
+
const pollIntervalMs = Number(CONFIG.loaderPollIntervalMs ?? 1000);
|
|
213
|
+
if (pollIntervalMs > 0) {
|
|
214
|
+
setInterval(() => {
|
|
215
|
+
try {
|
|
216
|
+
installAllHooks(config);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
emit('poll_error', {error: String(error)});
|
|
219
|
+
}
|
|
220
|
+
}, pollIntervalMs);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
package/src/launch.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import {ApifyKit} from './apify-kit.js';
|
|
4
|
+
import {createAndroidContext} from './context.js';
|
|
5
|
+
import {Device} from './device.js';
|
|
6
|
+
import {Frida} from './frida-client.js';
|
|
7
|
+
import {Logger} from './logger.js';
|
|
8
|
+
|
|
9
|
+
export const Launch = {
|
|
10
|
+
/**
|
|
11
|
+
* Android androider 的统一入口。
|
|
12
|
+
*
|
|
13
|
+
* 这层等价于普通 visitor 里的 Actor.init + Actor.getInput + requestHandler 的组合。
|
|
14
|
+
* 注意:toolkit 不读取环境变量,input/output 路径必须由具体 androider 的
|
|
15
|
+
* main.js 显式传入。这样 toolkit 不会和某个 runner、某个云手机平台绑定。
|
|
16
|
+
*
|
|
17
|
+
* handler 内建议只写业务流程;输入、ctx、toolkit 模块都从参数里取。
|
|
18
|
+
*/
|
|
19
|
+
async run(handler, options = {}) {
|
|
20
|
+
const input = options.input || JSON.parse(fs.readFileSync(requiredOption(options.inputPath, 'inputPath'), 'utf8'));
|
|
21
|
+
const ctx = options.ctx || createAndroidContext(input, options.contextDefaults || {});
|
|
22
|
+
const apifyKit = await ApifyKit.useApifyKit({
|
|
23
|
+
reset: true,
|
|
24
|
+
input,
|
|
25
|
+
ctx,
|
|
26
|
+
inputPath: options.inputPath,
|
|
27
|
+
outputPath: requiredOption(options.outputPath, 'outputPath')
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const kit = {
|
|
31
|
+
ApifyKit: apifyKit,
|
|
32
|
+
Device,
|
|
33
|
+
Frida,
|
|
34
|
+
Logger
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await handler({input, ctx, kit, ApifyKit: apifyKit});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
Logger.fail('Launch handler failed', error);
|
|
41
|
+
if (!apifyKit.hasPushed()) {
|
|
42
|
+
await apifyKit.pushFailed(error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function requiredOption(value, name) {
|
|
49
|
+
const clean = String(value || '').trim();
|
|
50
|
+
if (!clean) throw new Error(`Launch.run requires ${name}`);
|
|
51
|
+
return clean;
|
|
52
|
+
}
|