titanium-apk-recover 2.2.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 +252 -0
- package/dist/chunk-ML2DCRT6.js +1063 -0
- package/dist/chunk-ML2DCRT6.js.map +1 -0
- package/dist/cli.cjs +1086 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.js +90 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +1126 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +340 -0
- package/dist/index.d.ts +340 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import path4 from "path";
|
|
3
|
+
import { readdir, readFile as readFile3, rm } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
// src/apk.ts
|
|
6
|
+
import path2 from "path";
|
|
7
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
8
|
+
|
|
9
|
+
// src/zip.ts
|
|
10
|
+
import { readFile } from "fs/promises";
|
|
11
|
+
import { unzip } from "fflate";
|
|
12
|
+
async function readApkEntries(apkPath, filter) {
|
|
13
|
+
const buf = await readFile(apkPath);
|
|
14
|
+
const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
15
|
+
return await new Promise((resolve, reject) => {
|
|
16
|
+
unzip(u8, { filter: filter ?? (() => true) }, (err, data) => {
|
|
17
|
+
if (err) reject(err);
|
|
18
|
+
else resolve(data);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function isDexEntry(name) {
|
|
23
|
+
return /^classes\d*\.dex$/.test(name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/manifest.ts
|
|
27
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
28
|
+
import path from "path";
|
|
29
|
+
import { createRequire } from "module";
|
|
30
|
+
import { load } from "cheerio";
|
|
31
|
+
|
|
32
|
+
// src/fs-utils.ts
|
|
33
|
+
import { stat } from "fs/promises";
|
|
34
|
+
async function fileExists(filePath) {
|
|
35
|
+
try {
|
|
36
|
+
return (await stat(filePath)).isFile();
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function dirExists(dirPath) {
|
|
42
|
+
try {
|
|
43
|
+
return (await stat(dirPath)).isDirectory();
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/manifest.ts
|
|
50
|
+
var require2 = createRequire(import.meta.url);
|
|
51
|
+
var BinaryXmlParser = require2(
|
|
52
|
+
"adbkit-apkreader/lib/apkreader/parser/binaryxml"
|
|
53
|
+
);
|
|
54
|
+
var ANDROID_NS = "http://schemas.android.com/apk/res/android";
|
|
55
|
+
function isBinaryManifest(bytes) {
|
|
56
|
+
return bytes.length >= 4 && bytes[0] === 3 && bytes[1] === 0;
|
|
57
|
+
}
|
|
58
|
+
function attr(node, name) {
|
|
59
|
+
return node.attributes?.find((a) => a.name === name);
|
|
60
|
+
}
|
|
61
|
+
function attrString(node, name) {
|
|
62
|
+
const a = attr(node, name);
|
|
63
|
+
if (!a) return void 0;
|
|
64
|
+
if (a.value != null) return a.value;
|
|
65
|
+
const tv = a.typedValue?.value;
|
|
66
|
+
return tv == null ? void 0 : String(tv);
|
|
67
|
+
}
|
|
68
|
+
function parseBinaryManifest(bytes, dir) {
|
|
69
|
+
const doc = new BinaryXmlParser(bytes).parse();
|
|
70
|
+
const application = doc.childNodes?.find((n) => n.nodeName === "application");
|
|
71
|
+
const info = {
|
|
72
|
+
package: attrString(doc, "package"),
|
|
73
|
+
versionCode: attrString(doc, "versionCode"),
|
|
74
|
+
versionName: attrString(doc, "versionName"),
|
|
75
|
+
appName: application ? attrString(application, "label") : void 0,
|
|
76
|
+
dir
|
|
77
|
+
};
|
|
78
|
+
const xml = `<?xml version="1.0" encoding="utf-8"?>
|
|
79
|
+
${serialize(doc)}
|
|
80
|
+
`;
|
|
81
|
+
return { info, xml };
|
|
82
|
+
}
|
|
83
|
+
function parseManifest(xml, dir) {
|
|
84
|
+
const $ = load(xml, { xml: true });
|
|
85
|
+
return {
|
|
86
|
+
package: $("manifest[package]").attr("package"),
|
|
87
|
+
versionCode: $("manifest").attr("android:versionCode"),
|
|
88
|
+
versionName: $("manifest").attr("android:versionName"),
|
|
89
|
+
appName: $("manifest application").attr("android:label"),
|
|
90
|
+
dir
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function readManifest(apkDir) {
|
|
94
|
+
const manifestPath = path.join(apkDir, "AndroidManifest.xml");
|
|
95
|
+
if (!await fileExists(manifestPath)) return null;
|
|
96
|
+
const bytes = await readFile2(manifestPath);
|
|
97
|
+
if (isBinaryManifest(bytes)) {
|
|
98
|
+
return parseBinaryManifest(bytes, apkDir).info;
|
|
99
|
+
}
|
|
100
|
+
return parseManifest(bytes.toString("utf8"), apkDir);
|
|
101
|
+
}
|
|
102
|
+
function prefixFor(namespaceURI) {
|
|
103
|
+
if (!namespaceURI) return "";
|
|
104
|
+
return namespaceURI === ANDROID_NS ? "android:" : "";
|
|
105
|
+
}
|
|
106
|
+
function escapeXml(value) {
|
|
107
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
108
|
+
}
|
|
109
|
+
function serialize(node, depth = 0) {
|
|
110
|
+
const indent = " ".repeat(depth);
|
|
111
|
+
const attrs = [];
|
|
112
|
+
if (depth === 0) attrs.push(`xmlns:android="${ANDROID_NS}"`);
|
|
113
|
+
for (const a of node.attributes ?? []) {
|
|
114
|
+
const raw = a.value ?? (a.typedValue?.value == null ? "" : String(a.typedValue.value));
|
|
115
|
+
attrs.push(`${prefixFor(a.namespaceURI)}${a.name}="${escapeXml(raw)}"`);
|
|
116
|
+
}
|
|
117
|
+
const attrText = attrs.length ? " " + attrs.join(" ") : "";
|
|
118
|
+
const children = (node.childNodes ?? []).filter((c) => c.nodeType === 1);
|
|
119
|
+
if (children.length === 0) {
|
|
120
|
+
return `${indent}<${node.nodeName}${attrText} />`;
|
|
121
|
+
}
|
|
122
|
+
const inner = children.map((c) => serialize(c, depth + 1)).join("\n");
|
|
123
|
+
return `${indent}<${node.nodeName}${attrText}>
|
|
124
|
+
${inner}
|
|
125
|
+
${indent}</${node.nodeName}>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/apk.ts
|
|
129
|
+
var ASSETS_PREFIX = "assets/Resources/";
|
|
130
|
+
var CLOAK_LIB_SUFFIX = "/libti.cloak.so";
|
|
131
|
+
function isCloakLib(name) {
|
|
132
|
+
return name.startsWith("lib/") && name.endsWith(CLOAK_LIB_SUFFIX);
|
|
133
|
+
}
|
|
134
|
+
async function unpackApk(apkPath, tmpDir, debug = false) {
|
|
135
|
+
if (!await fileExists(apkPath)) {
|
|
136
|
+
throw new Error(`The given APK file doesn't exist: ${apkPath}`);
|
|
137
|
+
}
|
|
138
|
+
if (debug) console.log("preparing -> reading APK entries");
|
|
139
|
+
const entries = await readApkEntries(
|
|
140
|
+
apkPath,
|
|
141
|
+
(f) => f.name === "AndroidManifest.xml" || isDexEntry(f.name) || f.name.startsWith(ASSETS_PREFIX) || isCloakLib(f.name)
|
|
142
|
+
);
|
|
143
|
+
const apkDir = path2.join(process.cwd(), tmpDir) + path2.sep;
|
|
144
|
+
await mkdir(apkDir, { recursive: true });
|
|
145
|
+
const dexBuffers = Object.keys(entries).filter(isDexEntry).sort().map((name) => entries[name]);
|
|
146
|
+
const cloakLibs = Object.keys(entries).filter(isCloakLib).sort().map((name) => Buffer.from(entries[name]));
|
|
147
|
+
let manifest = null;
|
|
148
|
+
const manifestBytes = entries["AndroidManifest.xml"];
|
|
149
|
+
if (manifestBytes) {
|
|
150
|
+
const { info, xml } = parseBinaryManifest(Buffer.from(manifestBytes), apkDir);
|
|
151
|
+
manifest = info;
|
|
152
|
+
await writeFile(path2.join(apkDir, "AndroidManifest.xml"), xml);
|
|
153
|
+
}
|
|
154
|
+
for (const [name, data] of Object.entries(entries)) {
|
|
155
|
+
if (!name.startsWith(ASSETS_PREFIX) || name.endsWith("/")) continue;
|
|
156
|
+
const dest = path2.join(apkDir, name);
|
|
157
|
+
await mkdir(path2.dirname(dest), { recursive: true });
|
|
158
|
+
await writeFile(dest, Buffer.from(data));
|
|
159
|
+
}
|
|
160
|
+
if (debug) console.log("preparing -> ready");
|
|
161
|
+
return { apkDir, manifest, dexBuffers, cloakLibs };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/decrypt.ts
|
|
165
|
+
import { createDecipheriv } from "crypto";
|
|
166
|
+
function decodeJavaInt(literal) {
|
|
167
|
+
let s = literal.trim();
|
|
168
|
+
let sign = 1;
|
|
169
|
+
if (s.startsWith("+")) {
|
|
170
|
+
s = s.slice(1);
|
|
171
|
+
} else if (s.startsWith("-")) {
|
|
172
|
+
sign = -1;
|
|
173
|
+
s = s.slice(1);
|
|
174
|
+
}
|
|
175
|
+
let value;
|
|
176
|
+
if (s.startsWith("0x") || s.startsWith("0X")) {
|
|
177
|
+
value = parseInt(s.slice(2), 16);
|
|
178
|
+
} else if (s.startsWith("#")) {
|
|
179
|
+
value = parseInt(s.slice(1), 16);
|
|
180
|
+
} else if (s.length > 1 && s.startsWith("0")) {
|
|
181
|
+
value = parseInt(s.slice(1), 8);
|
|
182
|
+
} else {
|
|
183
|
+
value = parseInt(s, 10);
|
|
184
|
+
}
|
|
185
|
+
return sign * value;
|
|
186
|
+
}
|
|
187
|
+
function parseRanges(javaContent) {
|
|
188
|
+
const ranges = {};
|
|
189
|
+
const re = /hashMap\.put\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*new Range\(\s*([^,]+?)\s*,\s*([^)]+?)\s*\)\s*\)/g;
|
|
190
|
+
let match;
|
|
191
|
+
while ((match = re.exec(javaContent)) !== null) {
|
|
192
|
+
const file = match[1];
|
|
193
|
+
const offset = match[2];
|
|
194
|
+
const length = match[3];
|
|
195
|
+
if (file === void 0 || offset === void 0 || length === void 0) continue;
|
|
196
|
+
ranges[file] = {
|
|
197
|
+
offset: decodeJavaInt(offset),
|
|
198
|
+
bytes: decodeJavaInt(length)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return ranges;
|
|
202
|
+
}
|
|
203
|
+
function parseAssetBuffer(smaliContent) {
|
|
204
|
+
const lines = smaliContent.split(/\r?\n/);
|
|
205
|
+
let started = false;
|
|
206
|
+
let titaniumVersion = "unknown";
|
|
207
|
+
let bufferLen = -1;
|
|
208
|
+
const chunks = [];
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
if (line.indexOf("private static initAssetsBytes()Ljava/nio/CharBuffer") !== -1) {
|
|
211
|
+
started = true;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!started) continue;
|
|
215
|
+
if (line.indexOf("const v0, ") !== -1) {
|
|
216
|
+
titaniumVersion = "<5";
|
|
217
|
+
bufferLen = decodeJavaInt(line.split("const v0, ").join("").trim());
|
|
218
|
+
} else if (line.indexOf("const/16 v0, ") !== -1) {
|
|
219
|
+
titaniumVersion = "5.x";
|
|
220
|
+
bufferLen = decodeJavaInt(line.split("const/16 v0, ").join("").trim());
|
|
221
|
+
} else if (line.indexOf("const-string v1") !== -1) {
|
|
222
|
+
let content = line.split('const-string v1, "').join("").trim();
|
|
223
|
+
content = content.slice(0, -1);
|
|
224
|
+
chunks.push(content);
|
|
225
|
+
} else if (line.indexOf("rewind()Ljava/nio/Buffer;") !== -1) {
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { titaniumVersion, bufferLen, escaped: chunks.join("") };
|
|
230
|
+
}
|
|
231
|
+
function detectAlloy(files) {
|
|
232
|
+
return Object.keys(files).some(
|
|
233
|
+
(name) => name === "alloy.js" || name.startsWith("alloy/") || name.includes("/alloy/") || name === "_app_props_.json"
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
var KEY_LEN = 16;
|
|
237
|
+
function decryptRange(blob, offset, length) {
|
|
238
|
+
for (const total of [blob.length - 1, blob.length]) {
|
|
239
|
+
if (total - KEY_LEN < 0) continue;
|
|
240
|
+
const key = blob.subarray(total - KEY_LEN, total);
|
|
241
|
+
for (const start of [offset, offset - 1]) {
|
|
242
|
+
if (start < 0 || start + length > blob.length) continue;
|
|
243
|
+
try {
|
|
244
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
245
|
+
decipher.setAutoPadding(true);
|
|
246
|
+
const out = Buffer.concat([
|
|
247
|
+
decipher.update(blob.subarray(start, start + length)),
|
|
248
|
+
decipher.final()
|
|
249
|
+
]);
|
|
250
|
+
const text = out.toString("utf8");
|
|
251
|
+
if (text !== "") return text;
|
|
252
|
+
} catch {
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
function decryptRanges(blob, ranges, debug = false) {
|
|
259
|
+
const files = {};
|
|
260
|
+
let totalBytes = 0;
|
|
261
|
+
for (const range of ranges) {
|
|
262
|
+
const content = decryptRange(blob, range.offset, range.bytes);
|
|
263
|
+
if (content === null) continue;
|
|
264
|
+
files[range.file] = { offset: range.offset, bytes: range.bytes, content };
|
|
265
|
+
totalBytes += range.bytes;
|
|
266
|
+
if (debug) console.log(`file:${range.file}, decrypted !`);
|
|
267
|
+
}
|
|
268
|
+
return { files, totalBytes };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/dex.ts
|
|
272
|
+
import { DexFile } from "libdex-ts";
|
|
273
|
+
var WIDTHS = buildWidthTable();
|
|
274
|
+
function buildWidthTable() {
|
|
275
|
+
const w = new Uint8Array(256).fill(1);
|
|
276
|
+
const set = (from, to, units) => {
|
|
277
|
+
for (let op = from; op <= to; op++) w[op] = units;
|
|
278
|
+
};
|
|
279
|
+
set(2, 3, 2);
|
|
280
|
+
w[3] = 3;
|
|
281
|
+
set(5, 6, 2);
|
|
282
|
+
w[6] = 3;
|
|
283
|
+
set(8, 9, 2);
|
|
284
|
+
w[9] = 3;
|
|
285
|
+
w[19] = 2;
|
|
286
|
+
w[20] = 3;
|
|
287
|
+
w[21] = 2;
|
|
288
|
+
w[22] = 2;
|
|
289
|
+
w[23] = 3;
|
|
290
|
+
w[24] = 5;
|
|
291
|
+
w[25] = 2;
|
|
292
|
+
w[26] = 2;
|
|
293
|
+
w[27] = 3;
|
|
294
|
+
w[28] = 2;
|
|
295
|
+
w[31] = 2;
|
|
296
|
+
w[32] = 2;
|
|
297
|
+
w[34] = 2;
|
|
298
|
+
w[35] = 2;
|
|
299
|
+
w[36] = 3;
|
|
300
|
+
w[37] = 3;
|
|
301
|
+
w[38] = 3;
|
|
302
|
+
w[41] = 2;
|
|
303
|
+
w[42] = 3;
|
|
304
|
+
w[43] = 3;
|
|
305
|
+
w[44] = 3;
|
|
306
|
+
set(45, 49, 2);
|
|
307
|
+
set(50, 55, 2);
|
|
308
|
+
set(56, 61, 2);
|
|
309
|
+
set(68, 81, 2);
|
|
310
|
+
set(82, 95, 2);
|
|
311
|
+
set(96, 109, 2);
|
|
312
|
+
set(110, 114, 3);
|
|
313
|
+
set(116, 120, 3);
|
|
314
|
+
set(144, 175, 2);
|
|
315
|
+
set(208, 215, 2);
|
|
316
|
+
set(216, 226, 2);
|
|
317
|
+
w[250] = 4;
|
|
318
|
+
w[251] = 4;
|
|
319
|
+
w[252] = 3;
|
|
320
|
+
w[253] = 3;
|
|
321
|
+
w[254] = 2;
|
|
322
|
+
w[255] = 2;
|
|
323
|
+
return w;
|
|
324
|
+
}
|
|
325
|
+
function instructionWidth(insns, idx) {
|
|
326
|
+
const unit = insns[idx] ?? 0;
|
|
327
|
+
const op = unit & 255;
|
|
328
|
+
if (op === 0 && unit !== 0) {
|
|
329
|
+
switch (unit) {
|
|
330
|
+
case 256: {
|
|
331
|
+
const size = insns[idx + 1] ?? 0;
|
|
332
|
+
return size * 2 + 4;
|
|
333
|
+
}
|
|
334
|
+
case 512: {
|
|
335
|
+
const size = insns[idx + 1] ?? 0;
|
|
336
|
+
return size * 4 + 2;
|
|
337
|
+
}
|
|
338
|
+
case 768: {
|
|
339
|
+
const elementWidth = insns[idx + 1] ?? 0;
|
|
340
|
+
const size = (insns[idx + 2] ?? 0) + (insns[idx + 3] ?? 0) * 65536;
|
|
341
|
+
return 4 + Math.ceil(size * elementWidth / 2);
|
|
342
|
+
}
|
|
343
|
+
default:
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return WIDTHS[op] ?? 1;
|
|
348
|
+
}
|
|
349
|
+
var s16 = (v) => v > 32767 ? v - 65536 : v;
|
|
350
|
+
function extractStringChunks(dex, insns) {
|
|
351
|
+
const chunks = [];
|
|
352
|
+
let idx = 0;
|
|
353
|
+
while (idx < insns.length) {
|
|
354
|
+
const unit = insns[idx] ?? 0;
|
|
355
|
+
const op = unit & 255;
|
|
356
|
+
if (op === 26) {
|
|
357
|
+
chunks.push(dex.getStringById(insns[idx + 1] ?? 0));
|
|
358
|
+
} else if (op === 27) {
|
|
359
|
+
chunks.push(dex.getStringById((insns[idx + 1] ?? 0) + (insns[idx + 2] ?? 0) * 65536));
|
|
360
|
+
}
|
|
361
|
+
idx += instructionWidth(insns, idx);
|
|
362
|
+
}
|
|
363
|
+
return chunks;
|
|
364
|
+
}
|
|
365
|
+
function extractRanges(dex, insns) {
|
|
366
|
+
const regInt = /* @__PURE__ */ new Map();
|
|
367
|
+
const regStr = /* @__PURE__ */ new Map();
|
|
368
|
+
const pending = /* @__PURE__ */ new Map();
|
|
369
|
+
const ranges = [];
|
|
370
|
+
const resolveInvoke = (methodIdx, args) => {
|
|
371
|
+
const mid = dex.getMethodId(methodIdx);
|
|
372
|
+
const cls = dex.getTypeDescriptorByIdx(mid.classIdx);
|
|
373
|
+
const name = dex.getStringById(mid.nameIdx);
|
|
374
|
+
if (name === "<init>" && /[/$]Range;$/.test(cls) && args.length >= 3) {
|
|
375
|
+
const offset = regInt.get(args[1]);
|
|
376
|
+
const length = regInt.get(args[2]);
|
|
377
|
+
if (offset !== void 0 && length !== void 0) {
|
|
378
|
+
pending.set(args[0], { offset, length });
|
|
379
|
+
}
|
|
380
|
+
} else if (name === "put" && args.length >= 3) {
|
|
381
|
+
const key = regStr.get(args[1]);
|
|
382
|
+
const range = pending.get(args[2]);
|
|
383
|
+
if (key !== void 0 && range) {
|
|
384
|
+
ranges.push({ file: key, offset: range.offset, bytes: range.length });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
let idx = 0;
|
|
389
|
+
while (idx < insns.length) {
|
|
390
|
+
const unit = insns[idx] ?? 0;
|
|
391
|
+
const op = unit & 255;
|
|
392
|
+
switch (op) {
|
|
393
|
+
case 18: {
|
|
394
|
+
const a = unit >> 8 & 15;
|
|
395
|
+
let v = unit >> 12 & 15;
|
|
396
|
+
if (v > 7) v -= 16;
|
|
397
|
+
regInt.set(a, v);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 19: {
|
|
401
|
+
regInt.set(unit >> 8 & 255, s16(insns[idx + 1] ?? 0));
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case 20: {
|
|
405
|
+
const v = (insns[idx + 1] ?? 0) | (insns[idx + 2] ?? 0) << 16 | 0;
|
|
406
|
+
regInt.set(unit >> 8 & 255, v);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case 21: {
|
|
410
|
+
regInt.set(unit >> 8 & 255, (insns[idx + 1] ?? 0) << 16 | 0);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case 26: {
|
|
414
|
+
regStr.set(unit >> 8 & 255, dex.getStringById(insns[idx + 1] ?? 0));
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
case 27: {
|
|
418
|
+
regStr.set(
|
|
419
|
+
unit >> 8 & 255,
|
|
420
|
+
dex.getStringById((insns[idx + 1] ?? 0) + (insns[idx + 2] ?? 0) * 65536)
|
|
421
|
+
);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case 110:
|
|
425
|
+
case 111:
|
|
426
|
+
case 112:
|
|
427
|
+
case 113:
|
|
428
|
+
case 114: {
|
|
429
|
+
const a = unit >> 12 & 15;
|
|
430
|
+
const g = unit >> 8 & 15;
|
|
431
|
+
const methodIdx = insns[idx + 1] ?? 0;
|
|
432
|
+
const regsWord = insns[idx + 2] ?? 0;
|
|
433
|
+
const args = [
|
|
434
|
+
regsWord & 15,
|
|
435
|
+
regsWord >> 4 & 15,
|
|
436
|
+
regsWord >> 8 & 15,
|
|
437
|
+
regsWord >> 12 & 15,
|
|
438
|
+
g
|
|
439
|
+
].slice(0, a);
|
|
440
|
+
resolveInvoke(methodIdx, args);
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
case 116:
|
|
444
|
+
case 117:
|
|
445
|
+
case 118:
|
|
446
|
+
case 119:
|
|
447
|
+
case 120: {
|
|
448
|
+
const count = unit >> 8 & 255;
|
|
449
|
+
const methodIdx = insns[idx + 1] ?? 0;
|
|
450
|
+
const first = insns[idx + 2] ?? 0;
|
|
451
|
+
const args = [];
|
|
452
|
+
for (let i = 0; i < count; i++) args.push(first + i);
|
|
453
|
+
resolveInvoke(methodIdx, args);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
idx += instructionWidth(insns, idx);
|
|
458
|
+
}
|
|
459
|
+
return ranges;
|
|
460
|
+
}
|
|
461
|
+
function detectVersion(insns) {
|
|
462
|
+
let idx = 0;
|
|
463
|
+
while (idx < insns.length) {
|
|
464
|
+
const op = (insns[idx] ?? 0) & 255;
|
|
465
|
+
if (op === 20) return "<5";
|
|
466
|
+
if (op === 19) return "5.x";
|
|
467
|
+
if (op === 26 || op === 27) break;
|
|
468
|
+
idx += instructionWidth(insns, idx);
|
|
469
|
+
}
|
|
470
|
+
return "unknown";
|
|
471
|
+
}
|
|
472
|
+
var s32 = (lo, hi) => lo | hi << 16 | 0;
|
|
473
|
+
function readFillArrayBytes(insns, payloadIdx) {
|
|
474
|
+
if (payloadIdx < 0 || (insns[payloadIdx] ?? 0) !== 768) return null;
|
|
475
|
+
const elementWidth = insns[payloadIdx + 1] ?? 0;
|
|
476
|
+
if (elementWidth !== 1) return null;
|
|
477
|
+
const size = (insns[payloadIdx + 2] ?? 0) + (insns[payloadIdx + 3] ?? 0) * 65536;
|
|
478
|
+
const out = Buffer.alloc(size);
|
|
479
|
+
const dataStart = payloadIdx + 4;
|
|
480
|
+
for (let j = 0; j < size; j++) {
|
|
481
|
+
const unit = insns[dataStart + (j >> 1)] ?? 0;
|
|
482
|
+
out[j] = (j & 1) === 1 ? unit >> 8 & 255 : unit & 255;
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
function extractByteArrayFields(dex, insns) {
|
|
487
|
+
const pending = /* @__PURE__ */ new Map();
|
|
488
|
+
const fields = {};
|
|
489
|
+
let idx = 0;
|
|
490
|
+
while (idx < insns.length) {
|
|
491
|
+
const unit = insns[idx] ?? 0;
|
|
492
|
+
const op = unit & 255;
|
|
493
|
+
if (op === 38) {
|
|
494
|
+
const reg = unit >> 8 & 255;
|
|
495
|
+
const off = s32(insns[idx + 1] ?? 0, insns[idx + 2] ?? 0);
|
|
496
|
+
const bytes = readFillArrayBytes(insns, idx + off);
|
|
497
|
+
if (bytes) pending.set(reg, bytes);
|
|
498
|
+
} else if (op === 105) {
|
|
499
|
+
const reg = unit >> 8 & 255;
|
|
500
|
+
const bytes = pending.get(reg);
|
|
501
|
+
if (bytes) {
|
|
502
|
+
const fieldId = dex.getFieldId(insns[idx + 1] ?? 0);
|
|
503
|
+
fields[dex.getStringById(fieldId.nameIdx)] = bytes;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
idx += instructionWidth(insns, idx);
|
|
507
|
+
}
|
|
508
|
+
return fields;
|
|
509
|
+
}
|
|
510
|
+
function extractCloakSalt(dex, classDef) {
|
|
511
|
+
const clinit = methodCode(dex, classDef, "<clinit>");
|
|
512
|
+
if (!clinit) return null;
|
|
513
|
+
const fields = extractByteArrayFields(dex, clinit.insns);
|
|
514
|
+
if (fields.salt) return fields.salt;
|
|
515
|
+
const sixteen = Object.values(fields).filter((b) => b.length === 16);
|
|
516
|
+
return sixteen.length === 1 ? sixteen[0] : null;
|
|
517
|
+
}
|
|
518
|
+
function findAssetCryptClassDef(dex, packageHint) {
|
|
519
|
+
if (packageHint) {
|
|
520
|
+
const descriptor = `L${packageHint.split(".").join("/")}/AssetCryptImpl;`;
|
|
521
|
+
const byHint = dex.getClassDefByDescriptor(descriptor);
|
|
522
|
+
if (byHint) return byHint;
|
|
523
|
+
}
|
|
524
|
+
for (let i = 0; i < dex.header.classDefsSize; i++) {
|
|
525
|
+
const classDef = dex.getClassDef(i);
|
|
526
|
+
const descriptor = dex.getTypeDescriptorByIdx(classDef.classIdx);
|
|
527
|
+
if (descriptor.endsWith("/AssetCryptImpl;")) return classDef;
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
function methodCode(dex, classDef, methodName) {
|
|
532
|
+
const classData = dex.getClassData(classDef);
|
|
533
|
+
for (const list of [classData.directMethods, classData.virtualMethods]) {
|
|
534
|
+
let methodIdx = 0;
|
|
535
|
+
for (const method of list) {
|
|
536
|
+
methodIdx += method.methodIdx;
|
|
537
|
+
const name = dex.getStringById(dex.getMethodId(methodIdx).nameIdx);
|
|
538
|
+
if (name === methodName) return dex.getDexCode(method);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
function readAssetCrypt(dexBuffers, packageHint) {
|
|
544
|
+
for (const bytes of dexBuffers) {
|
|
545
|
+
let dex;
|
|
546
|
+
try {
|
|
547
|
+
dex = new DexFile(bytes);
|
|
548
|
+
} catch {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const classDef = findAssetCryptClassDef(dex, packageHint);
|
|
552
|
+
if (!classDef) continue;
|
|
553
|
+
const bytesCode = methodCode(dex, classDef, "initAssetsBytes");
|
|
554
|
+
if (!bytesCode) return { kind: "newscheme", salt: extractCloakSalt(dex, classDef) };
|
|
555
|
+
const assetsCode = methodCode(dex, classDef, "initAssets");
|
|
556
|
+
const chunks = extractStringChunks(dex, bytesCode.insns);
|
|
557
|
+
const blob = Buffer.from(chunks.join(""), "latin1");
|
|
558
|
+
const ranges = assetsCode ? extractRanges(dex, assetsCode.insns) : [];
|
|
559
|
+
const titaniumVersion = detectVersion(bytesCode.insns);
|
|
560
|
+
return { kind: "classic", data: { blob, ranges, titaniumVersion } };
|
|
561
|
+
}
|
|
562
|
+
return { kind: "none" };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// src/cloak.ts
|
|
566
|
+
import { createDecipheriv as createDecipheriv2 } from "crypto";
|
|
567
|
+
import { gunzipSync } from "zlib";
|
|
568
|
+
var KEY_LEN2 = 16;
|
|
569
|
+
var BLOCK_BASE = 8200;
|
|
570
|
+
function deriveCloakKey(so, salt) {
|
|
571
|
+
if (salt.length !== KEY_LEN2) return null;
|
|
572
|
+
try {
|
|
573
|
+
if (so.length < BLOCK_BASE + 64) return null;
|
|
574
|
+
const randomOffset = so.readUInt8(BLOCK_BASE + 62);
|
|
575
|
+
const xor = Buffer.concat([
|
|
576
|
+
so.subarray(BLOCK_BASE + 1, BLOCK_BASE + 5),
|
|
577
|
+
so.subarray(BLOCK_BASE + randomOffset, BLOCK_BASE + randomOffset + 4),
|
|
578
|
+
so.subarray(BLOCK_BASE + 15, BLOCK_BASE + 15 + 4),
|
|
579
|
+
so.subarray(BLOCK_BASE + 30, BLOCK_BASE + 30 + 4)
|
|
580
|
+
]);
|
|
581
|
+
if (xor.length < KEY_LEN2) return null;
|
|
582
|
+
const key = Buffer.alloc(KEY_LEN2);
|
|
583
|
+
for (let i = 0; i < KEY_LEN2; i++) key[i] = (salt[i] ?? 0) ^ (xor[i] ?? 0);
|
|
584
|
+
return key;
|
|
585
|
+
} catch {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
function decryptCloakAsset(bin, key, salt) {
|
|
590
|
+
if (key.length !== KEY_LEN2 || salt.length !== KEY_LEN2) return null;
|
|
591
|
+
try {
|
|
592
|
+
const decipher = createDecipheriv2("aes-128-cbc", key, salt);
|
|
593
|
+
decipher.setAutoPadding(true);
|
|
594
|
+
let out = Buffer.concat([decipher.update(bin), decipher.final()]);
|
|
595
|
+
if (out.length >= 2 && out[0] === 31 && out[1] === 139) {
|
|
596
|
+
try {
|
|
597
|
+
out = gunzipSync(out);
|
|
598
|
+
} catch {
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
602
|
+
} catch {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function isProbablyText(buf) {
|
|
607
|
+
if (buf.length === 0) return false;
|
|
608
|
+
const sample = buf.subarray(0, Math.min(buf.length, 512));
|
|
609
|
+
let control = 0;
|
|
610
|
+
for (const b of sample) {
|
|
611
|
+
if (b === 9 || b === 10 || b === 13) continue;
|
|
612
|
+
if (b < 32 || b === 127) control++;
|
|
613
|
+
}
|
|
614
|
+
return control / sample.length < 0.1;
|
|
615
|
+
}
|
|
616
|
+
function pickCloakKey(cloakLibs, salt, sampleBin) {
|
|
617
|
+
for (const so of cloakLibs) {
|
|
618
|
+
const key = deriveCloakKey(so, salt);
|
|
619
|
+
if (!key) continue;
|
|
620
|
+
const decrypted = decryptCloakAsset(sampleBin, key, salt);
|
|
621
|
+
if (decrypted && isProbablyText(decrypted)) return key;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/info.ts
|
|
627
|
+
function buildInfo(input) {
|
|
628
|
+
const { manifest, memorySource, meta, developmentMode } = input;
|
|
629
|
+
const files = Object.entries(memorySource).map(([name, file]) => ({
|
|
630
|
+
name,
|
|
631
|
+
bytes: file.bytes
|
|
632
|
+
}));
|
|
633
|
+
const totalBytes = meta ? meta.totalBytes : files.reduce((sum, f) => sum + (f.bytes || 0), 0);
|
|
634
|
+
return {
|
|
635
|
+
package: manifest?.package,
|
|
636
|
+
versionCode: manifest?.versionCode,
|
|
637
|
+
versionName: manifest?.versionName,
|
|
638
|
+
appName: manifest?.appName,
|
|
639
|
+
dir: manifest?.dir,
|
|
640
|
+
developmentMode,
|
|
641
|
+
titaniumVersion: meta?.titaniumVersion ?? "unknown",
|
|
642
|
+
alloy: meta?.alloy ?? detectAlloy(memorySource),
|
|
643
|
+
files,
|
|
644
|
+
totalBytes
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/reconstruct.ts
|
|
649
|
+
import { randomUUID } from "crypto";
|
|
650
|
+
var RESOURCES_PREFIX = "Resources/";
|
|
651
|
+
function reconstruct(memorySource, info) {
|
|
652
|
+
const remapped = {};
|
|
653
|
+
for (const [relPath, file] of Object.entries(memorySource)) {
|
|
654
|
+
const normalized = relPath.replace(/^[/\\]+/, "").split("\\").join("/");
|
|
655
|
+
const key = normalized.startsWith(RESOURCES_PREFIX) ? normalized : RESOURCES_PREFIX + normalized;
|
|
656
|
+
remapped[key] = file;
|
|
657
|
+
}
|
|
658
|
+
const tiappXml = buildTiappXml(info);
|
|
659
|
+
const tiappFile = {
|
|
660
|
+
offset: 0,
|
|
661
|
+
bytes: Buffer.byteLength(tiappXml, "utf8"),
|
|
662
|
+
content: tiappXml
|
|
663
|
+
};
|
|
664
|
+
remapped["tiapp.xml"] = tiappFile;
|
|
665
|
+
return { memorySource: remapped, tiappXml, alloy: info.alloy, restructured: true };
|
|
666
|
+
}
|
|
667
|
+
function buildTiappXml(info) {
|
|
668
|
+
const id = escapeXml2(info.package || "com.recovered.app");
|
|
669
|
+
const name = escapeXml2(info.appName || info.package || "RecoveredApp");
|
|
670
|
+
const version = escapeXml2(info.versionName || "1.0.0");
|
|
671
|
+
const guid = randomUUID();
|
|
672
|
+
const alloyNote = info.alloy ? "\n <!-- Reconstructed from an Alloy build: files under Resources/ are the\n compiled output. Original app/ sources (views/styles) are not\n recoverable from a distribution APK. -->" : "";
|
|
673
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
674
|
+
<!-- Reconstructed by ti_recover. Review before building. -->
|
|
675
|
+
<ti:app xmlns:ti="http://ti.appcelerator.org">${alloyNote}
|
|
676
|
+
<id>${id}</id>
|
|
677
|
+
<name>${name}</name>
|
|
678
|
+
<version>${version}</version>
|
|
679
|
+
<guid>${guid}</guid>
|
|
680
|
+
<deployment-targets>
|
|
681
|
+
<target device="android">true</target>
|
|
682
|
+
</deployment-targets>
|
|
683
|
+
<android xmlns:android="http://schemas.android.com/apk/res/android"/>
|
|
684
|
+
</ti:app>
|
|
685
|
+
`;
|
|
686
|
+
}
|
|
687
|
+
function escapeXml2(value) {
|
|
688
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/write.ts
|
|
692
|
+
import path3 from "path";
|
|
693
|
+
import { mkdir as mkdir2, writeFile as writeFile2, cp, copyFile } from "fs/promises";
|
|
694
|
+
import jsBeautify from "js-beautify";
|
|
695
|
+
function prettyCode(code) {
|
|
696
|
+
return jsBeautify.js(code, { indent_with_tabs: true });
|
|
697
|
+
}
|
|
698
|
+
async function writeToDisk(memorySource, outDir) {
|
|
699
|
+
const written = [];
|
|
700
|
+
await mkdir2(outDir, { recursive: true });
|
|
701
|
+
for (const [relPath, file] of Object.entries(memorySource)) {
|
|
702
|
+
const target = path3.join(outDir, relPath);
|
|
703
|
+
await mkdir2(path3.dirname(target), { recursive: true });
|
|
704
|
+
let data = file.content;
|
|
705
|
+
if (typeof data === "string" && relPath.endsWith(".js")) {
|
|
706
|
+
data = prettyCode(data);
|
|
707
|
+
}
|
|
708
|
+
await writeFile2(target, data);
|
|
709
|
+
const bytes = typeof data === "string" ? Buffer.byteLength(data, "utf8") : data.length;
|
|
710
|
+
written.push({ name: relPath, bytes });
|
|
711
|
+
}
|
|
712
|
+
return written;
|
|
713
|
+
}
|
|
714
|
+
async function copyAssets(apkDir, outDir, restructured = false) {
|
|
715
|
+
const manifestSrc = path3.join(apkDir, "AndroidManifest.xml");
|
|
716
|
+
if (await fileExists(manifestSrc)) {
|
|
717
|
+
await mkdir2(outDir, { recursive: true });
|
|
718
|
+
await copyFile(manifestSrc, path3.join(outDir, "AndroidManifest.xml"));
|
|
719
|
+
}
|
|
720
|
+
const resourcesSrc = path3.join(apkDir, "assets", "Resources");
|
|
721
|
+
if (await dirExists(resourcesSrc)) {
|
|
722
|
+
const dest = restructured ? path3.join(outDir, "Resources") : outDir;
|
|
723
|
+
await mkdir2(dest, { recursive: true });
|
|
724
|
+
await cp(resourcesSrc, dest, {
|
|
725
|
+
recursive: true,
|
|
726
|
+
filter: (src) => !src.endsWith(".bin")
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/index.ts
|
|
732
|
+
var UnsupportedEncryptionError = class extends Error {
|
|
733
|
+
constructor(reason) {
|
|
734
|
+
super(
|
|
735
|
+
"This APK uses Titanium's newer ti.cloak asset encryption (.bin assets). " + (reason ?? "Its AES key could not be derived from the bundled native library, so the sources could not be recovered.")
|
|
736
|
+
);
|
|
737
|
+
this.name = "UnsupportedEncryptionError";
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
var DEV_SOURCE_EXTENSIONS = [".js", ".json", ".xml", ".tss", ".rjss", ".jss", ".css"];
|
|
741
|
+
var DEFAULT_CONFIG = {
|
|
742
|
+
tmpDir: "_tmp",
|
|
743
|
+
debug: false
|
|
744
|
+
};
|
|
745
|
+
var TiRecover = class {
|
|
746
|
+
cwd = process.cwd();
|
|
747
|
+
config;
|
|
748
|
+
apkDir = "";
|
|
749
|
+
tmpUsed = false;
|
|
750
|
+
manifest = null;
|
|
751
|
+
dexBuffers = [];
|
|
752
|
+
cloakLibs = [];
|
|
753
|
+
assetCrypt = { kind: "none" };
|
|
754
|
+
developmentMode = false;
|
|
755
|
+
tested = false;
|
|
756
|
+
isTitanium = false;
|
|
757
|
+
memorySource = {};
|
|
758
|
+
meta;
|
|
759
|
+
restructured = false;
|
|
760
|
+
constructor(config = {}) {
|
|
761
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
762
|
+
}
|
|
763
|
+
/** Absolute output directory (resolved against cwd for relative paths). */
|
|
764
|
+
get outDir() {
|
|
765
|
+
const out = this.config.outDir ?? "";
|
|
766
|
+
return path4.isAbsolute(out) ? out : path4.resolve(this.cwd, out);
|
|
767
|
+
}
|
|
768
|
+
log(message) {
|
|
769
|
+
if (this.config.debug) console.log(message);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Prepares the working directory. If `apkDir` is supplied it is used directly
|
|
773
|
+
* (reading any `classes*.dex` and manifest found there); otherwise the
|
|
774
|
+
* configured `apk` is unzipped into `tmpDir`.
|
|
775
|
+
*/
|
|
776
|
+
async init() {
|
|
777
|
+
if (this.config.apkDir) {
|
|
778
|
+
this.apkDir = this.config.apkDir.endsWith(path4.sep) ? this.config.apkDir : this.config.apkDir + path4.sep;
|
|
779
|
+
this.dexBuffers = await readDexBuffersFromDir(this.apkDir);
|
|
780
|
+
this.cloakLibs = await readCloakLibsFromDir(this.apkDir);
|
|
781
|
+
this.manifest = await readManifest(this.apkDir);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (!this.config.apk) {
|
|
785
|
+
throw new Error("init() requires either 'apk' or 'apkDir' in the config.");
|
|
786
|
+
}
|
|
787
|
+
const apkResolved = path4.isAbsolute(this.config.apk) ? this.config.apk : path4.resolve(this.cwd, this.config.apk);
|
|
788
|
+
const result = await unpackApk(apkResolved, this.config.tmpDir, this.config.debug);
|
|
789
|
+
this.apkDir = result.apkDir;
|
|
790
|
+
this.manifest = result.manifest;
|
|
791
|
+
this.dexBuffers = result.dexBuffers;
|
|
792
|
+
this.cloakLibs = result.cloakLibs;
|
|
793
|
+
this.tmpUsed = true;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Returns `true` if the prepared APK was built with Titanium (in either
|
|
797
|
+
* distribution or development mode), populating internal detection state.
|
|
798
|
+
*/
|
|
799
|
+
async test() {
|
|
800
|
+
if (!this.apkDir) {
|
|
801
|
+
throw new Error("Call init() before test().");
|
|
802
|
+
}
|
|
803
|
+
if (!this.manifest) {
|
|
804
|
+
this.manifest = await readManifest(this.apkDir);
|
|
805
|
+
}
|
|
806
|
+
this.tested = true;
|
|
807
|
+
this.assetCrypt = readAssetCrypt(this.dexBuffers, this.manifest?.package);
|
|
808
|
+
if (this.assetCrypt.kind !== "none") {
|
|
809
|
+
this.developmentMode = false;
|
|
810
|
+
this.isTitanium = true;
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
813
|
+
const appDev = path4.join(this.apkDir, "assets", "Resources", "app.js");
|
|
814
|
+
if (await fileExists(appDev)) {
|
|
815
|
+
this.developmentMode = true;
|
|
816
|
+
this.isTitanium = true;
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
this.isTitanium = false;
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Extracts recovered sources into memory. For distribution builds this
|
|
824
|
+
* decrypts the AES asset blob (from data lifted out of the DEX); for
|
|
825
|
+
* development builds it reads the plain `assets/Resources` tree.
|
|
826
|
+
*/
|
|
827
|
+
async extract() {
|
|
828
|
+
if (!this.tested) await this.test();
|
|
829
|
+
if (!this.isTitanium) {
|
|
830
|
+
throw new Error("The given APK was not created using Appcelerator Titanium.");
|
|
831
|
+
}
|
|
832
|
+
if (!this.developmentMode) {
|
|
833
|
+
if (this.assetCrypt.kind === "newscheme") {
|
|
834
|
+
const files2 = await this.recoverCloak(this.assetCrypt.salt);
|
|
835
|
+
if (!files2) throw new UnsupportedEncryptionError();
|
|
836
|
+
this.memorySource = files2;
|
|
837
|
+
return files2;
|
|
838
|
+
}
|
|
839
|
+
if (this.assetCrypt.kind !== "classic") {
|
|
840
|
+
throw new Error("No recoverable Titanium asset data was found in the APK.");
|
|
841
|
+
}
|
|
842
|
+
const { blob, ranges, titaniumVersion } = this.assetCrypt.data;
|
|
843
|
+
const { files, totalBytes } = decryptRanges(blob, ranges, this.config.debug);
|
|
844
|
+
this.memorySource = files;
|
|
845
|
+
this.meta = { totalBytes, titaniumVersion, alloy: detectAlloy(files) };
|
|
846
|
+
return files;
|
|
847
|
+
}
|
|
848
|
+
const baseAssets = path4.join(this.apkDir, "assets", "Resources");
|
|
849
|
+
const relFiles = await readSourceFiles(baseAssets);
|
|
850
|
+
const source = {};
|
|
851
|
+
for (const rel of relFiles) {
|
|
852
|
+
const content = await readFile3(path4.join(baseAssets, rel));
|
|
853
|
+
source[rel.split(path4.sep).join("/")] = {
|
|
854
|
+
offset: 0,
|
|
855
|
+
bytes: content.length,
|
|
856
|
+
content
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
this.memorySource = source;
|
|
860
|
+
return source;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Attempts ti.cloak (.bin) recovery: derive the AES key from a bundled
|
|
864
|
+
* `libti.cloak.so` + the `salt` lifted from `AssetCryptImpl`, then AES-128-CBC
|
|
865
|
+
* decrypt every `Resources/*.bin` asset. Returns the recovered sources, or
|
|
866
|
+
* `null` when the salt/native lib is missing or no key validates.
|
|
867
|
+
*/
|
|
868
|
+
async recoverCloak(salt) {
|
|
869
|
+
if (!salt) {
|
|
870
|
+
this.log("ti.cloak-> no salt found in AssetCryptImpl; cannot derive key");
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
if (this.cloakLibs.length === 0) {
|
|
874
|
+
this.log("ti.cloak-> no libti.cloak.so bundled in the APK; cannot derive key");
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
const baseAssets = path4.join(this.apkDir, "assets", "Resources");
|
|
878
|
+
const bins = await readCloakBinFiles(baseAssets);
|
|
879
|
+
if (bins.length === 0) {
|
|
880
|
+
this.log("ti.cloak-> no .bin assets found under assets/Resources");
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
const sample = bins.find((b) => b.rel.endsWith(".js")) ?? bins[0];
|
|
884
|
+
const key = pickCloakKey(this.cloakLibs, salt, await readFile3(sample.abs));
|
|
885
|
+
if (!key) {
|
|
886
|
+
this.log("ti.cloak-> could not derive a valid AES key from libti.cloak.so");
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
const source = {};
|
|
890
|
+
let totalBytes = 0;
|
|
891
|
+
for (const bin of bins) {
|
|
892
|
+
const decrypted = decryptCloakAsset(await readFile3(bin.abs), key, salt);
|
|
893
|
+
if (!decrypted) continue;
|
|
894
|
+
const isText = DEV_SOURCE_EXTENSIONS.includes(path4.extname(bin.rel).toLowerCase());
|
|
895
|
+
source[bin.rel] = {
|
|
896
|
+
offset: 0,
|
|
897
|
+
bytes: decrypted.length,
|
|
898
|
+
content: isText ? decrypted.toString("utf8") : decrypted
|
|
899
|
+
};
|
|
900
|
+
totalBytes += decrypted.length;
|
|
901
|
+
this.log(`ti.cloak-> decrypted ${bin.rel}`);
|
|
902
|
+
}
|
|
903
|
+
if (Object.keys(source).length === 0) return null;
|
|
904
|
+
this.meta = { totalBytes, titaniumVersion: "unknown", alloy: detectAlloy(source) };
|
|
905
|
+
return source;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Returns Titanium metadata for the current APK. Must be called after
|
|
909
|
+
* {@link extract} to include the recovered file list.
|
|
910
|
+
*/
|
|
911
|
+
async info() {
|
|
912
|
+
if (!this.manifest && this.apkDir) {
|
|
913
|
+
this.manifest = await readManifest(this.apkDir);
|
|
914
|
+
}
|
|
915
|
+
return buildInfo({
|
|
916
|
+
manifest: this.manifest,
|
|
917
|
+
memorySource: this.memorySource,
|
|
918
|
+
meta: this.meta,
|
|
919
|
+
developmentMode: this.developmentMode
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Rebuilds the in-memory sources into a Titanium-openable project structure
|
|
924
|
+
* (everything under `Resources/`, plus a synthesised `tiapp.xml`).
|
|
925
|
+
*/
|
|
926
|
+
async reconstruct() {
|
|
927
|
+
if (Object.keys(this.memorySource).length === 0) {
|
|
928
|
+
throw new Error("Call extract() before reconstruct().");
|
|
929
|
+
}
|
|
930
|
+
const info = await this.info();
|
|
931
|
+
const result = reconstruct(this.memorySource, info);
|
|
932
|
+
this.memorySource = result.memorySource;
|
|
933
|
+
this.restructured = result.restructured;
|
|
934
|
+
return this.memorySource;
|
|
935
|
+
}
|
|
936
|
+
/** Writes the in-memory sources to the configured `outDir`. */
|
|
937
|
+
async writeToDisk() {
|
|
938
|
+
if (!this.config.outDir) {
|
|
939
|
+
throw new Error("writeToDisk() requires 'outDir' in the config.");
|
|
940
|
+
}
|
|
941
|
+
if (Object.keys(this.memorySource).length === 0) {
|
|
942
|
+
throw new Error("Call extract() before writeToDisk().");
|
|
943
|
+
}
|
|
944
|
+
const written = await writeToDisk(this.memorySource, this.outDir);
|
|
945
|
+
for (const file of written) {
|
|
946
|
+
const size = file.bytes > 1e3 ? `${Math.round(file.bytes / 1024)} KB` : `${file.bytes} Bytes`;
|
|
947
|
+
this.log(`writeToDisk-> file ${file.name} written (${size}).`);
|
|
948
|
+
}
|
|
949
|
+
return written;
|
|
950
|
+
}
|
|
951
|
+
/** Copies the APK's static assets and manifest into `outDir`. */
|
|
952
|
+
async copyAssets() {
|
|
953
|
+
if (!this.config.outDir) {
|
|
954
|
+
throw new Error("copyAssets() requires 'outDir' in the config.");
|
|
955
|
+
}
|
|
956
|
+
await copyAssets(this.apkDir, this.outDir, this.restructured);
|
|
957
|
+
}
|
|
958
|
+
/** Removes the temporary directory created during {@link init}. */
|
|
959
|
+
async clean() {
|
|
960
|
+
if (!this.tmpUsed || !this.apkDir) return;
|
|
961
|
+
try {
|
|
962
|
+
await rm(this.apkDir, { recursive: true, force: true });
|
|
963
|
+
this.log("clean->ok");
|
|
964
|
+
} catch {
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
async function recover(options) {
|
|
969
|
+
const { reconstruct: reconstruct2 = false, clean = true, ...config } = options;
|
|
970
|
+
const ti = new TiRecover(config);
|
|
971
|
+
try {
|
|
972
|
+
await ti.init();
|
|
973
|
+
if (!await ti.test()) {
|
|
974
|
+
return { recovered: false };
|
|
975
|
+
}
|
|
976
|
+
await ti.extract();
|
|
977
|
+
const info = await ti.info();
|
|
978
|
+
if (reconstruct2) {
|
|
979
|
+
await ti.reconstruct();
|
|
980
|
+
}
|
|
981
|
+
const files = await ti.writeToDisk();
|
|
982
|
+
await ti.copyAssets();
|
|
983
|
+
return { recovered: true, info, files };
|
|
984
|
+
} finally {
|
|
985
|
+
if (clean) await ti.clean();
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async function readDexBuffersFromDir(dir) {
|
|
989
|
+
if (!await dirExists(dir)) return [];
|
|
990
|
+
const names = (await readdir(dir)).filter(isDexEntry).sort();
|
|
991
|
+
const buffers = [];
|
|
992
|
+
for (const name of names) {
|
|
993
|
+
buffers.push(await readFile3(path4.join(dir, name)));
|
|
994
|
+
}
|
|
995
|
+
return buffers;
|
|
996
|
+
}
|
|
997
|
+
async function readCloakLibsFromDir(dir) {
|
|
998
|
+
const libDir = path4.join(dir, "lib");
|
|
999
|
+
if (!await dirExists(libDir)) return [];
|
|
1000
|
+
const entries = await readdir(libDir, { recursive: true, withFileTypes: true });
|
|
1001
|
+
const libs = [];
|
|
1002
|
+
for (const entry of entries) {
|
|
1003
|
+
if (!entry.isFile() || entry.name !== "libti.cloak.so") continue;
|
|
1004
|
+
const parent = entry.parentPath ?? entry.path ?? libDir;
|
|
1005
|
+
libs.push(await readFile3(path4.join(parent, entry.name)));
|
|
1006
|
+
}
|
|
1007
|
+
return libs;
|
|
1008
|
+
}
|
|
1009
|
+
async function readCloakBinFiles(base) {
|
|
1010
|
+
if (!await dirExists(base)) return [];
|
|
1011
|
+
const entries = await readdir(base, { recursive: true, withFileTypes: true });
|
|
1012
|
+
const bins = [];
|
|
1013
|
+
for (const entry of entries) {
|
|
1014
|
+
if (!entry.isFile() || !entry.name.endsWith(".bin")) continue;
|
|
1015
|
+
const parent = entry.parentPath ?? entry.path ?? base;
|
|
1016
|
+
const abs = path4.join(parent, entry.name);
|
|
1017
|
+
const rel = path4.relative(base, abs).split(path4.sep).join("/").replace(/\.bin$/, "");
|
|
1018
|
+
bins.push({ rel, abs });
|
|
1019
|
+
}
|
|
1020
|
+
return bins;
|
|
1021
|
+
}
|
|
1022
|
+
async function readSourceFiles(base) {
|
|
1023
|
+
if (!await dirExists(base)) return [];
|
|
1024
|
+
const entries = await readdir(base, { recursive: true, withFileTypes: true });
|
|
1025
|
+
const files = [];
|
|
1026
|
+
for (const entry of entries) {
|
|
1027
|
+
if (!entry.isFile()) continue;
|
|
1028
|
+
const ext = path4.extname(entry.name).toLowerCase();
|
|
1029
|
+
if (!DEV_SOURCE_EXTENSIONS.includes(ext)) continue;
|
|
1030
|
+
const parentPath = entry.parentPath ?? entry.path ?? base;
|
|
1031
|
+
const abs = path4.join(parentPath, entry.name);
|
|
1032
|
+
files.push(path4.relative(base, abs));
|
|
1033
|
+
}
|
|
1034
|
+
return files;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
export {
|
|
1038
|
+
parseBinaryManifest,
|
|
1039
|
+
parseManifest,
|
|
1040
|
+
decodeJavaInt,
|
|
1041
|
+
parseRanges,
|
|
1042
|
+
parseAssetBuffer,
|
|
1043
|
+
detectAlloy,
|
|
1044
|
+
decryptRange,
|
|
1045
|
+
decryptRanges,
|
|
1046
|
+
instructionWidth,
|
|
1047
|
+
extractStringChunks,
|
|
1048
|
+
extractRanges,
|
|
1049
|
+
extractByteArrayFields,
|
|
1050
|
+
extractCloakSalt,
|
|
1051
|
+
readAssetCrypt,
|
|
1052
|
+
deriveCloakKey,
|
|
1053
|
+
decryptCloakAsset,
|
|
1054
|
+
isProbablyText,
|
|
1055
|
+
pickCloakKey,
|
|
1056
|
+
buildInfo,
|
|
1057
|
+
reconstruct,
|
|
1058
|
+
buildTiappXml,
|
|
1059
|
+
UnsupportedEncryptionError,
|
|
1060
|
+
TiRecover,
|
|
1061
|
+
recover
|
|
1062
|
+
};
|
|
1063
|
+
//# sourceMappingURL=chunk-ML2DCRT6.js.map
|