起因是前两天一位老哥问我对某Guard的保护有啥方法?
是一个捕鱼游戏,刚好我很早就对这家保护感兴趣了,因为其介绍是“业界独创的无导入函数so加固”,一直想研究但是又没有碰见使用的游戏,所以就赶快要了样本拿来研究一番。看了两天基本把加固原理搞得差不多了,也脱壳修复完成,比之前的某盾在自定义linker上确实更有新意,于是想写篇文章总结一下。
一
概览
拿到样本,先用jadx看了一下java层,没什么加固。然后去看了一下lib目录,发现了:
libF*Guard.so特征模块。
ida打开后,发现主体部分全被加密了,只保留了壳的一小部分代码:
壳的代码
看一下got表,发现导入的是一堆三角函数tan,拿三角函数做什么?
字符串表大部分被抽空了:
符号表里也是一堆nullsub,不知所云:
好吧,看起来挺复杂,不过无所谓,初始化函数会出手。
看看init array:
有三个函数,并且没有混淆,那我们就从可以这三个init函数入手分析。
二
GNU Hash
在正式开始分析之前,我们先说说GNU Hash,顾名思义,GNU Hash就是一种Hash算法,具体代码如下:
uint32_t gnu_hash(const char* str)
{
uint_32 h = 5381;// 0x1505
while(*str != 0)
{
h += (h<<5) +*str++;// 33 * h + *str
}
return h;
}
算法很简单,就是对一个字符串(也可以是byte 数组),先设定值0x1505,然后每次将该值乘33,再加上下一个字符的数值,直到字符串结束。用位运算表示就是每次左移5位自加,再加上下一个字符数值。
简单来说,GNU Hash将给定的字符串映射成为一个32位无符号整数。
在linux 平台中,与GNU Hash相对应,有另外一种Hash 叫ELF Hash,算法如下:
uint_32 elf_hash(const char* str)
{
uint32_t h=0,g;
while(*str)
{
h = (h<<4) + *str++;
g = h & 0xf0000000;
h^= g;
h^ = g > >24;
}
return h;
}
也是一种计算给定字符串的hash值的算法。
这两种算法有啥用?
GNU Hash和Elf Hash主要是用于加速符号的查找。例如要使用dlsym函数获取一个符号的地址,背后就依赖这两个函数。
在android linker的源码,链接时的符号决议过程也有gnu hash和elf hash 的参与。gnu hash晚于elf hash诞生,据说是能将符号查找的过程提速50%左右。在具体执行时,会根据标志位选择一种hash算法查找符号。在使用gcc编译时,通过 –hash-style = gnu 来指定生成的so使用gnu hash来管理符号,否则,默认使用elf hash。
具体如何查找的,可以参考android linker部分的源码,大致如下:
unint32_t h = gnu_hash(name);
----省略-----
n = gnu_bucket_[h % gnu_nbucket_];
do {
Elf64_Sym * s = symtab_ + n;
char * sb=strtab_+ s->st_name;
if ((gnu_chain_[n] ^ hash) >> 1) == 0 && strcmp(sb ,name)) == 0 ) {
break;
}
} while ((gnu_chain_[n++] & 1) == 0);
Elf64_Sym * mysymf=symtab_+n;
long* finaladdr= reinterpret_cast<long*>(sb->st_value + (char *) start);
return finaladdr;
每个elf文件中会有一个特殊的区域,用来专门存放GNU Hash相关的信息,主要有bucket和chain。
首先根据符号名计算出gnu hash,然后根据hash 找到对应的hash桶,hash桶中先拿到初始索引n。
接着根据索引n去符号表中获取第n个符号。符号表的结构如下:
typedef struct {
Elf64_Word st_name;
unsigned char st_info;
unsigned char st_other;
Elf64_Half st_shndx;
Elf64_Addr st_value;
Elf64_Xword st_size;
} Elf64_Sym;
获取到符号后根据st_name字段(符号名在字符串表中的偏移),拿到符号名,使用strcmp比较符号名,如果同时满足(gnuchain[n] ^ hash) >> 1) == 0 条件,则认为找到了符号。
找到符号后获取st_value的值(符号在模块内部的地址偏移),加上模块的基地址,就是符号的真实地址了,然后返回。
如果比对失败,则n++,继续执行符号查找。
使用elfhash查找原理大致相似,这里就不在赘述了。
三
三个init函数
简单介绍了一下通过GNU Hash获取符号地址的原理,接下来我们分析三个init函数。
3.1第一个init函数
该函数先执行了一个get_module_addr的函数,通过syscall 打开/proc/self/maps文件,然后分别读出libc,liblog,libdl,libstdc++的基地址。
当然,这些字符串都被内置到函数里进行加密了,使用的时候临时解密:
拿到四个模块的基地之后,分别遍历他们的dynamic节区,拿到各个模块的字符串表,符号表,hash表,重定位表等信息(一个弱化版的prelink操作)。
拿到一些基本信息后,通过遍历libc,libdl等模块的符号表,获取到dlsym,dlclose,等函数的地址。
然后通过RC4解密了一大坨数据,主要是函数名和偏移:
可以看到解密出了munmap,mmap,calloc等字符串。解密这些字符串干啥?往下看。
解密完后开始计算这些字符串的GNU Hash!
(还记得GNU hash的magic 5381(0x1505)吗?)
然后遍历各个之前获取到的模块的hash表,执行“通过GNU Hash获取符号地址”的操作:
自定义的字符串比较函数,没有调用strcmp。
比较成功后,获取到符号的真实地址:
其中,X1是符号的st_value,x13是libc的基地址。加完后X13就是munmap的真实地址。
然后将x13存储到了一个地址:
其中,X20是libF*Guard.so的基址,而x10是:
可以看到,写入的地址,正好是之前tan函数的地址:
所以就知道了,该加固通过函数名,计算GNU Hash,然后直接去目标模块中拿到函数地址,写回got表,通过这种方式实现了导入函数的重定位。同时由于自己计算,所以符号表中该符号没用了(否则要进行符号重定位(0x402类型重定位)来更正地址,对应jmprela表),于是实现了导入符号的抹去!
通过GNU Hash的方式,将之前解密出来的符号名全部获取地址,然后写入got表:
第一个init函数到这里就结束了。
3.2 第二个init函数
第二个init函数主要是初始化了两个全局变量:
3.3 第3个init函数
第三个init函数里面有三个函数:
第一个函数仍然是做一些初始化工作:
第三个函数通过间接调用free做了一些收尾工作。主要逻辑在第二个函数:
3.3.1 解密子so
第二个函数首先间接调用了一个函数,真实地址是0x3E8F20。
该函数的作用是解密子so的代码和数据。具体算法是先通过RC4解密了一段初始信息,然后根据初始信息,分别使用四种解密算法对原始数据进行分段解密,每256字节换一种算法:
解密完成后,又对末尾的一大段数据进行了二次解密:
至此,之前加密的数据可以全部dump下来了,因为已经完成解密。
3.3.2 对子so执行prelink
解密出的子so是没有重定位的,所以还有需要执行正常的重定位操作。该加固首先根据解密的数据,使用子so的dynamic段做一次prelink:
3.3.3 根据GNU Hash对子so重定位
子so解密完后有一大串字符串:
可以看到,有模块名和函数名。该加固接下来先检查模块信息是否已经加载。然后根据后面的函数名,计算GNU Hash,去模块中获取函数地址,然后重定位。
等等,地址获取到了写入到哪里呢?
重定位主要有基址重定位(0x403)和符号重定(0x402)位两种,基址重定位通常就叫rela,而符号重定位通常叫jumprela。
//重定位项的数据结构
typedef struct {
Elf64_Addr r_offset;
Elf64_Xword r_info;
Elf64_Sxword r_addend;
} Elf64_Rela;
其中,r_info为重定位类型。
我们看到子so 的基址重定位是正常的:
但是符号重定位有问题:
其中的r_info字段不是正常的0x402,ida表示unknown。
正常的符号重定位表是这样的:
可以看到r_info低位是0x402,高位的数字代表该重定位项目对应的符号在符号表中的索引。
符号重定位的过程是这样的:
1.根据重定位项目r_info信息获取重定位项目的符号表索引。
2.根据符号表索引拿到对应的符号。
3.根据对应的符号的s_name信息,在字符串表中获取到符号名。
4.通过gnuhash或elfhash获取到符号对应的真实地址(在其他模块中)。
5.将符号的地址填写到重定位项目的r_offset中。
该加固的符号重定位项目有问题,其实是将r_info字段直接替换成了需要重定位的符号名在字符串表中的索引。然后直接根据r_info字段获取符号名,再通过GNU Hash拿到符号的地址,然后直接回填到r_offset中,少了根据符号索引获取符号这一步。
这样一来,也就是和第一个init函数中做重定位的方法一致了,只是这里是对子so也是直接用GNU Hash做重定位。
3.3.4 对子so进行基址重定位
对子so做完符号重定位后,根据基址重定位表做了基址重定位,方法和linker中的相同。
执行到这里,子so的重定位工作就完成了。
3.3.5. 抹除子so的链接信息
子so完成重定位后,该加固抹去了解密出来的子so 链接字符串信息,dynamic表,和重定位表等信息:
3.3.6 解密导出符号
但是事情并没有结束,如果子so有导出符号呢?
导出符号必须严格符合linker的查找方式,即GNU Hash或者Elf Hash否则linker在链接其他so的时候就找不到符号。
同时,由于linker拿到的符号表信息和字符串表信息是壳so的,所以导出符号的解密是在壳so中完成的。
具体方法是这样的:
1.(壳的)符号表中符号如果s_other字段为0x10或者0x30,则为加密符号,执行解密操作。
2.先解密符号对应的符号名(在壳的字符串表中)。
3.根据是0x10与0x30的不同,执行不同的解密逻辑解密符号的s_value。
可以看到,有一些符号的s_other字段为0x10。
3.3.7 hook关键函数
由于该加固是保护游戏的,所以会对unity,unreal一些关键函数进行hook。解密完导出符号后,会在sub_3f6ad0中hook一些函数:
对于u3d游戏,该壳hook了加载global-metadata.dat的函数,在il2cpp加载global-metadata时,通过inline hook 的方式,跳转到libF*Guard.so中对加载的数据进行解密,然后返回。
正常的加载global-metadata的函数:
hook之后的:
3.3.8 执行子so 的初始化函数:
子so很有可能也有一些初始化函数,所以解密出来后要先执行掉:
至此,该加固完成了他的工作,子so开始正常运行。
四
修复
该加固的工作原理我们已经分析清楚了,我们直接在3.3.1之后dump解密的子so,在3.3.6后dump壳so的符号表和字符串表信息,覆盖回去就可以。
4.1 修复符号重定位表
最关键的是子so的符号重定位如何修复,即该加固做了无导入符号加固,他自己link,可以。但是我们修复的目的是让系统linker可以正确的导入这些符号。
查看壳so的符号表,我们发现有大量的空符号:
我们可以利用这些空符号,将导入符号填进其中,然后修正符号重定位表,让原来的直接通过字符串偏移获取符号名,改为正常的通过符号表索引获取符号,这样linker就可以正常重定位了。同时,我们需要将符号名回填至壳的字符串表中,与回填的符号对应。算法如下:
oldstrbase = 0x444
oldstrsize = 0xa18
rawjumprelabase = 0xB5339C
rawjumprelacount = 0x8c9
relaentrysize = 0x18
strbase = 0x3a73a48
symbase = 0x3A6D9D0
symentrysize = 0x18
newbase = 0x3a75444
def getInt8(data,offset):
return data[offset]
def getInt32(data,offset):
return data[offset]|data[offset+1]<<8|data[offset+2]<<16|data[offset+3]<<24
def putInt32(data,offset,value):
for i in range(4):
data[offset+i]=value&0xff
value = value >> 8
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
usedindex = []
def getNextEmptySym():
global usedindex
index = 1
while True:
name = getInt32(data,symbase + index * 24)
ch = getInt8(data,strbase+name)
if ch == 0 and index not in usedindex:
usedindex.append(index)
return index
else:
index = index + 1
with open("f:\libil2cpp.so",'rb') as f:
data = list(f.read())
for i in range(rawjumprelacount):
r_addr = getInt64(data,rawjumprelabase+24*i)
r_info = getInt64(data,rawjumprelabase+24*i+8)
r_addend = getInt64(data,rawjumprelabase+24*i+16)
if r_info == 0:
newaddend = getInt64(data,r_addr) + r_addend
putInt64(data,rawjumprelabase+24*i,r_addr)
putInt64(data,rawjumprelabase+24*i+8,0x403)
putInt64(data,rawjumprelabase+24*i+16,newaddend)
else:#在符号表中创建一个新的符号
index = getNextEmptySym()
putInt32(data,symbase + 24*index,(r_info + newbase - strbase) & 0xffffffff)
putInt32(data,symbase + 24*index + 4,0x12)
putInt64(data,symbase + 24*index + 8,0)
putInt64(data,symbase + 24*index + 16,0)
print("new symbol create:"+hex((r_info + newbase - strbase) & 0xffffffff)+","+hex(index))
#修正重定位表
putInt64(data,rawjumprelabase+24*i,r_addr)
putInt32(data,rawjumprelabase+24*i+8,0x402)
putInt32(data,rawjumprelabase+24*i+0xc,index)
putInt64(data,rawjumprelabase+24*i+16,0)
#迁移字符串
for item in range(oldstrsize):
data[newbase + item] = data[oldstrbase + item]
with open("f:\libil2cpp1.so","wb") as f:
f.write(bytes(data))
修复前,直接通过符号名,计算GNU Hash做重定位:
修复后,变为了正常的0x402类型的重定位:
4.2 修复dynamic段,section信息
我们需要将壳so的dynamic中重定位表,init array,符号表等信息修正成子so的对应信息。保持壳so的Hash表,字符串表和符号表不变。
同时,添加正确的section信息,这里就不赘述了。
4.3 段错误?
修复完后我们替换手机里的libil2cpp.so。结果出现了段错误,程序直接闪退。
观察日志发现是在linker重定位时出现的段错误。
原来,子so的got表被放在了程序的代码段?!代码段的权限是rx,在重定位写入的时候就是会段错误。
壳so可以正常重定位是因为壳so在重定位前通过mprotect把整个代码段赋予了w权限。但是在andorid 8 以后,系统禁止出现具有W+E权限的段了,我们不能直接改段属性:
所以我们只好新添加一个data段。
刚好got表相关的信息在代码段的末尾,我们直接切割出一个数据段,赋予RW权限即可。
原来的段信息,代码段从0-0x3a9c980。
我们替换GNU Read-only After Relocation段:
代码段大小变成了0x32fda00,新增了一个从0x32fe000开始的段,这个段刚好包含了got表等数据信息。
4.4 替换global-metadata
修复完成后,由于跳过了壳的init函数,导致libF*Guard.so没有hook到global-matadata的解密函数,如果直接加载的话,就是一个加密过的global-metadata。
所以我们需要dump出正常的global-metadata,替换掉手机里的。
这样之后,游戏就可以正常启动运行了。
五
u3d的一些逆向
修复完成后,我们试试il2cppdumper。
直接成功。
打开脚本看了半天没发现什么有用的逻辑,这时候,突然看到项目目录下的这个:
这游戏的核心逻辑在lua不在c# ?
随便打开一个lua文件,发现是base64编码的,但是解码后依然是乱码,应该是加密的。
不过解密逻辑应该在il2cpp里,搜了一个函数:
看到叫xxtea_decryptBase64String,解密完直接丢给lua了。hook了这个函数,打印出了xxtea的秘钥:
写了个脚本解密了一下,把所有lua文件都解密了:
嗯,这下算善始善终了,收工。
六
总结
这次主要对某Guard的手游加固进行了分析,其中最为出色的地方在于通过GNU Hash和函数名来对导入函数进行重定位,然后在符号表中删除了导入符号相关的信息,从而实现了导入符号隐藏。同时也有很多不同的解密算法在对各种数据进行解密,总体来说,相较于之前的某盾加固,有一些新意,各有千秋。
不过,加固做的再复杂,也总是有办法脱掉的,因为,逆向工程师永远胜利!
看雪ID:乐子人
https://bbs.kanxue.com/user-home-872365.htm
# 往期推荐
3、安卓加固脱壳分享
球分享
球点赞
球在看
原文始发于微信公众号(看雪学苑):记一次有趣的手游加固脱壳与修复——从GNU Hash说起