背景:反编译一款APP手游,由cocos2dx开发,解密出lua脚本及资源素材。
备注:如果还没有环境的,请出门转到:小白反编译某手游(一):工具&环境配置篇
一、 IDA 静态分析
要注意下手机的架构,可以在CMD下输入:adb shell getprop ro.product.cpu.abi 查看自己是什么架构。
我这次的机器是小米9,Android 11,架构 arm64。因此在进行IDA静态分析的时候从arm64-v8a文件夹内找到libcocos2dlua.so。
将libcocos2dlua.so文件拖到IDA中,进行解析,这里解析需要几十分钟,耐心等待他解析完成。
背景中我有提到,我这次要主要解密的就是lua脚本和资源素材。因此在等待IDA解析的时间里,在assets文件里,随便找几个图片素材和lua脚本丢进UltraEdit里面看看。
通过上面几个文件可以发现,lua脚本都是MQKK开头,png都是DDD3开头,那么初步怀疑都是XXTEA加密的。(这里大家可以注意下,png除了DDD3开头,可以明显看到前段部分还有PKM 20字样,我自己当时分析的时候没有留意,也以为它是XXTEA加密的,其实它不是,当时我自己绕了好大圈,如果是XXTEA加密的,从固定的头往后就应该都会比较乱码的内容)
XXTEA的解密核心就是要找到它的SIGN和KEY,SIGN就是文件开头的内容,我们这里lua脚本的就是MQKK。下面核心就是要把他的KEY给想办法搞出来。
OK,那接着去IDA加载好的so,在IDA VIEW-A标签页下,去搜索xxtea。
搜索完成后,我们可以看到这几个都疑似可以跟踪下,但既然看到了setXXTEAKeyAndSigan,那肯定还是优先HOOK它。
双击就会将他的代码段定位到IDA VIEW-A标签页下对应的位置,然后这个就是它的函数名,一会HOOK,就HOOK这个函数名。
二、开始 FRIDA HOOK
在小白反编译某手游(一)中,当时我就建立了专门用来放HOOK脚本的位置在D:\HOOK,这里我就继续用该文件夹了。
在该文件夹中创建hook.js文件,通过VSCODE打开。
写入代码:
function hookThirdPartyLib() {
const moduleName = 'libcocos2dlua.so';
const lib = Process.findModuleByName(moduleName);
if (!lib) {
console.log(`Waiting for ${moduleName} to be loaded...`);
const intervalId = setInterval(() =\> {
const lib = Process.findModuleByName(moduleName);
if (lib) {
clearInterval(intervalId);
console.log(`${moduleName} was loaded!`);
hookLib(lib);
}
}, 1000);
} else {
console.log(`${moduleName} was already loaded!`);
hookLib(lib);
}
}
//hookThirdPartyLib()前面的代码主要是用来等待libcocos2dlua.so成功加载,要等他成功加载后再去运行hookLib()函数,否则会出现找不到你要HOOK的函数的情况
function hookLib(lib) {
const moduleName = 'libcocos2dlua.so';
const setXXTEAKeyAndSignAddr = Module.findExportByName("libcocos2dlua.so", "\_ZN7cocos2d8LuaStack18setXXTEAKeyAndSignEPKciS2\_i");
Interceptor.attach(setXXTEAKeyAndSignAddr, {
onEnter: function(args) {
const luaStack = this.context.rdi;
const key = args[1].readUtf8String(args[2].toInt32());
const sign = args[3].readUtf8String(args[4].toInt32());
console.log(`setXXTEAKeyAndSign was called:`);
console.log(`\tluaStack: ${luaStack}`);
console.log(`\tkey: ${key}`);
console.log(`\tsign: ${sign}`);
},
onLeave: function(retval) {}
});
}
hookThirdPartyLib();
保存代码后,在VSCODE开启两个CMD终端。
第一个终端用来运行FRIDA的服务端及配置转发端口:
>>adb forward tcp:27042 tcp:27042
>>adb forward tcp:27043 tcp:27043
>>adb shell
>>su
>>cd /data/local/tmp
>>./frida-server-16.1.4-android-arm64 //输入完该条指令后,没有返回任何提示表示OK,并且不要关掉该终端
在手机上打开一次你要HOOK的程序,接着在第二个终端运行:
>>cd D:\HOOK
>>frida -U -f 包名 -l hook.js //把包名自己替换成你HOOK的包名
这时候你手机上的程序会重启一次。接着你就可以惊喜的发现已经HOOK到KEY了。
OK,此时把SIGN和KEY填入XXTEA解密器,去进行批量解密就好了。
解密后,双击解开后的文件,原则上解密完成后应该看到的就是明文代码了,但是我的文件打开后依然还是乱码。
这里看到文件其实确实已经解密了XXTEA了,但是依然乱码是因为做了LUAJIT,从这个文件的LJ头被识别出来。
那么就用LUAJIT解密工具再解一次即可。将XXTEA解密后的文件拖到LUAJIT解密工具上。类似这样操作:
OK,成功解密,再打开解密后的文件查看下,已经成功明文。
至此,lua文件已经成功完成破解。
三、说说这次 PNG 解密那些事
非常惭愧,由于代码能力不足,以至于我虽然已经明确知道了他的加密方式,但是我依然没有能够自己写出破解脚本,但通过FRIDA HOOK,也依然将资源成功DUMP出来。我接下来会把历程都大概说下。
先说说关于资源这块从静态分析的历程吧。
首先,cocos2dx的图片资源都会用到initWithImageData函数处理,那么先从IDA搜索到这个函数(这里大家应该已经学会怎么用IDA搜索了,就不再详述了。)
其次,如果图片资源有被加密或者啥的,会从detectFormat函数去识别一下。那么我们跟进去。
因为apk里面解出来的资源都是png后缀的,所以我当时就想着去isPng函数看下。
从这个函数可以发现,他去了某个内存地址取值做了一个判断,那跟进去看下这个unk_1BE3D20。
进去后发现这里是在判断文件头来识别这是一个什么文件,进而程序就针对性的去对这类文件做处理。
在这里我们可以看到,判断文件头的几个内存地址都在这附近,那我们就上下看看有没有我们PNG文件的那个文件头呢。(大家还记得我上面说的咋们PNG的文件头么?DDD3)
嘿,一不小心在这附近就找到了,可以看出是isLZ4ETC2函数去判断了他。
OK,那这明细了就跟进这个函数就好了。找到这个函数伪代码。
接着在这段伪代码中选中这个函数名,按键盘X键,可以看看哪个函数调用了它。
点它继续跟进,发现回到了detectFormat函数。
在这里可以看出文件被判断为LZ4ETC2的话就会返回11,继续X跟进detectFormat是被谁调用了。
发现最后重新回到了initWithImageData函数,至此,我们来回顾下,我们刚才已知DDD3的头就是被isLZ4ETC2去判断了,说明DDD3就是所谓的LZ4ETC2格式文件,随着跟进也发现,如果是LZ4ETC2文件会返回11给到上一层函数的调用者,那么在上一层函数initWithImageData中,他会跳到initWithLZ4ETC2Data去执行操作。
跟进去这个函数看后,我们可以看到它就是DDD3文件头的解密。
从这段代码也可以阅读到,它是用了LZ4+ETC2压缩的,但根据代码情况看应该是做了适当改动的,不是原生的LZ4压缩,我也尝试了直接用公开的LZ4依赖库去尝试解压,但失败了。这里也就是我比较惭愧的地方,代码能力还不足,自己没能直接写出解压脚本。
既然自己写不出解压脚本,我就换了思路,直接去FRIDA里面把解压后的都给DUMP出来。说干就干。
这里我发现,它图片经过前期的LZ4解压后,最后还是要再过了initWithETC2Data函数去处理一次ETC2的解压,那我就跟进去。
这里我发现图片在sub_158FADC会去进行一次判断,那进去看看它判断了啥。
可以显然看到它在识别文件是不是PKM 20的文件,这不正好就是我们之前PNG文件中看到的其中一个压缩方式么。PKM文件是可以通过TexturePacker工具直接打开的。那我就把PKM头的文件都DUMP出来就好了。
接着开始写HOOK的脚本,因为它是一个自定义的函数,因此咋们在HOOK这个函数的时候不能直接用sub_158FADC这个名字去HOOK,而应该用内存地址。它的偏移地址就是我上图红框出来的地方。
因此我编译了如下脚本去DUMP图片素材。
function hookThirdPartyLib() {
const moduleName = 'libcocos2dlua.so';
const lib = Process.findModuleByName(moduleName);
if (!lib) {
console.log(`Waiting for ${moduleName} to be loaded...`);
const intervalId = setInterval(() =\> {
const lib = Process.findModuleByName(moduleName);
if (lib) {
clearInterval(intervalId);
console.log(`${moduleName} was loaded!`);
hookLib(lib);
}
}, 1000);
} else {
console.log(`${moduleName} was already loaded!`);
hookLib(lib);
}
}
function hookLib(lib) {
const moduleName = 'libcocos2dlua.so';
let imgCount = 0;
const soAddr = Module.findBaseAddress("libcocos2dlua.so");
const sub\_158FADCAddr = soAddr.add(0x158FADC);
console.log(sub\_158FADCAddr);
Interceptor.attach(sub\_158FADCAddr, {
onEnter: function(args) {
const imgData = args[0];
const imgDateLen = args[2].toInt32();
const imgDataValue = new Uint8Array(Memory.readByteArray(imgData, 3));
const imgDataAll = new Uint8Array(Memory.readByteArray(imgData,imgDateLen));
const imgDataStr = String.fromCharCode.apply(null, imgData);
console.log(`sub_158FADC was called:`);
console.log(`\timgDataValue: ${imgDataValue}`);
console.log(`\timgDateLen: ${imgDateLen}`);
console.log(`\timgDataValue1: ${String.fromCharCode.apply(null, imgDataValue)}`);
if (imgDataValue=="80,75,77") { // 如果开头是 PKM,则将完整数据存到本地文件
imgCount++;
const fileName = "image" + imgCount + ".pkm";
const file = new File("/storage/emulated/0/Download/dump/" + fileName, "wb"); //这里请特别注意,必须保证你的手机该位置的文件夹已经创建好了,否则会报错
console.log(`ok`);
file.write(imgDataAll);
file.flush();
file.close();
}
},
onLeave: function(retval) {}
});
}
hookThirdPartyLib();
以上是我的代码,请根据你自己要抓的函数的内存地址去修改。
接着,用FRIDA运行这个脚本去HOOK就好了,然后在游戏中图片在被加载时就会被DUMP出来了,DUMP出来的文件被存在手机的Download/dump/中。(这个脚本还有一点不够完善的地方,懒得改了,大家可以自己去改下。就是这个脚本我的图片命名都是数字递增的方式去命名的,但我们加载过程中很多资源会重复加载,这个程序就会重复DUMP相同资源,另外就是这个命名会导致你对应不上我具体解密了那些文件不是很清楚。其实可以结合读取文件的函数来协助做这个命名,小伙伴们可以自己动手改改)