游戏是steam上一款单机音游,难度有点高,在被虐了千百次后,我决定对这个游戏下手。
探秘
大家都知道,unity游戏的主要逻辑都在Assembly-CSharp.dll,只要用dnspy之类的工具就能够轻易的反编译出源码。于是我兴冲冲的掏出了我的dnspy,将Assembly-CSharp.dll拖了进去,然而一片空白的dnspy告诉我事情没这么简单。
使用010 editor打开文件,发现并不是标准的PE格式,DOS头的标志MZ被修改为了ML。
那就老规矩,开启游戏,对mono.dll的mono_image_open_from_data_with_name下断点观察,结果发现游戏并没有在这部分解密PE文件。抱着不想的预感,使用CE搜索了一下这个游戏的前几个字节,结果不出意料。
可以看到游戏并没有直接解密文件,外面长啥样内存里还是啥样。看来游戏应该是对mono的代码进行了修改,用自己的规则来加载文件。那没有办法,只能老老实实的跟着代码走一遍。
解密
将mono.dll拖进ida,一般加载dll都会走到mono_image_open_from_data_with_name,所以我们直接定位到这里,然后从github下了一份mono的源码作为对照。顺着流程走下去会走到do_mono_image_load函数,这个函数就是用来解析加载dll文件的。
PEHeader部分
首先看到pe_image_load_pe_data,这个函数是用来解析PE Header部分的,通过对比可以看出这个函数与源码不一样,是被修改过的,ida F5代码如下
1 | _BOOL8 __fastcall pe_image_load_pe_data(__int64 image) |
首先可以看到被修改过的mono在识别DOS头标志的时候用的不是MZ而是ML,与修改过的dll文件一致,然后在读取0x3c位置也就是NtHeader偏移值的时候减去了0x4D4C。do_load_header主要是记录一下IMAGE_NT_HEADERS结构,与源码没什么太大的差异,唯一的区别就是在识别NtHeader标志的时候用的不是PE而是ML,与修改过的dll一致。
1 | signed __int64 __fastcall do_load_header(__int64 image, char *header, int e_lfanew) |
接下来看看load_section_tables
1 | signed __int64 __fastcall load_section_tables(__int64 image, __int64 iinfo, unsigned int offset) |
这个函数主要是用来解析SectionHeader部分的。首先可以看到在读取NumberOfSections时加上了1,然后再接着解析SectionHeader。解析SectionHeader的时候对其中的PointerToRawData也动了手脚,操作是PointerToRawData-(i+1)*0x4D4C,其中i是SectionHeader的索引(从0开始)。
PEHeader部分有改动的解析就结束了,总结一下这个游戏对PEHeader的处理就是:
1、修改了DosHeader和NtHeader的标志,将MZ和PE修改成了ML。
2、修改了指向NtHeader的偏移值。
3、将NumberOfSections减去1。
4、修改了SectionHeader中的PointerToRawData。
按着修改方式逆处理一下就算把PEHeader修复完了,使用010 editor的模板也能正常识别了。于是我高高兴兴的将修复后的文件再次扔进dnspy,发现事情远远没有这么简单。
没办法,只能老老实实的接着往下看了。
CLIHeader部分
这一部分就是.net CLI文件特有的部分了,在开始着手这个游戏之前我对这部分基本没有了解,只能现学现卖了。网上关于这部分的中文资料基本等于没有,只好阅读官方文档ECMA 335了,在这里我简单的介绍一下,CLI文件的概览如下
可以看到除了有传统的PE文件部分之外,还有CLI特有的部分,比如CLIHeader。那这个CLIHeader位于文件中的哪里呢?答案就在PEHeader的OptionalHeader->DataDirectory[14]中,文档的说明如下
所以我们可以在这里获取CLIHeader的RVA。再来看看CLIHeader的结构
其中比较重要的就是MetaData元数据了,比如程序中每个方法的IL都可以通过元数据找到,元数据的具体介绍大家可以自己百度或者阅读文档,我这个半吊子就不在这里献丑了。通过CLIHeader中的MetaData我们可以找到MetadataRoot,也就是描述Metadata几个table的地方,下面是MetadataRoot的结构
对CLI文件格式的介绍暂时到这里,有兴趣的可以自行翻阅文档,接下来回到代码当中。在执行完pe_image_load_pe_data后,mono会执行pe_image_load_cli_data来解析CLIHeader部分。通过对比发现与源码中不同的部分在load_metadata_ptrs中,mono的源码对MetadataRoot中signature的判断是这样的
而游戏中的mono是这样的
看了一下dll中的signature也是WSML(我是Mengluu?),与游戏中的mono对应的上。
将文件中的WSML修改为BSJB后再次丢进dnspy,令人惊喜的发现可以看到东西了
正当我高兴的开始准备翻阅的时候,现实又给了我当头一棒。
函数反编译失败了。
opcode部分
将反编译方式切换至IL可以发现应该是opcode被替换了
这就麻烦了,在百度+谷歌了一段时间过后得出的结论就是通过阅读mono_method_to_ir,人肉识别出被修改的opcode与原opcode的对应关系。看了一下mono_method_to_ir的源代码,我心态瞬间崩了。
尝试着用ida F5了一下该函数,decompile了半天才出来结果,F5出来的伪代码一眼看去接近两万行,随便改个变量名都要卡半天。没办法,只能把F5抠掉看汇编了。分析opcode没什么好讲的,纯粹就是体力活。在分析了大概几十个opcode之后才发现了规律(我太菜了),原来就是把opcode 0xB3-0xC1插到了0x00的前面,用源码中的opcode.def文件来表示的话大概就是这样
1 | /* GENERATED FILE, DO NOT EDIT. Edit cil-opcodes.xml instead and run "make opcode.def" to regenerate. */ |
感谢作者没有完全打乱,否则不知道要看到猴年马月。
opcode修复
没想到这一步卡了我好久,在找了半天合适的轮子未果后(不得不说我的搜索能力实在是不太行),在坛友@艾莉希雅 的帮助下,我找到了ilasm和ildasm这两个工具,可以在github的coreclr里找到这两个工具的源码。首先修改源码中的opcode.def为上面提到的样子之后编译ildasm,用修改过的ildasm反编译游戏的dll文件为IL,再用正常的ilasm编译刚才生成的IL,即可得到opcode正确的dll文件。将这个新的dll拖入dnspy后即可正常反编译。
至此,这个游戏的dll文件应该就被正常解密了
结语
通过这个unity游戏的逆向学习了一波CLI文件,并且亲自分析了一遍opcode(以前都是云的),感觉收获还蛮大的。然而解密dll后顿时索然无味,为啥不好好玩游戏呢,然后就没有然后了。还有,关于替换opcode这一点我很好奇大家都是怎么做的,希望各位不吝赐教。