记一次某盾手游加固的脱壳与修复
2023-3-29 18:4:13 Author: 看雪学苑(查看原文) 阅读量:52 收藏


本文为看雪论坛精华文章

看雪论坛作者ID:乐子人


前言


前一段时间在逆向某游戏Y,被其丧心病狂的ollvm混淆折磨的欲仙欲死。游戏Y以牺牲性能为代价做安全保护的精神实在令人佩服,截图如图:
竟然对所有常数都进行了加密!
于是恼羞成怒打开另外一款小众的手游,想破解练练手,打开后发现是Unity的,上Il2cppdumper伺候:
直接报错。
打开global-metadata.dat看看,发现被加密了,正常的MAgic 是 AF 1B B1 FA,被换上了奇怪的Magic:HTPX
打开apk包,看到lib目录下有libNetHTProtect.so字样。原来是某盾手游加固:
抱着试一试的心情,想看看他的保护是怎么做的,但是一不小心就搞了一周。由于内容太过精彩,于是想总结成文章供大家学习赏析,这就是这篇文章的来源。


寻找Init_Array

使用ida加载libil2cpp.so,ida直接给出提示:
显示section header有问题。直接点ok进去,发现一大堆奇怪的函数名:
查看segement信息,发现出现了一些不常见的note.gnu.proc,note.gnu.text等节区,这显然不正常。
我们知道,linker(链接器)将一个动态库加载到内存时,做完重定位(relocate)操作后就会调用动态库的init函数,用来完成一些初始化操作。由于init函数是linker调用的,所以没法做加密。看到这么离谱的一个动态库,显然需要init函数来解密。
通常,init函数会出现在.init_array这个节区里,这是个函数指针数组,链接器会依次调用里面的函数。
但是我们的动态库没有.init_array这个节,linker去哪里找初始化函数?
我们可以使用readelf工具来查找:使用readelf -d 操作:
发现INIT_ARRAY在地0x7df6a50这个位置。
跳过去看看:
确实,上面的蓝字写了这里是ELF Initialization Function Table,我们点进第一个地址看看:
发现是一堆无意义的数据,ida也无法将其解析为汇编指令。这不科学!
因为这是链接器第一个调用的函数,如果这个函数都是加密的,那在链接阶段就会报错。所以我们合理怀疑初始化函数位置找错了。
其实之所以会搞错,是因为错误的section header干扰了ida的解析。这里有一个技巧,因为ida在解析动态库的时候不需要section信息,只需要segement信息ida也可以解析。所以我们直接将原来的section header全部抹去。
使用010Editor打开libil2cpp.so,运行ELF模板,直接跳转到section header的起始位置。
将所有数据用0覆盖:
然后重新打开ida,再次跳转的0x7df6a50这个位置,发现ida已经正常解析INIT函数的地址了:
并且代码也被成功的解析了出来:
我们愉快的按下F5,ida又报错了:
堆栈不平衡,ida发现了错误的栈指针。这通常是因为代码中有花指令的缘故,我们要考虑去除花指令了。


去除花指令

我们看看出错位置附近的代码:
从0x7D4415C开始,如果w8不为0就会跳转的0x7d4416c,然后给栈寄存器sp减了0x20然后ret。这显然错了!
因为函数的一开始并没有给sp寄存器加0x20,这里减去0x20再返回一定会堆栈不平衡。所以有理由怀疑,这里就是花指令,用来干扰ida解析的。
但是如果w8为0,会先执行sub_7d459bc这个函数,然后给栈寄存器加0x20,再跳过错误的部分,去0x7d44184。我们看看sub_7d459bc这个函数:
是给sp减去0x20,那这样就没问题。执行完后再加上0x20,栈是平衡的。
所以我们确信,中间的ret部分就是花指令。
观察后发现,这样的花指令在代码中有几十处,并且有两种模式,我们肯定不能手动Patch了。
模式1:
模式2:
我们可以根据这两种模式的机器码的特点,在原so中进行匹配,匹配后换上arm64下nop指令的机器码:0x1f2003d5就可以了。
去花指令脚本如下:
#去除花指令mod1 = [0x86,0x10,0x40,0xb9,0xa6,0x19,0x0,0x18,0xff,0x43,0x0,0xd1,0xc0,0x3,0x5f,0xd6]mod2 = [0x86,0x8,0x40,0xf9,0xff,0x43,0x0,0xd1,0x0,0x0,0x0,0x1b,0xFF,0x25,0x0,0x18,0xff,0x43,0x0,0xd1,0xc0,0x3,0x5f,0xd6]nop = [0x1f,0x20,0x3,0xd5] def match(data,index,mod,ignorerange):    for j in range(len(mod)):        if data[index + j] == mod[j] or j in ignorerange:            continue        else:            return False    return True def patchWord(data,index,code):    for i in range(4):        data[index+i] = code[i] def patch(data,index):    start = index - 0x10    patchWord(data,start,nop)    patchWord(data,start+4,nop)    patchWord(data,start+8,nop) def patch_mod1(data,index):    start = index    patchWord(data,start,nop)    patchWord(data,start+4,nop)    patchWord(data,start+8,nop)    patchWord(data,start+12,nop) def patch_mod2(data,index):    start = index    for i in range(6):        patchWord(data,start+i*4,nop) with open("f:\\test\\libil2cpp.so","rb") as f:    data = list(f.read()) for i in range(len(data)):    if match(data,i,mod1,[4,5,6,7]):        patch(data,i)        patch_mod1(data,i)    if match(data,i,mod2,[12,13,14,15]):        patch(data,i)        patch_mod2(data,i) with open("f:\\test\\libil2cpp_patch.so",'wb+') as f:    f.write(bytes(data))
非常简单的读文件,匹配,写文件的操作。
去除完花指令,我们就可以愉快的F5了:
去除后的汇编代码变成这个样子:
中间混淆的部分全部去掉,直接跳转到正确位置。
接下来就看看三个init函数都做了些什么。

