原文地址

对于任何一款要长期线上运营的游戏,防破解防外挂是必不可少的。本文总结了手游常用的防破解防外挂技术方案,这些方案都经过了笔者所在团队和线上项目的长期考验。很多方案来自于弱联网手游项目,但大部分思路也同样适用于强联网游戏。以Unity为例,但思路也适用于非Unity项目。笔者尽可能做到总结全面,希望能帮助大家形成一个整体的防御思路。

强联网游戏的特点是很多逻辑在服务端计算,重要数据由服务端控制,客户端多数时候着重于表现。而弱联网游戏因为要求玩家能在不联网或网络环境很差的情况也能正常玩,所以客户端可能包含了很多重要的游戏逻辑和数据,服务端则提供一些额外的业务逻辑,比如作弊校验,数据同步,排行榜,各种联网活动等。如果我们信赖客户端的逻辑和数据,那么一旦客户端被破解,整个游戏就会被操控,轻者损失了部分玩家,重者会污染游戏的整个生态环境。最麻烦的是,破解者只要有代码,本质上被破解就只是个成本和时间的问题。但是,我们仍有各种方式来抵御常见的破解和外挂。对于那些根本上很难防住的破解方式,我们至少能大大增加其破解成本。

本文从两方面来总结:客户端和服务端。这篇先讲客户端,分为几个章节:

  • 加固

  • 内存加密

  • 代码混淆

  • 破解apk

  • 资源加密

  • 玩家存档加密

  • 时间防作弊

加固

加固是对代码做各种形式的变换,比如加密,混淆,隐藏等,以提高代码逆向的难度。这是所有游戏都通用的一个技术,有不少公司提供了成熟的解决方案,比如网易,腾讯,乐变。已有的加固技术包括:

1 加壳

目的是防止二次打包。对加壳后的apk包重签名,进游戏时会闪退。

加壳分两种方式:

(1)dex加固:比较成熟,很多厂商采用的解决方案,比如乐变。

(2)so加固:比较新,网易易盾用的此方案,native层加密,更安全可靠。

2 反调试

目的是防止IDA动态调试。

这部分没什么需要过多考虑的,建议直接从这些成熟的解决方案中挑选一个应用于项目。

内存加密

网上有一些内存修改器可以搜索和修改内存数据,从而实现各种夸张的效果,比如金币无限,血量无限,攻击力无限等。常用的工具有八门神器,葫芦侠,烧饼修改器。他们的使用原理都是类似的,比如,若要修改玩家当前的金币数,先用工具在内存中搜索当前的金币数值,会搜出来很多内存地址。然后消耗一些金币,在之前的内存地址中再搜索当前的金币数,得到较少的匹配地址。重复该步骤,直到只剩一个地址匹配,就是存放金币的内存地址。最后,通过工具更改该地址存储的数值,就能把金币数改成一个很大的数值。

要防止这种工具的破解,就需要对内存数据做加密,让工具搜索不到该数据所在的内存地址。最简单的方案是:

1 准备一个key值,不要用字符串明文,得是运行期动态生成的。

2 存数据时,先把数据和一个key做异或操作,再存到内存。

3 读数据时,把从内存读出的数据和同样的key做异或,返回给上层。

该方案简单高效,能防住大部分内存修改器,但有一些搜索功能比较强大的工具,比如烧饼修改器有模糊搜索功能,仍能搜索到经过加密的数据。于是我们需要一个更强大的方案。

由于这些内存修改器都是在搜索到的内存地址集合里再次搜索筛查,所以只要不停地变换数据存储的地址,就能从根本上防住这种修改器。具体做法是:

对于任何一个需要加密的数据类型:

1 分配N个同类型元素的数组,N至少为3。

2 每次存储数据时,数组index加1,若超出数组长度则index归零,然后将数据和一个key做异或,得到加密数据,将其存储到该index指向的数组槽。记录下当前的index和key。

