神挡杀神——揭开世界第一手游保护nProtect的神秘面纱

0.前言

葡萄美酒夜光杯,欲饮琵琶马上催。

醉卧沙场君莫笑,古来征战几人回。

——致nProtect

如果说某易的保护是手游保护届的东岳泰山,某嘉德是西岳华山,某讯的ace是中岳嵩山,那么nProtect一定是手游保护的珠穆朗玛峰。

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第1张图片

至于nProtect的名声和地位,我想对游戏保护稍有了解的人一定早有耳闻,无需多言。

小弟我早在今年2月份的时候就接触了nProtect保护的手游——传奇m Global,但是当时被其复杂的代码动态解密和ollvm混淆劝退,其后又尝试了几次,还是放弃。

上个月在水友群里看到有老哥在研究np,问了一下他怎么处理那些加密的函数的,他说一个一个dump:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第2张图片

这确实是一个方法,但是因为太麻烦我就一直没有尝试,这次看到有人这样做了,那我也想着铁着头试一试,虽然麻烦。于是还真的借着“笨办法”终于把np给搞定了。前后一共花了快3周时间,研究后发现nProtect用到的技术之高妙,检测之周密,架构之庞大,实属罕见。遂整理成文章,与诸位研究安全的同仁做一分享。

1.导出符号干扰

打开游戏的lib目录,可以看到libcompatible.so,libstub.so和libengine.so三个特征动态库:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第3张图片

在游戏运行时,首先会加载libcompatible.so。我们打开它看看:

有一个初始化函数,但是无法F5:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第4张图片

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第5张图片 

 仔细观察后发现一个很奇怪的现象,在init_proc函数中间竟然导出了很多符号???神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第6张图片

甚至有的导出函数的地址是奇数??!

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第7张图片

给init_proc下断点,发现在单步到一处CODE64的时候ida就爆段错误,无法继续调试:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第8张图片

 

 

但是这处代码只是寄存器操作,人畜无害,不涉及到内存,不应该有段错误。

研究后发现,这是因为ida把代码识别成thumb模式导致的。导出符号的地址为奇数时,ida自动认为是thumb符号,但是实际中的arm64是没有thumb符号的,显然这些符号的存在就是为了干扰ida。神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第9张图片

