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