5 针对不同运行时的unity游戏鲁棒性加强实现方案
5.1 unity生成库文件后插桩操作
unity引擎库文件破坏名称的最佳时机本应是代码编译前直接针对源代码进行批量名称替换,但由于每次替换均需要人力参与,因此如此实现所需的人力成本过大,极不适用于目前国内环境的自动化打包。
而当游戏整体已经生成完成后再进行元数据破坏,会造成关系分析困难,且因为“木已成舟”大量元数据存在强依赖而无法破坏大部分名称,照成元数据破坏效果大打折扣。
为了平衡成本与效益,将元数据破坏的时机延后到库文件生成完成时,且资源依赖尚未形成时为佳,且此阶段易于与自动化打包结合,使得质量检测部门可以尽早发现因库混淆出现的问题。
图 5.1 国内高度自动化的打包推送
图 5.1 国内高度自动化的打包推送
图 5.2 机械化流水打包构建环境
图 5.2 机械化流水打包构建环境
5.1.1 分析unity生成过程寻找插桩点
Unity游戏生成主要有“Build Scene”、“Build Resource”、“Compiling shader variants”、“Postprocessing Player”等,根据平台或配置不同还有“Stripping assemblies” 、“Mesh data optimization”等优化类配置。
因5.1中提及的原因,我们寻找的插桩点在库生成完毕且资源尚未生成时便可。其中的“Build Scene”之后便是库文件已经生成完成资源即将开始生成的时间节点。根据最小化修改的原则,在添加我们需要的功能时我们最好使用现有的实现避免直接通过逆向工程修改unity引擎。而“UnityEditor.Callbacks”内部所提供的回调之一“PostProcessSceneAttribute”恰好可以满足我们的需要。
通过添加[PostProcessScene(1)]于插桩函数头部我们便可在第一个Scene生成完毕后触发我们的代码,将unity生成的库文件内部的元数据尽可能的破坏掉。
插桩函数局部代码:
[PostProcessScene(1)]//触发第一个PostProcessScene后进行混淆操作
public static void OnPostProcessScene(){
if (EditorUtility.DisplayDialog("库生成已完成", "Unity相关库已经编译完成是否进行下一步操作", "确定","不了")){
string targetDir = @"Library\PlayerScriptAssemblies\";
string targetFile = "Assembly-CSharp.dll";
string targetBakFile = "Assembly-CSharp.bakup.dll";
if (File.Exists(targetDir + targetFile)) {...}
if (EditorUtility.DisplayDialog("选择元数据破坏模式", "使用内置简易破坏还是使用第三方破坏", "内置", "第三方")){...}
else {...}
EditorUtility.RevealInFinder(targetDir + targetFile);//显示相关文件
}}
5.1.2 破坏库文件元数据
Unity库文件的元数据,主要分为“Namespaces”、“Members”、“Properties”、“Methods”、“Types”,而常规CSharp存在的元数据“Resource”在unity中并无作用可以完全无视之。
由于Unity内部实现混乱,为了做到最大兼容我们可以简单粗暴的将BaseType为“UnityEngine”的所有元数据都列入白名单不作处理,但这种简单粗暴的方式实属掩耳盗铃,因国内unity开发的习惯BaseType不为“UnityEngine”的元数据基本寥寥无几。
为了使得元数据破坏的效果得到保证,使用一刀切的方式明显是不合适的。经过逆向工程的分析与测试,影响Unity运行的关键部分仅仅是BaseType为“UnityEngine”的部分Methods,其影响范围主要集中在BaseType为 “UnityEngine.MonoBehaviour”的"Awake","FixedUpdate","LateUpdate",OnAnimatorIK","OnAnimatorMove","OnApplicationFocus","OnApplicationPause","OnApplicationQuit","OnAudioFilterRead","OnBecameInvisible"等Methods与“UnityEngine.StateMachineBehaviour”的"OnStateMachineEnter","OnStateMachineExit","OnStateEnter","OnStateExit","OnStateIK","OnStateMove","OnStateUpdate"。 将上述的Methods加入到白名单中,即可避免绝大多数的崩溃情况,如有其它崩溃情况,发现后再加上去就完事了。
在针对unity的特殊性建立了白名单后,剩余元数据破坏规则就可以直接按普通CSharp库的方式处理即可。
图 5.3 元数据破坏对比
图 5.3 元数据破坏对比
5.2 il2cpp的插桩操作
Unity游戏编译方式之一便是il2cpp,将易于被反编译逆向的CSharp转换为C++在提升性能的同时也无意中提升了游戏安全性。而为了维护CSharp语言的特性,在转换过程中它会将大多数CSharp元数据填入global-metadata.dat中。而其格式因Unity的编译需要直接暴露公开,因此针对il2cpp的名称还原方案在互联网上数不胜数。在经过5.1中的库元数据破坏后,编译成il2cpp后的global-metadata包含的数据也是我们破坏后的,但针对il2cpp编译方式我们仍有其他方式继续增强其安全性。
5.2.1 修复生成il2cpp的恶性BUG
Unity在生成il2cpp中存在一个至今未有其他人发现的恶性BUG,在转换破坏元数据后的CSharp库到CPP时会引发崩溃。且此错误在搜索引擎与其他私有文献中均未提及。
图 5.4 il2cpp崩溃
图 5.4 il2cpp崩溃
经过逆向工程的分析,位于“Unity.IL2CPP.Metadata.VTableBuilder”中的“private static Dictionary<MethodReference, MethodDefinition> CollectOverrides(TypeDefinition typeDefinition)”会引发il2cpp生成错误,原因是其进行“dictionary.Add(key, methodDefinition);”操作前未判断key是否存在于dictionary中导致il2cpp.exe运行出现崩溃现象,该bug触发条件是存在相同Methods时便会触发。根据分析多个版本的il2cpp发现,该BUG存在时间极长。推测该BUG目前尚未被人发现的原因是因正常的CSharp编译器在同一个namespace中不允许出现同名的method、type。
在Add操作前增加“if (!dictionary.ContainsKey(key))”进行判断即可解决该BUG。经过逆向工程的修复后,可以编译出能正常运行il2cpp运行时的游戏。
图5.5 修复后的CollectOverrides
图5.5 修复后的CollectOverrides
5.2.2还原游戏符号信息需要的资讯
通过分析各还原工具,我们可知还原游戏符号信息需要的文件有两个,一个是经由il2cpp转换编译后的游戏库文件,另一个是global-metadata.dat。而还原工具通过其算法寻找Il2CppCodeRegistration与Il2CppMetadataRegistration两个结构体的位置,解析留存的元数据生成地址函数名称等一一对应的关系信息。
图 5.6 工具还原的地址与函数名称对应关系
图 5.6 工具还原的地址与函数名称对应关系
5.2.3隐藏文件对抗分析
通过将global-metadata.dat文件直接嵌入il2cpp中,实现将其隐藏。同时可以通过极低性能损失的异或等简单加密方式或更为复杂的AES、DES甚至是性能消耗丧心病狂的RSA来加密global-metadata.dat。
但是整体加密global-metadata.dat的意义并不大,在如图5.2中我们可以看到,Unity引擎在读取global-metadata.dat后会将其整体放入内存中,此时整个文件都是处于解密状态下,直接对设备内存进行dump便可导出明文的global-metadata.dat。且由于如图5.3中的“sanity”存在,在内存中寻找到该元数据文件是十分简单的。
因此对其进行加密的意义并不大,且会严重影响游戏的启动性能。
图5.7 global-metadata.dat加载代码片段
图5.7 global-metadata.dat加载代码片段
图5.8 global-metadata.dat中的sanity
图5.8 global-metadata.dat中的sanity
5.2.4更改数据结构对抗自动分析
由于Il2CppCodeRegistration与Il2CppMetadataRegistration的寻找难度较小,即使通过其他手段隐藏或变造了指向此处的指针地址也可以通过手工分析的方式快速寻找到两个结构体的位置,尝试隐藏两个结构体的效果并无太大作用。
但通过修改Il2CppCodeRegistration与Il2CppMetadataRegistration我们可以达到使相关的工具直接报废的效果,重新定义一个结构即可。例如简单调换Il2CppCodeRegistration的reversePInvokeWrapperCount与invokerPointersCount。在修改struct Il2CppCodeRegistration或struct Il2CppMetadataRegistration的同时,我们需要使用逆向工程技术,修改unity转化il2cpp的过程,使得生成的代码符合我们修改的结构体。
表5.1 Il2CppCodeRegistration数据结构
名称 |
类型 |
reversePInvokeWrapperCount |
uint32_t |
表 5.2 Il2CppMetadataRegistration数据结构
5.2.5加密数据对抗静态手工分析
上一节的处理方式可以很好的对抗自动化工具的分析,使游戏鲁棒性有一定的提升。但由于Il2CppCodeRegistration与Il2CppMetadataRegistration的数据实际上仍明文暴露,手工分析虽然成本高,但实际上配合自动化工具仍可以寻找到两个数据存在的位置,只要对反汇编进行一定分析便可找到结构体对应的数据。
因此,对关键数据进行处理是十分有必要的,通过分析调用堆栈,发现两个数据结构的终点“il2cpp::vm::MetadataCache::Register”是最佳的修改点,但为了简化本文的书写与分析,本次的修改位点为两个数据结构的起点也就是 “Il2CppCodeRegistration.cpp”,在其中新建“解密”算法。如图所示,被加密的CodeRegistration不再直接展示,而是在运行时解密再传入“il2cpp_codegen_register”最终被使用。
图 5.9 CodeRegistration或MetadataRegistration相关堆栈
图 5.9 CodeRegistration或MetadataRegistration相关堆栈
为了方便说明,本次的“加密”方式为将真实数据乘以十,而解密是将加密数据除以十,在实际使用中建议选用更强大的算法,解密的位置也应结合到“il2cpp::vm::MetadataCache::Register”甚至是每一个调用点处,确保关键数据不集中解密形成致命弱点。
图 5.10 “加密”后的CodeRegistration对比
图 5.10 “加密”后的CodeRegistration对比
解密CodeRegistration局部代码:
Il2CppCodeRegistration D_CodeRegistration = { 0 };//初始化结构体
Il2CppCodeRegistration decrypt_g_CodeRegistration(Il2CppCodeRegistration E_CodeRegistration)//解密操作,应结合在实际读写处,此示例仅作演示{
D_CodeRegistration.reversePInvokeWrapperCount = DecryptUint32(E_CodeRegistration.reversePInvokeWrapperCount);
……//略去相似的解密过程
D_CodeRegistration.codeGenModules = E_CodeRegistration.codeGenModules;
return D_CodeRegistration;//返回解密后的CodeRegistration
}
void s_Il2CppCodegenRegistration(){
D_CodeRegistration= decrypt_g_CodeRegistration(g_Enc_CodeRegistration);//传入前解密
il2cpp_codegen_register (&D_CodeRegistration, &g_MetadataRegistration, &s_Il2CppCodeGenOptions);
}
经过加密后,可见我们的CodeRegistration已经被全部“加密”,在实际使用中它甚至可以被全部打散至应用程序各处,甚至可以将该结构体的数据放置到网络文件中获取。
图 5.11 “加密”后的CodeRegistration
图 5.11 “加密”后的CodeRegistration
5.3 unity生成mono运行时游戏
作为Unity运行时之一的mono,伴随了unity不知多少个版本,时至今日仍在使用mono运行时的游戏仍然占据着半壁江山。在经过元数据破坏后,根据函数名称直接进行爆破式修改的难度增加了不少,但仍可以针对其运行时特点进一步增加逆向难度。
5.3.1 破坏反编译CSharp库的必要信息
Unity生成的mono运行时库,其本质与正常CSharp并无区别,但其运行时mono与微软的.net framework又或是.net core截然不同。因其实现不同,因此有些在其他运行时下必须的部分信息在mono运行时下并无作用,将其抹除并不会影响其运行。但是将部分信息抹除后,反编译工具便不能识别CSharp库。
5.3.2 整体加密游戏的CSharp库
与il2cpp运行时的global-metadata.dat相似,mono运行时所必要的CSharp库也可以进行整体加密。但进行整体加密的意义并不大,因为最终mono运行时会将解密的CSharp库整体的放入内存中,只需要简单的完整dump内存便可轻松还原此类整体加密。
如图所示,在“mono_image_open_from_data_internal”加入解密CSharp库的代码即可实现在加载时解密CSharp库,因为解密过程性能损失极大,因此仅加密核心的“Assembly-CSharp.dll”库是最具有性价比的选择,根据需求也可以加密其他库,甚至可以加密全部库文件。
但本方案的缺陷如代码所示,被加密的库在解密时会整体明文出现在运行内存中,因此在游戏运行后只需要对内存进行导出操作即可获取到全部解密后的库文件,且因解密后的文件必然与静态存储的文件内容不一致,因此便不能使用内存映射的方式读取库文件,而只能全部读入内存中操作。因此该方案对内存的需求也极大,运行效率极低,保护效果极差。
图 5.12 解密加密的CSharp库
图 5.12 解密加密的CSharp库
5.3.3 修改mono运行时OPCode
Unity游戏的mono运行时为开源软件,其代码可于github寻找到源代码自行编译。拥有mono运行时源代码我们便可在任何一阶段改变其运行方式以增强其安全性。我们可以整体加密CSharp库文件,也可以加密每个Method名称,运行时解密。更可以通过重新定义OPCode的映射关系,在最大程度上加强防护。例如将CEE_CALL(0x28)与CEE_JMP(0x27)映射进行对换,CEE_POP(0x26)与CEE_DUP(0x25)映射进行对换,CEE_BRFALSE_S(0x2C)与CEE_BRTRUE_S(0x2D)进行对换。在目前版本的mono中OPCode的数量有332个,假设将OPCode全部两两对换将可以创建出繁复多变的映射关系。
理论上,直接简单暴力的重新映射全部OPCode确实可以形成极大的保护强度,但由于国内开发习惯,游戏通常都会引入大量诸如bugly、umeng等第三方SDK或插件,由于第三方插件使用的是mono运行时的标准OPCode映射关系,这将会造成他们无法在我们的定制版mono上运行,通过逆向工程替换第三方插件或SDK使用的OPCode虽然可以解决该问题,但有极大的法律风险,为了最大程度提高兼容性同时降低法律风险,简单暴力的对OPCode映射关系进行重定义是不可取的。
根据分析开源的mono运行模式,我们可以寻找到在jit运行时下用于处理opcode映射关系的“mono_method_to_ir”该函数位于“mono\mini\method-to-ir.c”中,该函数以“switch (*ip)”开始,通过上百个case实现各个OPCode的操作。只要在其中添加我们自定义的OPCode使其跳入对应的case中,便可等效替换OPCode。
加入opcode.def文件的新OPCode:
OPDEF(CEE_LDARG_0_0_YYY, "ldarg.0.0", Pop0, Push1, InlineNone, X, 1, 0xFF, 0xB1, NEXT)
OPDEF(CEE_CALLYYY, "call", VarPop, VarPush, InlineMethod, X, 1, 0xFF, 0xB2, CALL)
以call为例进行自定义,于“mono\cil\opcode.def”内添加我们的自定义映射CEE_CALLYYY(0xB2),同时在“mono\mini\method-to-ir.c”的CEE_CALL(0x28)附近添加我们的等效OPCode,在这里我们还可以发现部分不同的OPCode的实际实现是一样的,例如图中的CEE_CALL与CEE_CALLVIRT两个case最终执行的代码是一致的。
图 5.13 mono_method_to_ir新增CEE_CALL等效的CEE_CALLYYY
图 5.13 mono_method_to_ir新增CEE_CALL等效的CEE_CALLYYY
同时我们可以批量替换游戏CSharp库中的全部CEE_CALL为CEE_CALLYYY,再重新尝试进行反编译,如图所示反编译工具无法还原代码,OPCode为UNKNOWN1。
图5.14 批量替换后
图5.14 批量替换后
图 5.15 批量替换前
图 5.15 批量替换前
被替换为自定义OPCode后通用的反编译程序因为不能解析我们的自定义OPCode,因此不能解析应用程序的IL码,更不能将代码直接反编译为高可见性的CSharp代码。
图 5.16 被识别为UNKOWN1的自定义OPCode
图 5.16 被识别为UNKOWN1的自定义OPCode
图5.17 反编译因未知OPCode已不能识别代码
图5.17 反编译因未知OPCode已不能识别代码
5.3.4 新增mono运行时OPCode
Unity游戏的mono运行时为开源软件,因此即便是重新映射OPCode后仍旧有可能花费极大的代价,人工动态调试通过每个OPCode行为来猜测出原始的OPCode。为了防患未然,在重定义OPCode的方法上我们还可以在此处进行更强的操作,那便是新增OPCode。
OPCode的新增需要对整个mono十分熟悉,分析mono运行时并非本文的主要内容,为了降低说明门槛,可以尝试以组合OPCode的形式新增一个具有两个OPCode功能效果的新OPCode。本实例以两个CEE_LDARG家族的指令为例,如图所示的源代码片段所示,从CEE_LDARG_0至CEE_LDARG_3的实现本质上是相同的指令,理论上把全部CEE_LDARG家族的指令替换成同一个并不会影响运行,但就理论而言我们要注意他们MSIL的语义是不同。
图 5.18 CEE_LDARG家族指令
图 5.18 CEE_LDARG家族指令
下面的例子是以两个“ldarg.0”为例重新组合为一个“ldarg.0.0”,实质上该指令等价任意两个CEE_LDARG家族指令。将CEE_LDARG的实现简单的复制两次,并把重复CHECK与EMIT_NEW_ARGLOAD全部注释以避免访问到错误的内存造成崩溃,一个OPCode只能进行一次EMIT_NEW_ARGLOAD。
图 5.19 新定义的OPCode“ldarg.0.0”
图 5.19 新定义的OPCode“ldarg.0.0”
新增的OPCode“CEE_LDARG_0_0_YYY”的实现代码:
case CEE_LDARG_0_0_YYY:
CHECK_STACK_OVF (1);//栈检查
n = (*ip) - CEE_LDARG_0_0_YYY;//IP确立
CHECK_ARG (n);//参数检查
EMIT_NEW_ARGLOAD (cfg, ins, n);//值运算
ip++;//IP自增
*sp++ = ins;//sp修改
n = (*ip) - CEE_LDARG_0_0_YYY;//IP第二次确立
ip++;//IP第二次自增
*sp++ = ins;//SP第二次修改
break; //中断case
在新的指令诞生后,我们便可在Unity的CSharp库中尝试替换使用,通过修改现存的指令替换为新增的指令,我们便可以达到让攻击者无法仅通过人肉方式观察特征猜测出对应关系,因为该指令在原始的OPCode中根本不存在。我们甚至可以使用单个OPCode实现变种的MD5摘要算法或修改过的AES加密算法,给攻击者造成极大的困扰。
图 5.20 替换两个CEE_LDARG家族指令为新指令CEE_LDARG_0_0_YYY
图 5.20 替换两个CEE_LDARG家族指令为新指令CEE_LDARG_0_0_YYY
在修改本处时要格外注意由于Unity是闭源游戏引擎,mono库仅为外部引用因此我们无法进行动态调试,出现问题只能依赖日志进行排查。因此在修改OPCode实现时我们要尽可能的打印更多日志,方便我们定位问题所在。
图 5.21 访问无效地址错误
图 5.21 访问无效地址错误