(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

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 in range(sym_count):

    tmp_sym_start = sym_start + * 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了:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第10张图片

 

但是代码中有很多不透明谓词和虚假分支,我们直接通过修改不透明谓词的段属性为只读,然后将对应的全局变量赋值为0,去除虚假分支,去除后的init_proc:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第11张图片

这样之后,在ida中单步调试也不会出现因为识别为thumb符号而产生的段错误了。

init_proc主要的作用是解密了函数I1,函数I1解密I2,I2解密I3,然后执行I3.同时执行完后会把解密出来的函数加密回去:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第12张图片

2.libcompatible.so

2.1 I3函数分析

进入I3函数之后,np首先检查了magisk:

 神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第13张图片

检测方式为:

1.遍历maps文件,找到app_process模块的内存,遍历app_process内存,查找magisk和MAGISK字符串。

2.从给定的变量v282开始,进行栈残留检查,从当前位置检查至栈底,查找magisk和MAGISK字符串。

3.在/proc/self/mounts文件中查找magisk字符串

然后解密了大量的函数。

np的加密代码解密函数全是inline展开的,所以解密部分比较抽象,主要特征是先通过ca个秘间:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第14张图片

 

然后初始化秘钥:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第15张图片

 最后在ollvm里执行解密操作神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第16张图片

解密完后执行了自己的Init_array函数:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第17张图片

 

init_array里主要是一些常规的初始化工作。

执行完初始化函数后解密了JNI_OnLoad函数,同时在字符串表里回填了原来被抹去的JNI_Onload字符串。

然后通过svc mmap出了一些内存,完成了其他的初始化工作,就结束了。准备进入JNI_OnLoad。

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第18张图片

2.2 JNI_OnLoad函数分析

进入JNI_Onload之后,继续解密大量后面需要用到的函数,然后检查了模拟器:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第19张图片

2.3 sub_189c分析

sub_189c首先调用了KM4PI0Z7J8QMILO5G6P6函数,读取了/storage/emulated/0/Music,/storage/emulated/0/Download,/storage/emulated/0/Documents,/storage/emulated/0/Android等目录,不知道干啥用的。。

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第20张图片 

2.3.1 process_libc函数

2.3.1.1 保存系统libc函数地址

process_libc函数首先去maps文件里找到内存中libc的位置,然后把找到一些关键函数的地址,保存起来供后面间接调用:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第21张图片

其中找libc函数地址的方法比较别致,不是传统的dlsym,而是先解密函数名,然后遍历libc的hash表和符号表,通过函数名的GNU Hash来寻找函数地址:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第22张图片

 

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第23张图片 

 

所谓secure libc,即安全libc函数,推测是np对传统的libc函数间接调用的一种改进。

因为传统的libc虽然将直接调用改成了间接调用,但是如果hook系统函数或者下断点,还是能监测到对例如fopen,strcmp等关键函数的调用,暴露行踪。

所以np的做法是什么?

直接自己加载一份自己的libc,不用系统的!

nprotect首先读取了一份本地的libc文件到内存,然后解析了libc文件的section header:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第24张图片

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第25张图片

为了保证一些全局一致性,np会把自己libc中的一些函数inline hook,跳转回系统libc,比如malloc,free等函数:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第26张图片 

至此,SecureLibc算是加载完成了。

在nprotect中,对libc的调用有两组函数,一组类似scall_fopen,是间接调用系统libc。另一组类似sapi_fopen,是间接调用自己的libc。猜测scall应该是np早期的实现,但是旧代码没有删掉,还保留着。

使用sapi的安全性又搞了一大截。

至此,process_libc执行完成。

2.3.2 检查xposed

接下来,np检查了xposed,方法是在maps文件里检查XposedBridge.jar字符串:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第27张图片

 

然后主要是检查Tracerid和frida,如果检测到了就会把子进程父进程一起杀死,也会连带着ida一起杀死,这中间通过管道进行父子进程的通讯,不细说了。

对debug的检测,主要是打开/proc/self/status检查Tracerpid。

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第28张图片

对frida的检测也是常规那一套,检查task,fd里有没有gum-js-loop,frida-gadget,frida-agent,

linjector-等字符串:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第29张图片

3.libstub.so

libcompatible.so里注册的jni函数主要是用来加载一些额外的dex文件,在加载的dex文件中会通过System.loadlibrary函数加载libstub.so。

我们看看libstub.so函数:只有一个init_proc

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第30张图片

解密时会先通过自定义算法解密原so的所有loadable段,然后通过aes解密+LZ4解压,依次解出原so的字符串表,符号表,基址重定位表,符号重定位表,依赖的动态库字符串。神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第31张图片

正常的elf文件是以section header结尾的。但是np加固过的so在section header后藏着原始so的加密数据:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第32张图片 

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第33张图片 

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第34张图片 

如果匹配上,就会走解密流程。

解密完后会先根据依赖so的名称,通过dlopen加载依赖so:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第35张图片

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第36张图片 

3.2 修复

其实修复就比较简单了,将所有的解密数据dump下来,回填到对应位置。np解密后的数据比较完整,也有原来so的dynamic段保留:神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第37张图片

所以只需要调整一下段的偏移,修正一下section header就行了。

3.3 libstub.so内部窥探

dump修复之后,打开libstub.so,惊喜的发现np竟然没有去符号!!

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第38张图片

 主要是初始化了scall,然后启动earlyEngineInit线程加载libengine.so,然后注册了大量的native函数,作为java层控制engine的接口:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第39张图片

主要是间接调用kill 和 exit。

还有一些对unity 游戏的hook,由于对我的样本是UE,就不再赘述了

3.4 load engine

我们看看libengine.so:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第40张图片

跟入earlyEngineInit函数,发现最终调用了libcompatible.so里的load_engine函数:

load_engine函数和solibrarystart函数执行流程差不多,只不过load_engine是完全自己读文件,加载,解析,重定位,没有用任何系统的加载函数如dlopen之类。(毕竟没有正确的elf头,系统也加载不了)

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第41张图片 

然后调用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头

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第42张图片

赶快拖进ida看看。

4.libengine.so

欣喜若狂!libengine.so也没有去符号。并且看到了一大堆检测函数:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第43张图片

依次有:

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.检查是否存在作弊器。

........

检查条目之多,令人惊叹!

4.1 斐波那契数列与魔改aes

np中的所有关键字符串都加密了,从libcompatible.so到libstub.so到libengine.so。

通过解密出的函数名可以得知,np把他叫GxEncString:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第44张图片 

aes总体来讲有两步,生成轮秘钥和解密。

np既修改了生成轮秘钥的过程,也修改了解密过程。

具体的,np将生成轮秘钥过程中的T函数的循环左移改成了循环右移,解密过程比较复杂,但是也魔改了。

可以自己搞一份aes的源码,然后针对性修改。轮秘钥过程简单一改就行,解密过程就直接把np的T表扣出来自己对照着写一遍吧。。。np的魔改aes还原后代码太长就不贴了:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第45张图片

另外,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

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 in range(1,len(jmpids)):

        outkey.append(rawdata[jmpids[i]])

    for in range(4):

        for in range(2):

            tmp = outkey[4*i+j]

            outkey[4*i+j] = outkey[4*i+3-j]

            outkey[4 * + 3 - j] = tmp

    return outkey

def decryptStr(raw,item):

    offset = item["data"]

    rawdata = raw[offset:offset+0x50]

    newdata = []

    key = buildKey(rawdata)

    for in range(0x50):

        if in jmpids:

            continue

        else:

            newdata.append(rawdata[i])

    out = AES.AES128_ECB_encrypt(data1,key)

    res = []

    end = False

    for in range(5):

        for 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等文件存在:!神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第46张图片

检查的文件:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第47张图片 

然后还检查了一些root框架:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第48张图片

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第49张图片 

如果检查到root,会给全局变量赋值为1:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第50张图片

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第51张图片 

 神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第52张图片

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第53张图片 

 

上面只列举了一半,还有另外一大堆,太长了就不展示了。。

4.2.3 usb调试检查

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第54张图片

主要是native调用ContentResolver检查一些全局变量的值
神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第55张图片 

4.3 执行流程

这些检测手段是如何执行的?原来是通过之前的libstub.so中注册的java层函数command,来最终调用engine里的不同操作。

AppGuardEngine::command->SecurityEngine::command ->SecureAuthentication::operate

->各种operate策略

不同的operate执行策略不同,最终会执行到具体的检测函数身上。

至此,nprotect的总体流程就全部分析清楚了。

5.最后的保护——服务器认证

libcompatible.so和libstub.so脱下来后,我们可以将start_anti_debug,检查xposed,检查magisk等地方全部patch掉,把libstub.so加载libengine的地方直接nop掉,然后做一些适配(AppGuardEngine::command直接retrun之类)然后把so扔回去,发现游戏可以正常启动了。

但是启动之后点击登录没法进入战斗。

抓包后发现登录请求返回了正常的服务器信息,应该不是游戏自己的保护,仔细研究libUE4.so后发现,在游戏的逻辑里还调用了np的服务器校验:

 

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第56张图片 

libUE4.so也被np加固了,走的是solibrarystart的解密逻辑,和libstub.so相同。我们直接脱出来把认证这里patch掉就好。

patch掉之后,终于在一台通过magisk root的手机上,成功启动了重打包过的传奇m手游。

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第57张图片

 

至此nProtect已经被我们完全扒光脱干净,并且所有保护全部patch掉,可以随便修改重打包了,爽~

6.后记

如果你看到这里,并完全理解了np的保护流程,那么你一定知道nProtect的保护有多么吓人了。

其中随便一个自定义linker,gnuhash拿出来,都是国产游戏保护的核心技术,更不用说三层so保护,各种多进程多线程保护,门类繁多的各种类型的检测,java层so层加固,无elf头的so文件加载,SecureLibc,魔改aes加密字符串等等。。

当然,np最骚的还是他的所有关键函数都是解密出来的。。小弟我一个一个dump,dump了十几处,否则根本没法分析:

神挡杀神——揭开世界第一手游保护nProtect的神秘面纱_第58张图片

 

nProtect ,无愧为手游保护届的真神。。。

nProtect最核心的部分叫做libengine,从实现上来说,他确实可以称得上是一个引擎了——甚至自己实现了内存池。。

这次逆向的过程一度想要放弃,但是还是在水友们的鼓励下坚持了下来,每一个大的技术点啃下来,都觉得酸爽无比,比如魔改的aes,要一步步调试aes的每个过程,秘钥拓展的每一步。包括securelibc,流程看起来简单清晰,但是在ida里对着各种混淆,一步步调试,看懂他在干啥,也花了一天时间。。

虽然很艰难,但是完成之后只想说一句:nProtect,牛批!

最后的最后,还是那句老话——逆向工程师永远胜利!!!

 

 

 

 

 

 

 

 

你可能感兴趣的:(大神分析,游戏)