分析init函数

1、第一个init函数

第一个init函数主要进行了一个初始化操作:
将一些常用的系统函数如fopen,mmap,memcpy等赋值给一个全局的函数数组,后面通过这个函数数组来间接调用基本的系统函数。显然这种脱裤子放*的做法是为了增加代码阅读难度,提升安全性:

2、第二个init函数

第二个init函数主要做了三件事:
1.遍历elf文件,寻找dynmic segement


具体逻辑就是遍历program header,寻找p_type为2的段。
为什么要找Dynamic 段?我们转到Dynamic段看看:
原来,dynamic段保存了所有动态链接需要的信息。注意到0x19,这个正是INIT_ARRAY的编码,而后面的0x7df6a50则正是初始化函数在内存中的位置。
其实readelf -d的工作原理也是遍历这个dynamic段。
可以看到所有动态信息与文件中的数据对应。同时,ida之所以不需要section信息也能解析,也是因为有了dynamic段就足够的缘故。
这里附一份动态段type表:
这张表中,我们可以清楚的看到,有字符串表DT_STRTAB(5),有符号表DT_SYMTAB(6),有重定位表DT_RELA(7),大家可以对照这张表,将readelf的输出,和动态信息在文件中二进制数据结合在一起看,会更加清楚。
2.执行prelink操作
prelink是android linker源码里进行的一个操作。这个操作的本质就是解析dynamic段的各种数据,将他们分门别类的存储起来,供后面使用。
libil2cpp.so中自己实现了prelink操作,令人毛骨悚然,看看代码:
就是通过一个大的switch case去解析各项数据。
而andorid源码里是这样的:
可以看到几乎是一样的。但是如果不熟悉那些type的具体数值,如init_array是0x19,即10进制的25,则很难在ida中搞懂他是在做什么。
3.解密字符串表和符号表
执行完prelink操作后,就拿到了字符串表和符号表在内存中的偏移和大小。然后对其进行了解密操作:
其中,v11[4]是符号表的偏移,v11[6]是字符串表的偏移。而每个偏移后面跟随的是解密起始位置和总长度。
这个加固并没有将全部的字符串和符号都加密,而是进行了部分加密和部分解密。
可以看到,字符串表的解密方式是按DWord进行异或0x56312342,而符号表的解密是与解密长度本身有关。
我们可以找到文件中字符串表和符号表的偏移,然后手动用脚本进行解密,解密完后再将数据覆盖回去:
def getInt32(data,offset):    return data[offset]|data[offset+1]<<8|data[offset+2]<<16|data[offset+3]<<24 def getInt64(data,offset):    return getInt32(data,offset)|getInt32(data,offset+4)<<32 def putInt64(data,offset,value):    for i in range(8):        data[offset+i]=value&0xff        value = value >> 8 def decodeStrTable():    start = 0x45BBC38    encryptLen = 0x19c98//4    key = [0x42,0x23,0x31,0x56]    with open("f:\\test\\libil2cpp.so",'rb') as f:        data = list(f.read())    for i in range(encryptLen):        for j in range(4):            data[start+4*i+j]=data[start+4*i+j]^key[j]    with open("f:\\test\\libil2cpp_str.so","wb") as f:        f.write(bytes(data)) def decodeSymTable():    start = 0x4599990    _from = 0x8a8    _to = 0x113c    with open("f:\\test\\libil2cpp_str.so", 'rb') as f:        data = list(f.read())    while _from<_to:        offset = start + 0x18*_from        val0 = getInt64(data,offset+8)^((0x8a8+0x151)&0xffffffff)        putInt64(data,offset+8,val0)        val0 = getInt64(data,offset+0x10)^((0x8a8+0x15b)&0xffffffff)        putInt64(data,offset+0x10,val0)        _from = _from+1    with open("f:\\test\\libil2cpp_str_sym.so","wb") as f:        f.write(bytes(data)) decodeStrTable()decodeSymTable()
覆盖回去后,重新用ida加载so,我们看到函数名已经正常了:
但是,这时候还只是壳在运行,真正的原始的so还没有出来。真正的so要出来,在第三个init函数里。