3 读取数据时,根据存储的index,读取数组槽中的数据,和key做异或,将结果返回。

实测下来,经过这样的处理后,烧饼修改器也完全无法搜索到其内存地址,所以能有效防住这种类型的工具。该方案听说在腾讯内部项目里使用了,笔者自己在Unity里实现了一套加密数据类型,可直接拿来在项目中使用,放在Github上[1]

https://github.com/nichos1983/CSEncryptType

该代码实现的要点:

1 用泛型尽量精简了代码。

2 实现了类型转换的操作符,这样能最大程度简化已有项目的重构,比如若要将基础数据类型更改为加密数据类型,只需要更改变量声明处的类型,比如将int改为EncryptInt,其他的上层代码不需要做任何改动,自定义的类型转换操作符会帮助编译器处理剩下的工作。

需要注意的是,实际项目中应全面地对任何游戏界面可见的关键性数据做加密,比如金币,血量,攻击力等。而且,所有会和关键性数据做运算的相关数据,也得用加密类型。比如,有一个游戏内弹框界面,上面可以让玩家自由选择要购买的道具数量及对应的金币花费,那么此处的金币花费的变量也应做加密。否则,玩家通过多次更改道具数量,就能用工具很容易地搜索出金币花费对应的地址,然后将其修改为0或者负数,再进行购买,就能达到买道具不花钱或者买完金币增加的效果。防破解这种事,百密一疏就会导致严重的问题,所以在防御上要尽量考虑全面。

代码混淆

网上有各种工具能对Unity游戏的dll文件做反编译,或者对so文件做反汇编。Dll反编译后,所有代码就非常可读,毫无安全性。所以我们需要把代码中的各种元素,比如类名,函数名,变量名,改成无意义或很难看懂的名字,使得破解者即使反编译了代码也很难读懂,从而加大破解难度。常用的Unity代码混淆工具有Obfuscator,Obfuscar,CodeGuard等,这些工具大部分都是在.Net IL层修改字节码,不影响正常开发流程。另外,还有很多针对iOS和安卓原生层的工具。

以Obfuscator插件为例,有一个名为ObfuscatorOptions的配置文件,其中很多设置会影响混淆的强度。值得注意的设置有:

1 Name mapping history

勾选,混淆时会生成符号映射文件,记录混淆前后的名字映射关系。

2 Rename

选择哪些被混淆。对于上层接入了lua的项目,就只勾选private和protected的函数和变量,不对public成员做混淆。因为public函数可能被lua层调用,如果做混淆,那么lua代码也要相应做修改,无法方便地维护。

函数名被混淆后,会带来一些不便:

(1)崩溃统计后台显示的是混淆后的名字,如果是private或protected函数,就需要查符号映射表得到混淆前的名字。

(2)若接入了xlua代码热修复,那么热修复private或protected函数时,也需要查符合映射表,调用xlua_hotfix时得传入混淆后的函数名。

3 Fake code

勾选后会增加垃圾代码,通过改变一些fake相关的参数可以调整混淆的强度。需要注意fake code加得越多会导致代码尺寸越大,一是会增加包体,二是在IL2CPP模式下,iOS包体代码尺寸可能会超过苹果规定的限制,从而导致审核上传时被拒。

4 Unity methods

该列表中的函数不会被混淆,可根据项目自身需求删减。除了这个列表,对于自己写的lua层回调函数,使用了反射调用的函数,和Inspector里绑定的事件函数,还可以在函数声明前加[SkipRename]属性来避免被混淆。

代码混淆的作用除了增加破解难度以外,还能用于应付苹果审核。苹果对马甲包的审核很严格,如果你的app和其他app在代码和资源上相似度很高,就会有审核被拒的风险。代码混淆工具就可以用来人为制造二进制包的差异化。但是,由于流行的混淆工具都是在IL层把各种名字改为随机的类似乱码的名字,二进制的特征和正常app是不同的,可能会在苹果机审阶段被查出来,导致被拒。很多开发者就因为过度使用了混淆工具,收到了苹果爸爸类似这种回信:

