葡萄美酒夜光杯,欲饮琵琶马上催。
醉卧沙场君莫笑,古来征战几人回。
——致nProtect
如果说某易的保护是手游保护届的东岳泰山,某嘉德是西岳华山,某讯的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中插入的导出符号,具体做法为:
1.遍历符号表。
2.找到地址在init_proc范围中的符号。
3.将对应的符号表内容全部抹0
具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
但是代码中有很多不透明谓词和虚假分支,我们直接通过修改不透明谓词的段属性为只读,然后将对应的全局变量赋值为0,去除虚假分支,去除后的init_proc:
这样之后,在ida中单步调试也不会出现因为识别为thumb符号而产生的段错误了。
init_proc主要的作用是解密了函数I1,函数I1解密I2,I2解密I3,然后执行I3.同时执行完后会把解密出来的函数加密回去:
进入I3函数之后,np首先检查了magisk:
检测方式为:
1.遍历maps文件,找到app_process模块的内存,遍历app_process内存,查找magisk和MAGISK字符串。
2.从给定的变量v282开始,进行栈残留检查,从当前位置检查至栈底,查找magisk和MAGISK字符串。
3.在/proc/self/mounts文件中查找magisk字符串
然后解密了大量的函数。
np的加密代码解密函数全是inline展开的,所以解密部分比较抽象,主要特征是先通过ca个秘间:
然后初始化秘钥:
解密完后执行了自己的Init_array函数:
init_array里主要是一些常规的初始化工作。
执行完初始化函数后解密了JNI_OnLoad函数,同时在字符串表里回填了原来被抹去的JNI_Onload字符串。
然后通过svc mmap出了一些内存,完成了其他的初始化工作,就结束了。准备进入JNI_OnLoad。
进入JNI_Onload之后,继续解密大量后面需要用到的函数,然后检查了模拟器:
sub_189c首先调用了KM4PI0Z7J8QMILO5G6P6函数,读取了/storage/emulated/0/Music,/storage/emulated/0/Download,/storage/emulated/0/Documents,/storage/emulated/0/Android等目录,不知道干啥用的。。
process_libc函数首先去maps文件里找到内存中libc的位置,然后把找到一些关键函数的地址,保存起来供后面间接调用:
其中找libc函数地址的方法比较别致,不是传统的dlsym,而是先解密函数名,然后遍历libc的hash表和符号表,通过函数名的GNU Hash来寻找函数地址:
所谓secure libc,即安全libc函数,推测是np对传统的libc函数间接调用的一种改进。
因为传统的libc虽然将直接调用改成了间接调用,但是如果hook系统函数或者下断点,还是能监测到对例如fopen,strcmp等关键函数的调用,暴露行踪。
所以np的做法是什么?
直接自己加载一份自己的libc,不用系统的!
nprotect首先读取了一份本地的libc文件到内存,然后解析了libc文件的section header:
为了保证一些全局一致性,np会把自己libc中的一些函数inline hook,跳转回系统libc,比如malloc,free等函数:
至此,SecureLibc算是加载完成了。
在nprotect中,对libc的调用有两组函数,一组类似scall_fopen,是间接调用系统libc。另一组类似sapi_fopen,是间接调用自己的libc。猜测scall应该是np早期的实现,但是旧代码没有删掉,还保留着。
使用sapi的安全性又搞了一大截。
至此,process_libc执行完成。
接下来,np检查了xposed,方法是在maps文件里检查XposedBridge.jar字符串:
然后主要是检查Tracerid和frida,如果检测到了就会把子进程父进程一起杀死,也会连带着ida一起杀死,这中间通过管道进行父子进程的通讯,不细说了。
对debug的检测,主要是打开/proc/self/status检查Tracerpid。
对frida的检测也是常规那一套,检查task,fd里有没有gum-js-loop,frida-gadget,frida-agent,
linjector-等字符串:
libcompatible.so里注册的jni函数主要是用来加载一些额外的dex文件,在加载的dex文件中会通过System.loadlibrary函数加载libstub.so。
我们看看libstub.so函数:只有一个init_proc
解密时会先通过自定义算法解密原so的所有loadable段,然后通过aes解密+LZ4解压,依次解出原so的字符串表,符号表,基址重定位表,符号重定位表,依赖的动态库字符串。
正常的elf文件是以section header结尾的。但是np加固过的so在section header后藏着原始so的加密数据:
如果匹配上,就会走解密流程。
解密完后会先根据依赖so的名称,通过dlopen加载依赖so:
其实修复就比较简单了,将所有的解密数据dump下来,回填到对应位置。np解密后的数据比较完整,也有原来so的dynamic段保留:
所以只需要调整一下段的偏移,修正一下section header就行了。
dump修复之后,打开libstub.so,惊喜的发现np竟然没有去符号!!
主要是初始化了scall,然后启动earlyEngineInit线程加载libengine.so,然后注册了大量的native函数,作为java层控制engine的接口:
主要是间接调用kill 和 exit。
还有一些对unity 游戏的hook,由于对我的样本是UE,就不再赘述了
我们看看libengine.so:
跟入earlyEngineInit函数,发现最终调用了libcompatible.so里的load_engine函数:
load_engine函数和solibrarystart函数执行流程差不多,只不过load_engine是完全自己读文件,加载,解析,重定位,没有用任何系统的加载函数如dlopen之类。(毕竟没有正确的elf头,系统也加载不了)
然后调用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的elf头
赶快拖进ida看看。
欣喜若狂!libengine.so也没有去符号。并且看到了一大堆检测函数:
依次有:
1.检查是否安装了bad的app。
2.检查libdvm.so的完整性。
3.检查andoriddebug。
4.检查是否是自定义rom。
5.检查是否是weird(奇怪的)内核。
6.检查smith的完整性。(不知道啥玩意)
7.检查动态库是否存在plt hook。
8.检查是有使用app虚拟化技术(啥玩意)
9.检查engine文件的完成性。
10.检查是否安装了非应用市场的app(签名校验)
11.检查app lib的完整性。
12.检查odex文件的完整性。
13.检查root。
14.检查so文件是否被注入。
15.检查系统文件的odex完整性。
16.检查libc的完整性。
17.检查unity的完整性。
18.检查是否有加速外挂(单位时间是否正常)
19.检查模拟器。
20.检查libart.so的完整性。
21.检查是否存在内存扫描。
22.检查是否存在usb调试。
23.检查android framework 相关odex的完整性。
24.检查内存中odex的完整性。
25.检查是否存在作弊器。
........
检查条目之多,令人惊叹!
np中的所有关键字符串都加密了,从libcompatible.so到libstub.so到libengine.so。
通过解密出的函数名可以得知,np把他叫GxEncString:
aes总体来讲有两步,生成轮秘钥和解密。
np既修改了生成轮秘钥的过程,也修改了解密过程。
具体的,np将生成轮秘钥过程中的T函数的循环左移改成了循环右移,解密过程比较复杂,但是也魔改了。
可以自己搞一份aes的源码,然后针对性修改。轮秘钥过程简单一改就行,解密过程就直接把np的T表扣出来自己对照着写一遍吧。。。np的魔改aes还原后代码太长就不贴了:
另外,np并不是简单的aes整体解密。而是先加密0x10个字节,将加密后的结果作为秘钥,然后分别与接下来的0x10个字节的数据异或,作为最终的解密结果。。。
解密流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
通常的安全保护对于字符串就是简单的异或,但是np搞得这么复杂,全部魔改aes,可见其王者的霸气。。
有了字符串解密函数我们就可以把engine的字符串全部解密了,由于检测函数太多就不逐一分析了,只简单分析几个:
np对root检查的比较厉害,主要检查了以下文件夹中是否有su等文件存在:!
检查的文件:
然后还检查了一些root框架:
如果检查到root,会给全局变量赋值为1:
上面只列举了一半,还有另外一大堆,太长了就不展示了。。
主要是native调用ContentResolver检查一些全局变量的值
这些检测手段是如何执行的?原来是通过之前的libstub.so中注册的java层函数command,来最终调用engine里的不同操作。
AppGuardEngine::command->SecurityEngine::command ->SecureAuthentication::operate
->各种operate策略
不同的operate执行策略不同,最终会执行到具体的检测函数身上。
至此,nprotect的总体流程就全部分析清楚了。
libcompatible.so和libstub.so脱下来后,我们可以将start_anti_debug,检查xposed,检查magisk等地方全部patch掉,把libstub.so加载libengine的地方直接nop掉,然后做一些适配(AppGuardEngine::command直接retrun之类)然后把so扔回去,发现游戏可以正常启动了。
但是启动之后点击登录没法进入战斗。
抓包后发现登录请求返回了正常的服务器信息,应该不是游戏自己的保护,仔细研究libUE4.so后发现,在游戏的逻辑里还调用了np的服务器校验:
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 ,无愧为手游保护届的真神。。。
nProtect最核心的部分叫做libengine,从实现上来说,他确实可以称得上是一个引擎了——甚至自己实现了内存池。。
这次逆向的过程一度想要放弃,但是还是在水友们的鼓励下坚持了下来,每一个大的技术点啃下来,都觉得酸爽无比,比如魔改的aes,要一步步调试aes的每个过程,秘钥拓展的每一步。包括securelibc,流程看起来简单清晰,但是在ida里对着各种混淆,一步步调试,看懂他在干啥,也花了一天时间。。
虽然很艰难,但是完成之后只想说一句:nProtect,牛批!
最后的最后,还是那句老话——逆向工程师永远胜利!!!