自定义链接与重定位加载真正的so——第三个INIT函数

进入第三个init函数,首先解密了一小段代码,执行完又加密回去,功能是初始化一些全局变量,这里就不展开讲了。
核心是进入了自定义的链接器函数:
具体怎么看出来的是需要结合android linker部分的源码来研究,这里直接说最终的结论。已经在图中标出了各个函数的作用。
首先是初始化了soinfo,(先malloc一段内存,然后清零)
接下来解密了子so的代码段和数据段:
解密算法是魔改的rc4。
解密之后,也解密出了子so的program header。然后根据子so的program header,将解密后的代码和数据通过load_segement操作映射到libil2cpp.so的内存空间。
子so的program header 是解密后存在别处的,在使用时单独调用。这样做可以防止无脑dump整个内存,因为dump下来没有正确的program header信息,子so的program header:
load_segement也是android linker加载动态库的操作,核心步骤是:
1、遍历program header,找到PT_TYPE为1的段。(上面说过,type为2的是dynamic段,而type为1 的段是loadable段,即需要加载到内存中的部分)!
数据段和代码段通常都是loadable的。而dynamic段通常包含在数据段中,只是单独用type=2指明出来,方便链接器快速定位。
2.通过mmap将文件中数据映射到内存,同时修改内存的访问权限。
3.进行页对齐操作,将多映射的内存用0填充。
对于某盾的加固,他自己实现的操作有些不一样,具体是:
1.计算出需要映射的地址的偏移和大小,通过mprotect函数将内存权限修改为可读可写可执行。
2.使用0xBB填充需要映射的内存。
3.使用memcpy将解密出来的数据和代码复制到对应内存。
4.将多余的内存用0填充。
5.根据segement的读写权限,再次调用,mprotect,修改为对应的权限。
等等,没有调用mmap,那么往哪里映射?
其实,在壳so加载的时候,已经映射了足够的内存空间:
看看第一段。第一段其实是原始的so的代码,文件大小只有0x447c000,但是居然在内存中要了0x7cd4000的空间。这么大的空间就是为了后面把解密的代码和数据全部复制过来用的。
由于这部分数据是加密的,没什么卵用,所以直接将解密后的数据覆盖过来挺好。某盾的壳就是这么做的。
load_segement之后,接着根据壳so的dynamic信息,执行了一次prelink操作,这次操作的目的是获取到字符串表等信息。
(由于壳so和子so是共享了部分信息,所以有必要获取一下壳so的各种数据信息)
接着,根据解密后子so的dynamic段信息,再次执行prelink。
为什么再次执行prelink?
因为壳so和子so的初始化函数肯定不一样,要把子so成功加载进内存,显然要执行子so的init函数。所以这次prelink要把init_array修改为子so的init_array,方便后面执行。子so的dynamic 段数据如下:
可以看到,子so的dynamic信息很少。只有init_array,fini_array的信息。
其实,这个动态信息是壳so的dynamic 和子so的dynamic diff出来的信息。对于子so,大部分信息已经包含在壳的信息里了,只有少部分需要修正。
第二次prelink后,子so的数据已经全部加载到内存中了,但是还没有进行重定位,无法执行。
需要执行重定位(relocate)操作。

具体的,也是仿照android 源码,根据重定位符号类型,对符号地址做修正:
其中,0x401,0x402是pltgot类型的重定位,0x403是相对距离调用类型的重定位。需要对这些数字很敏感,才能看出这是在做重定位。这就需要各位仔细阅读andorid linker部分的源码了,我也是在做这次逆向过程中读了好几遍,才基本搞懂的。
上图是android 源码里关于重定位类型的定义0x401对应10进制的1025,依次类推。
android 源码中重定位部分,也是根据重定位类型做switch case循环。(只不过为了跨平台,把宏又全部重新定义为了R_GENERIC类型)
执行完重定位后,调用了子so的init函数:
到这里,子so就被正确的加载进了内存。