We discovered that your app contains obfuscated code, selector mangling, or features meant to subvert the App Review process by changing this app's concept after approval to the App Store. The next submission of this app may require a longer review time, and this app will not be eligible for an expedited review until this issue is resolved.

所以,为了避免不必要的审核风险,建议大家不要过度依赖这些混淆工具,可以自己写一些脚本,在源代码层或IL层处理字符串替换。

破解apk

破解apk包的危害很大。破解者可以把包破解后,传到网上供人下载。对于Unity apk包,网上已经有比较统一的破解流程,这里做一个简单的总结。下面的方法能处理未做加固加壳处理的,若做了加固加壳,就会使得一些文件结构被修改,方法就不一定奏效了。

Unity有两种脚本后端模式:mono和il2cpp。mono比较老,现在大部分游戏使用了il2cpp。Apk解包后,通过里面的文件信息能判断是哪一种模式:

1 如果assets/bin/Data/Managed/下有一堆dll文件,其中有Assembly-CSharp.dll,则是mono

2 如果assets/bin/Data/Managed/下有三个文件夹:etc/,Metadata/,Resources/,则是il2cpp

不管是mono或il2cpp,破解流程都大致如下:

1 解包

可用apktool运行命令解包abc.apk:

apktool d -r abc.apk 

得到同名文件夹。注意用命令行解包,若把apk的后缀改为zip解压缩,得到的文件夹中会缺少apktool.yml文件,到后面重新打包时会报错:

brut.directory.PathNotExist: apktool.yml

2 修改代码

解包后根据文件信息判断是mono还是il2cpp。

对于mono包:

(1)Windows机器上安装.Net Reflector和Reflexil插件,用它打开assets/bin/Data/Managed/Assembly-CSharp.dll。

(2)查看反编译的dll代码,尝试去找需要破解的逻辑,直接修改IL代码,或写源代码然后用Reflexil编译成IL。

(3)将修改后的代码导出为新的Assembly-CSharp.dll,覆盖前面解包目录下的同名文件。

对于il2cpp包:

(1)用il2cppDumper工具[2],根据这两个文件:

  • lib/armeabi-v7a/libil2cpp.so:包含所有可执行汇编代码

  • assets/bin/Data/Managed/Metadata/global-metadata.dat:包含符号表信息

运行il2cppDumper,会生成两个文件:

  • dump.cs:包含所有函数及地址信息

  • script.py或ida.py(由il2cppDumper版本决定):作为IDA的脚本后面使用

(2)查看dump.cs,尝试去找自己感兴趣的函数信息。

(3)用IDA打开libil2cpp.so,先运行script.py或ida.py添加各种符号的可读信息,若是ida.py,还需要选择script.json。这时各种类和函数都具有了可读的字符串名字。找到需要破解的逻辑地址,修改汇编代码。

(4)将修改后的代码导出为新的libil2cpp.so,覆盖解包目录下的同名文件。

3 重签名打包

(1)运行命令:

keytool -genkey -keystore mykey.keystore -keyalg RSA -validity 10000 -alias mykey 

得到mykey.keystore文件。

(2)运行命令:

apktool b abc 

得到abc.apk文件,位于目录abc/dist/。

(3)运行命令签名打包:

jarsigner -digestalg SHA1 -sigalg MD5withRSA -verbose -keystore mykey.keystore -signedjar abc_signed.apk abc/dist/abc.apk mykey 

得到新包abc_signed.apk。

网上有些教程里会加上-tsa参数,测试下来会导致报错:

jarsigner error: java.lang.NullPointerException

