对于任何一款要长期线上运营的游戏,防破解防外挂是必不可少的。本文总结了手游经常使用的防破解防外挂技术方案,这些方案都通过了笔者所在团队和线上项目的长期考验。不少方案来自于弱联网手游项目,但大部分思路也一样适用于强联网游戏。以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]:
该代码实现的要点:
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:
获得同名文件夹。注意用命令行解包,若把apk的后缀改成zip解压缩,获得的文件夹中会缺乏apktool.yml文件,到后面从新打包时会报错:javascript
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文件。html
(2)运行命令:
获得abc.apk文件,位于目录abc/dist/。java
(3)运行命令签名打包:
jarsigner -digestalg SHA1 -sigalg MD5withRSA -verbose -keystore mykey.keystore -signedjar abc_signed.apk abc/dist/abc.apk mykey
获得新包abc_signed.apk。git
网上有些教程里会加上-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 正常状况下,玩家的本地时间会一直往前走。若是检测到本地时间有后退的状况,就能够断定为做弊。
断定为做弊后,如何惩罚玩家,就取决于业务需求了。
有一种时间外挂叫加速齿轮,能够加速本地时间的流逝。这个也能够经过联网时本地时间和联网时间的差值来断定,若是该差值呈现一个稳定线性递增的模式,就能够断定为使用了时间加速功能。
参考