修复——借尸还魂

6.1移植代码段和数据段

我们将所有解密的数据先在内存中dump下来,idadump脚本:
import idaapidata = idaapi.dbg_read_memory(start, len)fp = open('filename', 'wb')fp.write(data)fp.close()
子so有两个segement(代码段与数据段),同时,子so的program header,dynamic段信息,符号表,重定位表是存在别处的,我们都要dump下来(或者截屏保存,如果数据不多的话):
然后,我们对已经解密过字符串表和符号表的壳so做修改。(因为很多数据是共享的,不能只组装一个子so,所以我们要借壳so的尸,来还子so的魂)
首先将解密后的第一段复制到program header后面(我们保留壳so的program header。
因为壳的很多东西还要映射,后面我们在壳so的program header上做修改,把子so的移植进去)
壳so的第一段,可以看到数据是加密的。
复制后:
第一段其实是加密过的子so的代码段。
接下来需要将子so的数据段弄进来。但是program header里没有关于子so数据段的映射信息。我们需要先修改program header。
壳so有七个program header,期中,最后一个是GNU Read-only After Relocation。里面保留的主要是pltgot表信息。在重定位后提示链接器,这里不要再动了。
我们可以把这段删掉,换成子so数据段对应的header信息:
直接复制过来,这时候第七个program header也变成loadable 的了:
然后我们将子so的数据段复制过来,复制到哪?
由于在文件中,数据十分紧密,如果随意覆盖,可能会破话其他部分的数据,会出问题
所以我们将子so的数据段数据附在文件的末尾,然后通过修改program header里面的 file offset字段来完成修改。
将子so的代码和数据都移植进去后,我们打开ida看看:
直接报错。
是因为我们在把子so的数据段往文件末尾添加时,忘记修改section header的偏移了。错误的section header会干扰ida的分析。
于是我们调整section header的偏移。在文件的末尾再加上一个空的section header,然后修改elf 头中关于section header offset的信息:

再次打开ida,我们兴奋的发现,熟悉的JNI_ONLoad出现了:
并且有关于IL2CPP的信息。但是点进sub_b0bca0后发现,函数是空的:
看这个样子像是子so的plt表。但是函数都是sub_b0b360。原来,我们没有修复子so的重定位信息,导致ida找不到这些符号的真实地址。因为,子so的重定位表还在我们手里呢?

6.2修复符号表与重定位表

由于子so与壳so都需要重定位,所以我们可以把子so和壳so的重定位表合在一起。但是这样一来,壳so数据段空间不够了。(文件中数据很紧密的)而且如果直接将子so的重定位表覆盖在壳so重定位表的后面,谁知道会破坏什么数据。
所以我们需要做的操作是:
1.将壳so的重定位表copy一份出来。
2.将子so的重定位表与壳so的重定位表合成一个大的重定位表。
3.将新的重定位表附在壳数据段的末尾。(此时我们覆盖已经了子so的数据段数据,因为壳so的数据段末尾基本在原文件的末尾)
4.修改program header中壳so的数据段中,file_size字段(多出了子so的重定位表,需要一起映射进去)
5.修改dynamic 段中关于重定位表偏移和大小的信息(因为我们把重定位表附在了文件末尾,原来的已经不用了,并且大小变了)
dynamic 段中关于重定位表的信息(7,8为重定位表相关,可以参考前面的那张dynamic type表):
修改后:
6.由于我们覆盖了子so的数据段。所以我们需要重新把子so的数据段附在文件末尾,然后修改该段对应的program header中偏移信息,然后附加上新的section header,然后再次修改elf头中section header的偏移。
这样,子so的重定位表就加进去了。
但是没完。重定位中主要有两种,一种是pltgot表的重定位,一种是相对距离调用的重定位。
相对距离调用的重定位比较简单,linker会直接用elf文件 的base 加上重定位信息中的addend信息作为内存中真实的地址。
例如上图中红框内是一个重定位信息。
重定位表的数据结构是这样的:
typedef struct elf64_rela {   Elf64_Addr r_offset;   Elf64_Xword r_info;   Elf64_Sxword r_addend;} Elf64_Rela;
第一个字段是符号的偏移(重定位前),第二个是重定位类型(0x403,0x402之类),第三个是附加信息。
对于红框中的数据来说,他的意思是在0x448b4d0这个位置的符号,是0x403(相对距离)类型的重定位信息。addend是0xb0d5dc。
假设我们的so文件加载的基地址是0x70000000。那么在重定位的过程中,linker会做这样的操作:
*(0x70000000+0x448b4d0)=0x700000000+0xb0d5dc
这样就完成了重定位。但是pltgot表的重定位比较特殊:
上面是一个pltgot类型的重定位,可以看到符号的地址是0x7dfbae8,但是没有addend,反而在02 04后面有一个0x51。
其实,这个0x51是符号表的符号索引。
linker会根据这个索引,先在符号表中找到对应的符号,然后获取符号名。
通过符号名分别在依赖库和自身的符号表里找这个符号。
然后将找到的符号的真实地址直接用来重定位。
某盾在做重定位时,用的是子so的符号表而不是壳so的符号表。由于符号表只能有一张,如果我们直接按照重定位表那样融合,索引一定会乱掉。所以我们只能用子so的符号表去覆盖壳so的符号表。
这样一来,壳so的重定位会有一部分乱掉。我们手动修复一下,运气好的话不会太多,10处左右。
修复完符号表和重定位信息后,我们看到了之前JNI_onload里的函数:

原来是在打log。

6.3修复init_array

记得要把init_array修复为子so的init_array。具体过程就不在赘述了。

6.4将壳so的第一个init_array添加

因为某盾的保护在子so运行过程中会用到之前初始化过的全局函数数组。所以要把壳so的第一个函数的init_array函数加到子so的init_array里。
具体的:
1.修改dynamic 段init_array_size部分,加一个函数。
2.对应的需要对finit_array_size部分减一个函数,同时将finit_array的偏移向后移动一个指针的距离(空出来的部分给添加的init函数)
3.找到壳so 第一个init函数的重定位信息,将其复制给新添加的init函数对应的重定位表里。
如图,是原来fini_array的样子:
把第一个函数替换后:
可以到fini 函数少了一个,而init函数多了一个!这个函数正是壳so的第一个init函数。

6.5添加正确的section header

对于ida来说,没有正常的section header是可以正常解析的。但是对于系统linker来说,没有section header会无法正确加载so。所以我们需要移植一个正确的section header。
android linker需要的section header信息其实不多,主要由三个:
1.shstr,2.dynamic,3.dynstr
具体过程就不再赘述了,大家可以根据加载时候的报错信息,结合andorid源码来针对性的修改:
三个section header就可以了(第一个需要为空)


起飞

接下来将我们修复的so替换app里面的so,发现游戏可以正常运行:)
但是global-metadata还是加密的。
没关系,我们已经把il2cpp扒光了,可以直接定位到加载global-metadata的位置:
在sub_bc9564下断点,执行完后直接内存中dump出global-metadata就可以了。
不过某盾把global-metadata的magic和version信息抹去了。这两个信息在运行时是不用的,但是il2cppdumper解析需要。我们手动修复就好:
上il2cppdumper:
成功dump出cs脚本。简单搜索了一个叫get_hp的函数,如果把这个函数修改了,后果不堪设想。
当然,作为社会主义好青年,我们不会做这种事滴!至此,某盾手游加固的脱壳与修复,就全部完成了。


后记

某盾手游加固不同于传统的加固方式。传统的加固方式是直接整体加密,解密,执行。内存dump下来很好修复。
但是某盾自己实现了linker中加载,重定位的全部代码,将子so的数据全部加密,并且分开存储,使得内存中从来没有出现过完整的子so的影子,无法整体dump,只能分段dump,然后重组,安全强度确实高了不止一个数量级。
本来只是想逆向一个普通游戏泄愤,没想到入了某盾加固的坑。不过整个实验的过程中,看雪前辈关于android linker的文章我都仔细读了,andorid linker相关的源码也是翻了个遍,之前模糊的链接与重定位,elf文件格式相关的知识也在脑海里明晰了起来,也算是收获颇丰。
以上内容仅供学习技术,交流,严禁用于违法的活动!

看雪ID:乐子人

https://bbs.kanxue.com/homepage-872365.htm

*本文由看雪论坛 乐子人 原创,转载请注明来自看雪社区

# 往期推荐

1、Realworld CTF 2023 ChatUWU 详解

2、安卓协议逆向 cxdx 分析与实现

3、Kernel PWN从入门到提升

4、Kernel PWN-开启smap保护的babydrive

5、【详解】CTFHUB-FastBin Attack

6、Relocate、PLT、GOT And Lazy Binding

球分享

球点赞

球在看

点击“阅读原文”,了解更多!


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458500165&idx=1&sn=b16710232d3c2799c4177710f0ea6d41&chksm=b18e8ccf86f905d9a0b6c2c40997e9b859241a4d7f798c4aeab21352b0a72b6135afce349262#rd
如有侵权请联系:admin#unsafe.sh