一个动态SO文件自解密的修复


一个动态SO文件自解密的修复

题目来源
文章中已经有非常详细的解析,这边我写一下自己操作的过程,并探索了几个自己感兴趣的地方。

Java层分析

  先安装apk看看情况

app_main

  很简单,只有一个输入框和按钮,输入正确的flag即可。
把apk拖到jadx发现是360加壳,直接用frida-dexdump脱壳。

dumpdex

  虽然没法还原被抽取的指令但是因为这个app很简单所以已经足够分析了。只有一个来自native的test函数,应该就是将输入框里的字符串传给native和flag进行对比,可以使用objection hook这个函数然后手动输入几次测试一下,图就不贴了。

Native层初步分析

  首先直接在导出函数找test函数,发现并没有,应该是用了动态注册,看看JNI_Onload函数。

JNI_Onload

  非常简单,就是将某个Java层的函数动态注册到ooxx函数,不过这里的unk_1C070unk_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

init_array

  只有一个函数,进入这个函数看看,很明显是一个字符串解密函数。这边可以参考这个函数的逻辑来编写脚本完成字符串解密,但是太麻烦了,我选择将解密完成后的字符串dump出来的方式。观察解密函数可以知道加密后的字符串是存在data段的,所以在ida中找到data段的偏移和大小,然后想办法dump即可。
  这边我使用懒人工具objection进行dump

memory dump from_base 0x1C000 372 ***your_path***/byte_1C180

dump_str

  可以看到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段kaitou,选择脚本然后选择dump出来的文件即可完成patch。完成后就能看到字符串的内容啦!

recover_str

  这边可以验证一下这样patch是否正确,一个简单的方式就是把SO放到apk里让他跑一跑,看看结果正不正确,会不会crash。当然现在直接将SO放进去肯定是不对的,因为在SO加载时会对我们已经解密的字符串在进行一次解密,得到的结果肯定是不对的,所以要想办法跳过解密函数的执行。

  看到这里可能有人觉得没必要浪费时间。虽然我确实已经知道这题的答案了,但是学习嘛,多折腾折腾哈哈。

  个简单的思路就是让解密函数直接return,先简单看看它的代码方便找一个比较好的patch点。

cfg

  就是一条路执行到头,所以patch的思路是直接让他跳转到最后一个代码块。第一个代码块里已经有一个跳转指令B loc_9A48了,所以我们就改它吧。最后在0x9C02有一个跳转到结尾块的指令B loc_9C04但是我们不能直接复制,因为跳转指令是根据当前地址与目标地址的偏移决定的,即这个指令的作用是向下跳转”目标地址-当前地址”,而不是跳转至目标地址,虽然助记符看起来是后者,但是实际上不是,算是个小坑吧。

  这边我们可以直接跳转到0x9C04也可以跳转到0x9C02,结果都一样,我选择后者,就是玩儿~

  跳转指令的计算用我友链里的ARM Converter

armconvert

得到结果后patch到0x9A5E就行,看看结果

patch_decode

完美

  然后要想办法让APP加载patch后的SO文件。经典做法是重打包,不过这个apk加壳了可能没那么容易重打包。还有一个方法是找到app的安装目录,替换里面的SO文件。

  找安装目录的方法有很多,比如使用objection

env

  也可以使用

pm list package -f | grep ****

  总之找到以后去lib/arm目录下替换原来的SO,然后chmod 777,否则运行不起来。不出意外应该是能正常工作,说明字符串解密的patch没问题。

函数解密

  字符串部分的解密其实可有可无,只不过我想动手试试看罢了,接下来继续分析这个ooxx函数,发现是这样的

ooxx

  看它的汇编代码发现一堆垃圾指令MOV还有下面一大块乱码,应该是加密了,接下来分析一下这个过程。

  根据ooxx的汇编和伪代码可以看到它首先调用了sub_8930估计是用来解密自己的。看看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出来两个文件dump1dump11storage/emulated/0/Android/data/com.kanxue.test/cache/目录下,说明解密函数执行了两次,推测是在ooxx刚开始执行的时候进行解密,执行完成后又把自己加密回去了,这样的话简单的dump so是没有用的。这边dump1是解密后的函数,而dump11是解密前的函数,对比两个dump文件可以找到两者不同的地方,也就是ooxx函数被加解密的地方,可以手动用010Editor将解密后的函数patch到SO文件中,也可以使用上边已经用过一次的patch脚本进行patch。完成后

decoded_ooxx

  可以看到果然解密函数执行了两次。最终的flag也是很简单,就只是和kanxuetest进行了一下字符串匹配而已。同样的我们要验证一下这样patch是否可行,我们将解密函数的调用给NOP掉即可,然后参照上面的流程把SO拷贝过去,运行成功。

总结

  总体而言这个题目还是非常简单的,主要是想了解一下SO的加密解密过程,以及熟悉一下dump和patch的操作,收获还蛮多的,之后还会对这个题目中涉及的ELF相关的只是进行学习和补充。


文章作者: 大A
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 大A !
评论
  目录