如果说某易的保护是手游保护届的东岳泰山,某嘉德是西岳华山,某讯的ace是中岳嵩山,那么nProtect一定是手游保护的珠穆朗玛峰。
至于nProtect的名声和地位,我想对游戏保护稍有了解的人一定早有耳闻,无需多言。
小弟我早在今年2月份的时候就接触了nProtect保护的手游——传奇m Global,但是当时被其复杂的代码动态解密和ollvm混淆劝退,其后又尝试了几次,还是放弃。
上个月在水友群里看到有老哥在研究np,问了一下他怎么处理那些加密的函数的,他说一个一个dump:
这确实是一个方法,但是因为太麻烦我就一直没有尝试,这次看到有人这样做了,那我也想着铁着头试一试,虽然麻烦。于是还真的借着“笨办法”终于把np给搞定了。前后一共花了快3周时间,研究后发现nProtect用到的技术之高妙,检测之周密,架构之庞大,实属罕见。遂整理成文章,与诸位研究安全的同仁做一分享。
打开游戏的lib目录,可以看到libcompatible.so,libstub.so和libengine.so三个特征动态库:
在游戏运行时,首先会加载libcompatible.so。我们打开它看看:
仔细观察后发现一个很奇怪的现象,在init_proc函数中间竟然导出了很多符号?
给init_proc下断点,发现在单步到一处CODE64的时候ida就爆段错误,无法继续调试:
但是这处代码只是寄存器操作,人畜无害,不涉及到内存,不应该有段错误。
研究后发现,这是因为ida把代码识别成thumb模式导致的。导出符号的地址为奇数时,ida自动认为是thumb符号,但是实际中的arm64是没有thumb符号的,显然这些符号的存在就是为了干扰ida。
(arm64中出现了thumb模式,这是不应该的,因为只有arm里有thumb)
所以我们通过脚本去除所有在Init_proc中插入的导出符号,具体做法为:
sym_start = 0x88728
sym_count = 0x26c
ini_start = 0x6AA0
ini_end = 0xCC60
inName = "F:\np\libcompatible.so"
outName = "F:\np\libcompatible_p.so"
with open(inName,'rb') as f:
data = list(f.read())
#遍历符号表
for i in range(sym_count):
tmp_sym_start = sym_start + i * 0x18
tmp_value_start = tmp_sym_start + 8 #符号地址
if getInt32(data,tmp_value_start) % 2 == 1 or ( ini_start <= getInt32(data,tmp_value_start) <= ini_end):
#在init函数内,抹零
putInt64(data,tmp_value_start,0)
putInt64(data,tmp_value_start+8,0)
putInt64(data,tmp_value_start+0x10,0)
with open(outName,'wb') as f:
f.write(bytes(data))
全部去除后,init_proc函数就可以愉快的F5了:
但是代码中有很多不透明谓词和虚假分支,我们直接通过修改不透明谓词的段属性为只读,然后将对应的全局变量赋值为0,去除虚假分支,去除后的init_proc:
这样之后,在ida中单步调试也不会出现因为识别为thumb符号而产生的段错误了。
init_proc主要的作用是解密了函数I1,函数I1解密I2,I2解密I3,然后执行I3.同时执行完后会把解密出来的函数加密回去:
2.1 I3函数分析
1.遍历maps文件,找到app_process模块的内存,遍历app_process内存,查找magisk和MAGISK字符串。
2.从给定的变量v282开始,进行栈残留检查,从当前位置检查至栈底,查找magisk和MAGISK字符串。
3.在/proc/self/mounts文件中查找magisk字符串
np的加密代码解密函数全是inline展开的,所以解密部分比较抽象,主要特征是先通过calloc生成一个秘钥空间:
init_array里主要是一些常规的初始化工作。
执行完初始化函数后解密了JNI_OnLoad函数,同时在字符串表里回填了原来被抹去的JNI_Onload字符串。
然后通过svc mmap出了一些内存,完成了其他的初始化工作,就结束了。准备进入JNI_OnLoad。
2.2 JNI_OnLoad函数分析
进入JNI_Onload之后,继续解密大量后面需要用到的函数,然后检查了模拟器:
lib3btrans.so,libhoudini.so,/lib/arm/nb/libc.so,/lib64/arm64/nb/libc.so
根据第二个传入的不同,注册了不同的native函数。
注册完native函数后,进入了一个非常重要的函数:sub_189c(这个函数也是JNI_Onlad里解密出来的)
2.3 sub_189c分析
sub_189c首先调用了KM4PI0Z7J8QMILO5G6P6函数,读取了/storage/emulated/0/Music,/storage/emulated/0/Download,/storage/emulated/0/Documents,/storage/emulated/0/Android等目录,不知道干啥用的。
然后又解密了一个函数——process_libc函数,然后调用之:
2.3.1 process_libc函数
2.3.1.1 保存系统libc函数地址
process_libc函数首先去maps文件里找到内存中libc的位置,然后把找到一些关键函数的地址,保存起来供后面间接调用:
其中找libc函数地址的方法比较别致,不是传统的dlsym,而是先解密函数名,然后遍历libc的hash表和符号表,通过函数名的GNU Hash来寻找函数地址:
get_libc_func_addr_by_gnuhash函数:
但是这些libc的间接调用函数好像被弃用了,np接下来执行了一个非常骚的操作——Secure Libc。
2.3.1.2 加载Secure Libc
所谓secure libc,即安全libc函数,推测是np对传统的libc函数间接调用的一种改进。
因为传统的libc虽然将直接调用改成了间接调用,但是如果hook系统函数或者下断点,还是能监测到对例如fopen,strcmp等关键函数的调用,暴露行踪。
nprotect首先读取了一份本地的libc文件到内存,然后解析了libc文件的section header:
然后在自己mmap的内存里,开始加载,重定位自己的libc!
首先映射了一份libc文件的.text和.plt到内存,然后复制了系统libc的got表至相应位置:
至此,一份全新的libc已经加载到mmap出来的内存中,神不知鬼不觉。
然后np又把自己的secure libc的关键函数地址收集了一波,自己的libc函数也是间接调用的:
为了保证一些全局一致性,np会把自己libc中的一些函数inline hook,跳转回系统libc,比如malloc,free等函数:
在nprotect中,对libc的调用有两组函数,一组类似scall_fopen,是间接调用系统libc。另一组类似sapi_fopen,是间接调用自己的libc。猜测scall应该是np早期的实现,但是旧代码没有删掉,还保留着。
2.3.2 检查xposed
接下来,np检查了xposed,方法是在maps文件里检查XposedBridge.jar字符串:
2.3.3 antidebug
然后主要是检查Tracerid和frida,如果检测到了就会把子进程父进程一起杀死,也会连带着ida一起杀死,这中间通过管道进行父子进程的通讯,不细说了。
对debug的检测,主要是打开/proc/self/status检查Tracerpid。
对frida的检测也是常规那一套,检查task,fd里有没有gum-js-loop,frida-gadget,frida-agent,
至此,sub189c函数执行完毕,JNI_ONLoad函数也执行完毕。
libcompatible.so里注册的jni函数主要是用来加载一些额外的dex文件,在加载的dex文件中会通过System.loadlibrary函数加载libstub.so。
我们看看libstub.so函数:只有一个init_proc。
阅读汇编知道这里是去地址0x41e70(0x3d000 + 0x4<<12 + 0xe70 )拿一个值x17,然后跳转过去。
通过符号重定位表我们得知,0x41e70里面存的导入函数SoLibraryStart的地址:
而这个函数又是libcompatible.so导出的:
SoLibraryStart函数结构比较清晰,先解密真实的so_library_start函数,执行,然后加密回去。
3.1 real_so_library_start执行流程
我们看看real_so_library_start:
解密时会先通过自定义算法解密原so的所有loadable段,然后通过aes解密+LZ4解压,依次解出原so的字符串表,符号表,基址重定位表,符号重定位表,依赖的动态库字符串。
正常的elf文件是以section header结尾的。但是np加固过的so在section header后藏着原始so的加密数据:
real_so_library_start会先读取文件最后0x14个字节,判断是否存在加密标志:
解密完后会先根据依赖so的名称,通过dlopen加载依赖so:
依赖so加载完后,会先把字符串表和符号表复制回原so的对应位置,同时对原so的一些导入符号的值(主要是导入了libcompatible.so的符号)进行修正:
然后分别根据解密出来的重定位表,做基址重定位和符号重定位:
其中0x403是基址重定位,0x402是符号重定位,这个在之前的文章里介绍过,这里就不再赘述。
完成重定位后,real_so_library_start执行了原so的initarray:
至此,原so的原始数据被正确加载进了内存,可以开始运行。
3.2 修复
其实修复就比较简单了,将所有的解密数据dump下来,回填到对应位置。np解密后的数据比较完整,也有原来so的dynamic段保留:
所以只需要调整一下段的偏移,修正一下section header就行了。
3.3 libstub.so内部窥探
dump修复之后,打开libstub.so,惊喜的发现np竟然没有去符号!!
主要是初始化了scall,然后启动earlyEngineInit线程加载libengine.so,然后注册了大量的native函数,作为java层控制engine的接口:
而这些java函数在libcompatible动态加载的dex里,不在apk本身的dex里。
主要就是启动engine,对engine发command等操作。
还有一些对unity 游戏的hook,由于对我的样本是UE,就不再赘述了。
3.4 load engine
跟入earlyEngineInit函数,发现最终调用了libcompatible.so里的load_engine函数:
load_engine函数和solibrarystart函数执行流程差不多,只不过load_engine是完全自己读文件,加载,解析,重定位,没有用任何系统的加载函数如dlopen之类。(毕竟没有正确的elf头,系统也加载不了)
首先通过svc 0 把libengine.so读进来。
然后执行了解密操作,和solibrarystart算法相同,可以把关键数据都dump下来准备后面修复:
然后调用dlopen加载依赖so,回填字符串表和符号表,修正导入符号,重定位,执行init_array,流程和solibrarystart一样。
由于libengine.so是np自行加载的,没有调用系统api,所以在ida中没法break on load library。在segmeng中也看不到libengine.so的内存,只是一个mmap出来的匿名内存,神不知鬼不觉。
修复libengine.so,除了回填数据,修正program header和section header外,还需要自己添加一个elf 头,根据program header和section header的信息,添加一个正确的ELF header也不难,这里就不再赘述了:
欣喜若狂!libengine.so也没有去符号。并且看到了一大堆检测函数:
10.检查是否安装了非应用市场的app(签名校验)
23.检查android framework 相关odex的完整性。
4.1 斐波那契数列与魔改aes
np中的所有关键字符串都加密了,从libcompatible.so到libstub.so到libengine.so。
通过解密出的函数名可以得知,np把他叫GxEncString:
2,3,5,8,13(0xd),21(0x15),34(0x22)….
原来,np把待解密的数据和对应的秘钥混合在一起了。每0x50个数据为一组,其中有0x10的数据是秘钥,而其他0x40的数据是待解密数据。秘钥正是按照斐波那契数列顺序分布在这0x50个数据中的!
0x40 0x030xf40xff0x500x020xf4 0xff0xac0x020xf4 0xff 0xaf0x9e0x30 0x15
0xb4 0x66 0x34 0x99 0x6e0x450x80 0x9a0xaa0xeb 0x030x430x25 0x7b 0x51 0x02
0x16 0xaf0x260x0b 0x1c 0x11 0x5d 0xe0 0xf9 0xee 0xb0 0x3b 0xb7 0x58 0xa1 0xe9
0x43 0x950x050x7d 0x2d 0xe2 0x010xd90xd50x550x3e 0x73 0x08 0x09 0x57 0xf1
0x7d0x8d 0x2f 0x49 0xf1 0x27 0x9d 0x48 0x970xad0x72 0x3f 0x680xd30x9a 0x17
np会先把加粗的字节抽出来组成秘钥,然后剩下的0x40个字节组合起来作为待解密数据,非常妙。
至于解密算法,一开始以为np用的是普通的aes,但是调试之后发现,这玩意是一个魔改的aes。
具体的,np将生成轮秘钥过程中的T函数的循环左移改成了循环右移,解密过程比较复杂,但是也魔改了。
可以自己搞一份aes的源码,然后针对性修改。轮秘钥过程简单一改就行,解密过程就直接把np的T表扣出来自己对照着写一遍吧。np的魔改aes还原后代码太长就不贴了:
另外,np并不是简单的aes整体解密。而是先加密0x10个字节,将加密后的结果作为秘钥,然后分别与接下来的0x10个字节的数据异或,作为最终的解密结果。
data1 = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
jmpids = [2,3,5,8,0xd,0x15,0x22,0x37,0x9,0x40,0x49,0x39,0x32,0x1b,0x4d,0x18]
def buildKey(rawdata):
outkey = []
outkey.append(0)
for i in range(1,len(jmpids)):
outkey.append(rawdata[jmpids[i]])
for i in range(4):
for j in range(2):
tmp = outkey[4*i+j]
outkey[4*i+j] = outkey[4*i+3-j]
outkey[4 * i + 3 - j] = tmp
return outkey
def decryptStr(raw,item):
offset = item["data"]
rawdata = raw[offset:offset+0x50]
newdata = []
key = buildKey(rawdata)
for i in range(0x50):
if i in jmpids:
continue
else:
newdata.append(rawdata[i])
out = AES.AES128_ECB_encrypt(data1,key)
res = []
end = False
for i in range(5):
for j in range(0x10):
val = (out[j] ^ newdata[i*0x10 + j])&0xff
if val == 0:
end = True
break
else:
res.append(val)
if len(res) > 60:
end = True
out = AES.AES128_ECB_encrypt(newdata[i*0x10:(i+1)*0x10],key)
if end:
break
if "index" in item:
return hex(item["index"])+":"+str(bytes(res))
elif "line" in item:
return item["line"]+":"+str(bytes(res))
通常的安全保护对于字符串就是简单的异或,但是np搞得这么复杂,全部魔改aes,可见其王者的霸气。
4.2 一些检测手段分析
有了字符串解密函数我们就可以把engine的字符串全部解密了,由于检测函数太多就不逐一分析了,只简单分析几个:
4.2.1 root检测:
np对root检查的比较厉害,主要检查了以下文件夹中是否有su等文件存在:
然后通过postcondition调用java层的contioncallback,进行弹窗,然后退出。
4.2.2 对作弊器的检查:
上面只列举了一半,还有另外一大堆,太长了就不展示了。
4.2.3 usb调试检查
主要是native调用ContentResolver检查一些全局变量的值。
其他的检测就不细说了,大家可以自己脱下来慢慢研究,反正都是带符号的,也不难。
4.3 执行流程
这些检测手段是如何执行的?原来是通过之前的libstub.so中注册的java层函数command,来最终调用engine里的不同操作。
AppGuardEngine::command->SecurityEngine::command ->SecureAuthentication::operate
不同的operate执行策略不同,最终会执行到具体的检测函数身上。
至此,nprotect的总体流程就全部分析清楚了。
libcompatible.so和libstub.so脱下来后,我们可以将start_anti_debug,检查xposed,检查magisk等地方全部patch掉,把libstub.so加载libengine的地方直接nop掉,然后做一些适配(AppGuardEngine::command直接retrun之类)然后把so扔回去,发现游戏可以正常启动了。
抓包后发现登录请求返回了正常的服务器信息,应该不是游戏自己的保护,仔细研究libUE4.so后发现,在游戏的逻辑里还调用了np的服务器校验:
这里的服务器校验并不是真正的发送web请求,而是调用java层的一些bridge函数,设置一些np相关的数据:
认证之后会将一个全局变量置0,然后走游戏的启动逻辑:
libUE4.so也被np加固了,走的是solibrarystart的解密逻辑,和libstub.so相同。我们直接脱出来把认证这里patch掉就好。
patch掉之后,终于在一台通过magisk root的手机上,成功启动了重打包过的传奇m手游。
至此nProtect已经被我们完全扒光脱干净,并且所有保护全部patch掉,可以随便修改重打包了。
如果你看到这里,并完全理解了np的保护流程,那么你一定知道nProtect的保护有多么吓人了。
其中随便一个自定义linker,gnuhash拿出来,都是国产游戏保护的核心技术,更不用说三层so保护,各种多进程多线程保护,门类繁多的各种类型的检测,java层so层加固,无elf头的so文件加载,SecureLibc,魔改aes加密字符串等等。
当然,np最骚的还是他的所有关键函数都是解密出来的。。小弟我一个一个dump,dump了十几处,否则根本没法分析:
nProtect最核心的部分叫做libengine,从实现上来说,他确实可以称得上是一个引擎了——甚至自己实现了内存池。
这次逆向的过程一度想要放弃,但是还是在水友们的鼓励下坚持了下来,每一个大的技术点啃下来,都觉得酸爽无比,比如魔改的aes,要一步步调试aes的每个过程,秘钥拓展的每一步。包括securelibc,流程看起来简单清晰,但是在ida里对着各种混淆,一步步调试,看懂他在干啥,也花了一天时间。过程很艰难,收获也很丰富。
看雪ID:乐子人
https://bbs.kanxue.com/user-home-872365.htm
*本文为看雪论坛精华文章,由 乐子人 原创,转载请注明来自看雪社区
原文始发于微信公众号(看雪学苑):神挡杀神——揭开世界第一手游保护nProtect的神秘面纱