记一次有趣的手游加固脱壳与修复——从GNU Hash说起
2023-6-18 18:0:17 Author: 看雪学苑(查看原文) 阅读量:89 收藏

起因是前两天一位老哥问我对某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

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

# 往期推荐

1、在 Windows下搭建LLVM 使用环境

2、深入学习smali语法

3、安卓加固脱壳分享

4、Flutter 逆向初探

5、一个简单实践理解栈空间转移

6、记一次某盾手游加固的脱壳与修复

球分享

球点赞

球在看


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