某游戏so加固思路分析
反调试
frida hook strstr
发现会对一些敏感的字符串进行判断,将其中涉及到反调试的进行过滤直接返回null
。
xposed
substrate
XposedBridge.jar
/data/local/tmp
TracerPid:
State:
android_server
gdb
lldb
test-keys
dkplugin
com.qgwapp.shadowside
libtopa1024.so
com.svm.proteinbox
libpbclient.so
因为libil2cpp.so
加壳了,要想脱壳需要在so之前进行附加调试,但是发现使用am start -D -n
无法暂停app。
-
这里测试在
android 10
上可以先利用frida 以pause
模式启动后再用ida
附加进行调试,这样即可用frida
hook过掉反调试,又可以在libil2cpp.so
加载之前进行附加。 -
但是使用高版本的
Frida 16.1.4
在android 13上以pause
模式启动app后frida会闪退,这里采用另一个思路,hooklinker64
的call_constructors
,当判断到libil2cpp.so
被加载的时候用frida patchlibil2cpp.so
的.init_array
中的第一个函数开头为0x14000000
,这是一条死循环指令,这时候用ida附加后将patch的指令修正后即可正常调试。(对于call_constructors
函数如何得到so路径,可以通过反编译linker64
查看)
var linker_module = Module.getBaseAddress("linker64");
// hook call_constructors
Interceptor.attach(linker_module.add(0x4EDE0), {
onEnter:function(args){
var readpath
var flag = args[0].add(416).readU8()
if(flag & 1){
readpath = args[0].add(432).readPointer()
}
else{
readpath = args[0].add(417)
}
if(readpath.readCString().indexOf("libil2cpp.so") != -1){
// patch .init_array 的第一个函数头部
Memory.protect(Module.findBaseAddress("libil2cpp.so").add(0x56BB808), 4, 'rwx')
Module.findBaseAddress("libil2cpp.so").add(0x56BB808).writeU32(0x14000000);
console.log("write ok")
}
},onLeave:function(){}
})
在调试的过程中发现程序有的时候会fork
一个子进程,貌似是用来做反调试的,子进程fork后直接kill掉即可,否则ida无法正常调试。
加固so工作流程分析
libil2cpp.so
使用了自定义linker加固so,ida中查看导出表发现符号全部都被加密了。
壳要想还原原程序需要获取到最早的执行时机,一般都是截获.init_array
中的函数。readelf
查看so的.init_array
发现有三个函数
ida查看这三个函数发现地址不对,函数跳转过去全都是乱码,.init_array
是由linker64
执行的,所以函数不可能是加密的。
因为.init_array
中的函数也是需要做重定位的,去重定位表中看一下对应的重定位项,.init_array
中的数据对应的重定位类型为R_AARCH64_RELATIVE
,一般是位于RELA
(.rela.dyn)节区中。RELA
的前三个重定位对应的就是.init_array
中的三个函数的重定位数据,所以实际重定位后.init_array
中的三个函数地址应该为0x56BB808,0x56BC018,0x56BC710
。
这里ida未能解析重定位表完成重定位是因为section
干扰了ida的分析,直接将elf头的中section
数据清空也可以使ida进行正常的重定位。
过掉反调试后ida调试这三个函数
func1
func1
主要是初始化一个函数表,后面func2
和func3
会使用这些初始化的函数
func2
func2
会先调用自实现的prelinker_image
函数,解析壳自己的dynamic
动态链接节区获取到dynstr
等数据的位置,接着就会调用解密函数去解密dynstr
中的字符串数据
func3
func3会加密代码段和数据段,然后还原到内存中原程序位置。之后再次解析壳so的.dynamic
,目的是得到壳中解密后的.dynstr
和.dynsym
等动态链接信息。调用prelinker_image
获取原代码的dynamic
相关信息之后调用linker_image
对原程序进行重定位。
linker_image
会获取到原程序的.rel.dyn和.rel.plt
节区中所有的重定位项进行重定位。
最后循环调用原程序的.init_array
中的所有函数。
壳加固思路分析
原程序具有两个PT_LOAD
段(代码段和数据段),加固的时候将代码段和数据段中的数据加密,同时将第二个PT_LOAD
段(数据段)在文件中清空,以.bss
段的形式存在。所以加固后的so第一个PT_LOAD
段的内存大小是远大于文件大小的,多出来的这部分在内存中就是数据段。壳还会增加两个PT_LOAD
段保存壳的代码和数据
将解密后原程序代码段和数据段dump后查看原程序的.plt
表在原程序的代码段中,而got
表在原程序的数据段中,也就是二者的相对偏移并没有变化,.plt
表中的代码不需要动态修正即可正常工作
因为壳的代码段在内存中的偏移为0x563f000
,而原程序的.got
表在这偏移之前的原程序代码段中。壳的重定位表中只包含了0x563f000
偏移地址之后需要重定位的数据,所以壳的重定位表只包含了壳自身的重定位项。
而原程序的重定位表是壳在func3
函数中对原程序进行重定位之前保存在malloc申请的堆内存中。并且重定位表在完成重定位操作后就没有用了,可以直接清除防止dump
壳获取原程序的dynamic
信息中只包含了DT_NEEDED
和init_array
数据,并不包含原程序的dynstr
和dynsym
信息,这是因为壳和原程序共用同一份dynstr
和dynsym
。壳和原程序需要共用一份符号表和字符串表的原因是:linker保存了壳程序的program header table信息到soinfo中,后续其他so与此so进行交互的时候需要通过soinfo链找到此so的program header table信息。例如如果有其他so试图通过dlsym获取此加固so的导出函数时,linker就需要得到对应加固so的soinfo,接着获取program header table中的符号表symtab,找到对应的导出函数地址并返回。(这也解决了之前自实现linker加固so的时候需要修复壳so的soinfo为原始程序的soinfo的问题)
dump解密数据并patch壳so
func2
的时候用ida脚本将解密后的.dynstr
数据dump下来
func3
在对原程序进行linker_image
重定位之前将代码段和数据段dump下来,调用了linker_image
之后dump还需要对重定位的数据进行修复。这里目的是为了dump sdk,所以直接将解密的数据patch到对应的文件偏移处,其中解密的数据段为了方便直接插入到文件中原程序代码段和壳代码段中间即可,然后修改第一个PT_LOAD段的大小为原始程序代码段和数据段之和,后面的段对应的文件偏移需要加上插入的原程序代码段的大小。
dump global-metadata.dat
查看apk的unity资源文件发现使用的引擎版本是2018.4.24f1
自编译一个相同版本的apk,找到加载global-metadata.dat
文件的地方,同时定位加固so的相似位置进行dump,dump后修复global-metadata.dat
文件的头
寻找g_CodeRegistration和g_MetadataRegistration并dump sdk
对于2018版本的unity引擎,通过.init_array
的函数找到RegisterRuntimeInitializeAndCleanup
函数,并通过参数得到s_Il2CppCodegenRegistration
函数,继续通过参数得到两个表的地址
壳so在func3
函数最后会调用原程序init_array
中的所有函数,将这些函数列表dump下来
分析原程序init_array
中的所有函数,最后定位到s_Il2CppCodegenRegistration
函数
通过s_Il2CppCodegenRegistration
函数参数得到g_CodeRegistration
= 0x2F6B408,g_MetadataRegistration
= 0x2F6B488
Il2CppDumper
填入g_CodeRegistration
和g_MetadataRegistration
成功dump sdk
查看get_position_Injected
函数地址
对比ida中解密的代码发现是正确的