上述破解方式的关键还是在于读懂反编译或反汇编的代码,找到关键逻辑代码做修改。破解者可能会搜索user,level,coin这种常见的关键字,进而很容易就找到关键逻辑。所以,我们可以尽量混淆这些关键类名,函数名,变量名等,改成一些难读懂甚至具有误导性的名字,就能增加破解的难度。但是,如前面所说,这些都只是增加了破解难度,只要有代码,破解就只是时间和成本问题。

针对这种破解方式,有些安全方案对这些静态文件做了保护。mono模式下,对Assembly-CSharp.dll做加密,改变了PE文件格式,使得反编译工具无法识别。il2cpp模式下,可对so文件做加密,或对global-metadata.dat符号文件做保护,使得工具无法还原出符号信息,也增加了破解难度。

资源加密

普通的未加密的ipa和apk包,我们可以用工具解包,很容易得到资源的明文形式。对于Unity包,可以用资源查看工具(比如AssetStudio)解出Resources目录下的资源和各种AssetBundle资源。所以我们需要对资源做加密,以保证至少无法用工具简单地解包。

一般Unity项目的很多资源都打成了AssetBundle,所以需要对AssetBundle做加密。很容易想到的方式是:

1 构建打AssetBundle包时,对资源做对称加密

2 运行期加载时,先把AssetBundle加载到内存,用key解密,得到解密后的AssetBundle内存

3 调用AssetBundle.LoadFromMemory(Async)接口从内存中加载资源,初始化对象

这一切看起来很清晰完美。但不幸的是,用AssetBundle.LoadFromMemory(Async)加载资源,会导致内存使用量暴增。一份资源通过该接口加载,会在内存里出现三份拷贝,除了资源本身在系统层或GPU层有一份,还会在Native层和托管层里各有一份。如果是LZMA格式,会先解压缩再存储,内存消耗比资源原始资源尺寸更大。所以,官方其实不推荐使用该接口[3]

那么,还有更简单的方式吗?也有,UWA提供了一个加密方式[4],通过给AssetBundle文件内容加一个偏移,就能做到无法用资源查看工具直接读取其内容。该方案的优点是简单高效,不耗额外内存,但缺点也很明显,它的防护强度很弱。

除了AssetBundle,ScriptableObject资源也没有简便的加密方式。所以,Unity在设计上就没有很好地支持资源加密,可能是因为国外没有我们国内市场的一些困扰。Unity中国团队针对我们的国情,出了个Unity增强版,接口上直接支持了AssetBundle的加密,使用起来很简单[5]。是否合适好用就由大家各自判断了。

除了Unity格式的资源,对于通用格式的资源,比如csv,json,xml,lua文件等,可能也包含非常重要的信息,并且文件尺寸通常不大。就可以用前面提到的方式,打包时做对称加密,运行期先读到内存做解密,然后加载初始化。

需要注意的是,不管加密什么格式的资源,加密的密钥务必要隐藏好,至少不要用明文字符串,应在运行期用算法动态生成,然后尽可能让这个函数不容易被发现和读懂。每发布一次版本,都可以更换一次密钥,使得破解者用老版本的密钥无法破解新版本的资源。

另外,网上有VirBox Protector这种加固工具,也包含了资源加密的功能。

玩家存档加密

重要的数据都需要加密。和资源一样,玩家存档本质也是一种重要的数据,会序列化成文件,所以加密思路和资源加密类似。不同的是存档数据由玩家玩的时候动态生成,而且可能在不同代码版本间流通,需要考虑兼容性。对于强联网游戏,玩家存档数据中重要的部分都存储在服务端,只要设计得当,客户端无论如何怎么修改数据,都不会导致严重的后果。但对于弱联网游戏,玩家在没联网的情况也能玩,就不得不以客户端的数据为主导,防破解的难度很大。

存档可存放在自定义的文件中,这种情况下加密方式可以和资源加密一样。对于Unity包,本地存档常放在PlayerPrefs中,本质上是键值对,我们无法对PlayerPrefs整个文件操作,就可以对键和值分别做加密,或只对值做加密。和资源加密一样,注意保护好加密密钥。如果要更换密钥,需要处理数据的前后兼容问题。除了文件加密外,玩家存档在内存中的数据应做内存加密。

