题目来源 文章中已经有非常详细的解析,这边我写一下自己操作的过程,并探索了几个自己感兴趣的地方。
先安装apk看看情况
很简单,只有一个输入框和按钮,输入正确的flag即可。 把apk拖到jadx发现是360加壳,直接用frida-dexdump脱壳。
虽然没法还原被抽取的指令但是因为这个app很简单所以已经足够分析了。只有一个来自native的test函数,应该就是将输入框里的字符串传给native和flag进行对比,可以使用objection hook这个函数然后手动输入几次测试一下,图就不贴了。
首先直接在导出函数找test函数,发现并没有,应该是用了动态注册,看看JNI_Onload
函数。
非常简单,就是将某个Java层的函数动态注册到ooxx函数,不过这里的unk_1C070
和unk_1C066
都是乱码,应该是加密了,不清楚它将哪个函数绑定到ooxx了,虽然很容易猜到就是xxoo函数,但是还是要研究一下。这边也可以直接使用现成的轮子hook_RegisterNatives来打印动态注册的具体地址,github上一堆。不过这边没必要花这么大功夫搞这个,我其实更关心字符串解密的部分。
function hook_RegisterNatives() {
var symbols = Module.enumerateSymbolsSync("libart.so");
var addrRegisterNatives = null;
for (var i = 0; i < symbols.length; i++) {
var symbol = symbols[i];
//_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi
if (symbol.name.indexOf("art") >= 0 &&
symbol.name.indexOf("JNI") >= 0 &&
symbol.name.indexOf("RegisterNatives") >= 0 &&
symbol.name.indexOf("CheckJNI") < 0) {
addrRegisterNatives = symbol.address;
console.log("RegisterNatives is at ", symbol.address, symbol.name);
}
}
if (addrRegisterNatives != null) {
Interceptor.attach(addrRegisterNatives, {
onEnter: function (args) {
console.log("[RegisterNatives] method_count:", args[3]);
var env = args[0];
var java_class = args[1];
var class_name = Java.vm.tryGetEnv().getClassName(java_class);
var methods_ptr = ptr(args[2]);
var method_count = parseInt(args[3]);
for (var i = 0; i < method_count; i++) {
var name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
var sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
var fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));
var name = Memory.readCString(name_ptr);
var sig = Memory.readCString(sig_ptr);
var find_module = Process.findModuleByAddress(fnPtr_ptr);
console.log("[RegisterNatives] java_class:", class_name, "name:", name, "sig:", sig, "fnPtr:", fnPtr_ptr, "module_name:", find_module.name, "module_base:", find_module.base, "offset:", ptr(fnPtr_ptr).sub(find_module.base));
}
}
});
}
}
setImmediate(hook_RegisterNatives);
可以知道,在JNI_Onload函数执行之前应该就已经完成了字符串解密的工作,所以字符串解密应该是在init函数中完成的。按shift+F7
找到.init_array
只有一个函数,进入这个函数看看,很明显是一个字符串解密函数。这边可以参考这个函数的逻辑来编写脚本完成字符串解密,但是太麻烦了,我选择将解密完成后的字符串dump出来的方式。观察解密函数可以知道加密后的字符串是存在data段的,所以在ida中找到data段的偏移和大小,然后想办法dump即可。 这边我使用懒人工具objection进行dump
memory dump from_base 0x1C000 372 ***your_path***/byte_1C180
可以看到dump出来的字符串了。然后我们要将这个dump的内容覆盖掉原来SO中的data段,写个脚本方便以后也能用上
import os
def selectFile():
selectedFile = ida_kernwin.ask_file(0, "*.*", "请选择文件")
if not selectedFile:
print("not select file")
else:
print(selectedFile)
return selectedFile
return None
# 可手动设置patch地址
def setPatchAddr():
addr = ida_kernwin.ask_str("0x0000", 1, "请输入patch起始处地址")
if not addr:
print("stop patch")
else:
try:
addrValue = eval(addr)
return addrValue
except Exception:
print("请输入正确的地址!")
return -1
def readFile(path):
if not os.path.exists(path):
print("读取文件失败,文件不存在!")
else:
mFlie = open(path,'rb')
result = mFlie.read(-1)
mFlie.close()
print("读取文件成功!")
return result
return None
if __name__ == '__main__':
mFile = selectFile()
if mFile:
bytes = readFile(mFile)
if bytes:
ida_bytes.patch_bytes(idc.get_screen_ea(),bytes)
直接在ida中将光标放到data段开头,选择脚本然后选择dump出来的文件即可完成patch。完成后就能看到字符串的内容啦!
这边可以验证一下这样patch是否正确,一个简单的方式就是把SO放到apk里让他跑一跑,看看结果正不正确,会不会crash。当然现在直接将SO放进去肯定是不对的,因为在SO加载时会对我们已经解密的字符串在进行一次解密,得到的结果肯定是不对的,所以要想办法跳过解密函数的执行。
一个简单的思路就是让解密函数直接return,先简单看看它的代码方便找一个比较好的patch点。
就是一条路执行到头,所以patch的思路是直接让他跳转到最后一个代码块。第一个代码块里已经有一个跳转指令B loc_9A48
了,所以我们就改它吧。最后在0x9C02
有一个跳转到结尾块的指令B loc_9C04
但是我们不能直接复制,因为跳转指令是根据当前地址与目标地址的偏移决定的,即这个指令的作用是向下跳转”目标地址-当前地址”,而不是跳转至目标地址,虽然助记符看起来是后者,但是实际上不是,算是个小坑吧。
这边我们可以直接跳转到0x9C04
也可以跳转到0x9C02
,结果都一样,我选择后者,就是玩儿~
跳转指令的计算用我友链里的ARM Converter
得到结果后patch到0x9A5E
就行,看看结果
完美
然后要想办法让APP加载patch后的SO文件。经典做法是重打包,不过这个apk加壳了可能没那么容易重打包。还有一个方法是找到app的安装目录,替换里面的SO文件。
找安装目录的方法有很多,比如使用objection
也可以使用
pm list package -f | grep ****
总之找到以后去lib/arm
目录下替换原来的SO,然后chmod 777,否则运行不起来。不出意外应该是能正常工作,说明字符串解密的patch没问题。
字符串部分的解密其实可有可无,只不过我想动手试试看罢了,接下来继续分析这个ooxx
函数,发现是这样的
看它的汇编代码发现一堆垃圾指令MOV还有下面一大块乱码,应该是加密了,接下来分析一下这个过程。
根据ooxx
的汇编和伪代码可以看到它首先调用了sub_8930
估计是用来解密自己的。看看sub_8930
还是比较简单的一个函数,其中找ooxx
函数偏移的部分可能需要理解一下,涉及到ELF文件的格式解析,这里暂时不提。
老方法,我还是比较喜欢dump,我希望能把解密后的函数dump出来。这里可以看到解密开始前和解密完成后都会调用一次mprotect
来修改内存的访问权限,其中第一次修改内存可写,方便解密函数,解密完成后将权限修改回去。可以使用IDA进行动态调试,在第二个mprotect
处下断点然后dump,也可以使用frida来hook mprotect
函数,在调用的第三个参数为5时(修改内存为只读,即第二次调用的时侯)dump ooxx
函数所在的内存。人比较懒,直接用frida-trace来trace mprotect
函数,然后修改生成的js文件如下:
onEnter(log, args, state) {
log('mprotect(' + args[0] + ',' + args[1] + ',' + args[2] +')');
if(args[2].toInt32() == 5){
var jFile = Java.use('java.io.File')
var file_path = "/storage/emulated/0/Android/data/com.kanxue.test/cache/dump1"
var tmp = jFile.$new(file_path)
if(tmp.exists())
file_path += "1"
var file_handle = new File(file_path, "wb+");
var libso_buffer = args[0].readByteArray(args[1].toInt32())
file_handle.write(libso_buffer);
}
}
然后手动点一下app的按钮触发ooxx,发现dump出来两个文件dump1
和dump11
在storage/emulated/0/Android/data/com.kanxue.test/cache/
目录下,说明解密函数执行了两次,推测是在ooxx
刚开始执行的时候进行解密,执行完成后又把自己加密回去了,这样的话简单的dump so是没有用的。这边dump1
是解密后的函数,而dump11
是解密前的函数,对比两个dump文件可以找到两者不同的地方,也就是ooxx函数被加解密的地方,可以手动用010Editor将解密后的函数patch到SO文件中,也可以使用上边已经用过一次的patch脚本进行patch。完成后
可以看到果然解密函数执行了两次。最终的flag也是很简单,就只是和kanxuetest
进行了一下字符串匹配而已。同样的我们要验证一下这样patch是否可行,我们将解密函数的调用给NOP掉即可,然后参照上面的流程把SO拷贝过去,运行成功。
总体而言这个题目还是非常简单的,主要是想了解一下SO中函数的加密解密过程,以及熟悉一下dump和patch的操作。