一种破解方式是,玩家把自己的存档文件传到网上,其他玩家下载下来复制到本地,实现存档转移。比如有些游戏淘宝上就有卖家将高进度或破解后的个人存档出售。为了防御这种情况,可以让一个玩家的存档包含了自己的标识符信息,使得在另一个玩家的设备上无法打开。一个简单的方案是,存档的加密密钥有玩家UDID或设备ID参与,比如用原始密钥和UDID做异或拼接等操作,或者原始密钥和UDID的MD5做异或操作。

时间防作弊

很多游戏功能依赖于系统时间,比如体力恢复,建筑升级,各种CD时间。对于强联网游戏,所有时间都由服务端控制,比较好处理。弱联网游戏则相对比较麻烦。如果完全信任本地时间,那么玩家可通过修改本地系统时间来达到很多目的。所以,整体思路是,联网的时候完全信任网络时间。没联网的时候,就用系统本地时间。等到联网后再对时间做校正,以及做作弊判定。

网络时间可通过NTP协议或自己的服务端获取。NTP其实不太可靠,有时会连不上,建议使用自己的服务端。注意由于网络传输的延时及不稳定性,获取到的网络时间会在真实时间值附近波动,所以在作弊判定时,应留有足够的阈值。

iOS或安卓原生层都有接口可获取设备开机到现在的流逝时间,比如在安卓上,接口是SystemClock.elapsedRealtime()。该数值不会受到玩家修改本地时间而影响,所以是一个更值得信赖的数值。但该接口的问题是设备重启后,这个数值会重新从零开始计算。

借助这个设备启动流逝时间的机制,可设计一个联网时完全可靠的时间获取逻辑,不受玩家调整本地时间的影响。方案如下:

1 游戏启动后开启协程获取网络时间,若没网络或没获取到就隔一段时间再触发,直到获取成功。

2 获取到网络时间时,记录获取到的网络时间为N1,记录此刻设备重启后流逝的时间D1。

3 以后任意时刻要获取当前的时间,就先获取此时设备重启后流逝的时间D2,计算当前时间为:

Tn = N1 + (D2 - D1)

N1,D1,D2都是完全可信赖的,所以任意时刻的Tn也是准确的。

由于访问原生层接口可能会有一定性能消耗,如果时间获取调用频率很高,就可以优化为每帧只访问一次原生层接口,缓存该值,该帧的后续操作都访问缓存的值,直到下一帧再调用原生层接口。

没联网的时候,就使用系统本地时间。再次联网时,对时间做校正,以及作弊判定。要判定玩家是否修改了系统本地时间来作弊,有如下方式:

1 正常情况下,玩家的本地时间和联网时间可能有一定差值。但只要玩家不调本地时间,该差值应几乎在某一固定值附近波动。如果检测到该差值有很大变化,就可以判定为作弊。

2 正常情况下,玩家的本地时间会一直往前走。如果检测到本地时间有后退的情况,就可以判定为作弊。

判定为作弊后,如何惩罚玩家,就取决于业务需求了。

有一种时间外挂叫加速齿轮,可以加速本地时间的流逝。这个也可以通过联网时本地时间和联网时间的差值来判定,如果该差值呈现一个稳定线性递增的模式,就可以判定为使用了时间加速功能。

参考

1 https://github.com/nichos1983/CSEncryptType

2 https://github.com/Perfare/Il2CppDumper

3 https://learn.unity.com/tutorial/assets-resources-and-assetbundles

4 https://blog.uwa4d.com/archives/USparkle_AssetBundle.html

5 https://connect.unity.com/p/unityzeng-qiang-ban-gong-neng-assetbundlejia-mi-shuo-ming-wen-dang


What doesn’t kill you makes you stronger.